[
  {
    "path": ".dockerignore",
    "content": "# Ignores everything except dist/ prisma/ apps/\n*\n!dist/\n!prisma/\n!package.json\n!apps/client/env.sh"
  },
  {
    "path": ".editorconfig",
    "content": "# Editor configuration, see http://editorconfig.org\nroot = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 2\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.md]\nmax_line_length = off\ntrim_trailing_whitespace = false\n"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n    \"root\": true,\n    \"ignorePatterns\": [\"**/*\", \"**/*.png\"],\n    \"plugins\": [\"@nrwl/nx\", \"eslint-plugin-json\"],\n    \"overrides\": [\n        {\n            \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n            \"rules\": {\n                \"@nrwl/nx/enforce-module-boundaries\": [\n                    \"error\",\n                    {\n                        \"enforceBuildableLibDependency\": true,\n                        \"allow\": [],\n                        \"depConstraints\": [\n                            {\n                                \"sourceTag\": \"scope:shared\",\n                                \"onlyDependOnLibsWithTags\": [\"scope:shared\"]\n                            },\n                            {\n                                \"sourceTag\": \"scope:app\",\n                                \"onlyDependOnLibsWithTags\": [\"*\"]\n                            },\n                            {\n                                \"sourceTag\": \"scope:client-shared\",\n                                \"onlyDependOnLibsWithTags\": [\"scope:client-shared\", \"scope:shared\"]\n                            },\n                            {\n                                \"sourceTag\": \"scope:server-shared\",\n                                \"onlyDependOnLibsWithTags\": [\"scope:server-shared\", \"scope:shared\"]\n                            },\n                            {\n                                \"sourceTag\": \"scope:server\",\n                                \"onlyDependOnLibsWithTags\": [\n                                    \"scope:server\",\n                                    \"scope:server-shared\",\n                                    \"scope:shared\"\n                                ]\n                            },\n                            {\n                                \"sourceTag\": \"scope:client\",\n                                \"onlyDependOnLibsWithTags\": [\n                                    \"scope:client\",\n                                    \"scope:client-shared\",\n                                    \"scope:shared\"\n                                ]\n                            }\n                        ]\n                    }\n                ]\n            }\n        },\n        {\n            \"files\": [\"*.ts\", \"*.tsx\"],\n            \"extends\": [\"plugin:@nrwl/nx/typescript\"],\n            \"rules\": {\n                \"@typescript-eslint/no-unused-vars\": [\"warn\", { \"argsIgnorePattern\": \"^_\" }],\n                \"@typescript-eslint/no-non-null-assertion\": \"off\",\n                \"@typescript-eslint/no-explicit-any\": \"off\",\n                \"@typescript-eslint/ban-types\": \"off\",\n                \"@typescript-eslint/ban-ts-comment\": \"off\",\n                \"@typescript-eslint/consistent-type-assertions\": \"error\",\n                \"@typescript-eslint/consistent-type-imports\": [\n                    \"error\",\n                    { \"fixStyle\": \"inline-type-imports\" }\n                ]\n            }\n        },\n        {\n            \"files\": [\"*.js\", \"*.jsx\"],\n            \"extends\": [\"plugin:@nrwl/nx/javascript\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.tsx\", \"*.jsx\"],\n            \"rules\": {\n                \"jsx-a11y/anchor-is-valid\": [\n                    \"error\",\n                    {\n                        \"components\": [\"Link\"],\n                        \"specialLink\": [\"hrefLeft\", \"hrefRight\"],\n                        \"aspects\": [\"invalidHref\", \"preferButton\"]\n                    }\n                ]\n            }\n        }\n    ]\n}\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n\n-   OS: [e.g. iOS]\n-   Browser [e.g. chrome, safari]\n-   Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n\n-   Device: [e.g. iPhone6]\n-   OS: [e.g. iOS8.1]\n-   Browser [e.g. stock browser, safari]\n-   Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-request-or-improvement.md",
    "content": "---\nname: Feature request or improvement\nabout: Suggest a new feature or improvement\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "name: Docker\n\non:\n  push:\n    branches: [ \"main\" ]\n    tags: [ 'v*.*.*' ]\n  pull_request:\n    branches: [ \"main\" ]\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n  NODE_ENV: production\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n      id-token: write\n    strategy:\n      fail-fast: false\n      matrix:\n        image: [\"ghcr.io/${{ github.repository }}\", \"ghcr.io/${{ github.repository }}-worker\", \"ghcr.io/${{ github.repository }}-client\"]\n        include:\n          - image: \"ghcr.io/${{ github.repository }}\"\n            dockerfile: \"./apps/server/Dockerfile\"\n            nx: \"server:build\"\n          - image: \"ghcr.io/${{ github.repository }}-worker\"\n            dockerfile: \"./apps/workers/Dockerfile\"\n            nx: \"workers:build\"\n          - image: \"ghcr.io/${{ github.repository }}-client\"\n            dockerfile: \"./apps/client/Dockerfile\"\n            nx: \"client:build\"\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v3\n\n      - name: Install cosign\n        if: github.event_name != 'pull_request'\n        uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 #v3.1.1\n        with:\n          cosign-release: 'v2.1.1'\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0\n\n      - name: Log into registry ${{ env.REGISTRY }}\n        if: github.event_name != 'pull_request'\n        uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract Docker metadata\n        id: meta\n        uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0\n        with:\n          images: ${{ matrix.image }}\n\n      - name: Setup node\n        uses: actions/setup-node@v4\n        with:\n          node-version: '16'\n\n      - uses: pnpm/action-setup@v2\n        name: Install pnpm\n        with:\n          version: 8\n          run_install: false\n\n      - name: Get pnpm store directory\n        shell: bash\n        run: |\n          echo \"STORE_PATH=$(pnpm store path --silent)\" >> $GITHUB_ENV\n\n      - uses: actions/cache@v3\n        name: Setup pnpm cache\n        with:\n          path: ${{ env.STORE_PATH }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n\n      - name: Install dependencies\n        run: pnpm install --frozen-lockfile --production=false\n\n      - name: Run nx target\n        run: npx nx run ${{ matrix.nx }}\n      \n      - name: Build and push Docker image\n        id: build-and-push\n        uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0\n        with:\n          context: .\n          file: ${{ matrix.dockerfile }}\n          push: ${{ github.event_name != 'pull_request' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n\n      - name: Sign the published Docker image\n        if: ${{ github.event_name != 'pull_request' }}\n        env:\n          TAGS: ${{ steps.meta.outputs.tags }}\n          DIGEST: ${{ steps.build-and-push.outputs.digest }}\n        run: echo \"${TAGS}\" | xargs -I {} cosign sign --yes {}@${DIGEST}\n"
  },
  {
    "path": ".gitignore",
    "content": "# See http://help.github.com/ignore-files/ for more about ignoring files.\n\n# compiled output\n**/dist\n**/tmp\n**/out-tsc\n\n# dependencies\n**/node_modules\n\n# IDEs and editors\n/.idea\n.project\n.classpath\n.c9/\n*.launch\n.settings/\n*.sublime-workspace\n\n# IDE - VSCode\n.vscode/*\n!.vscode/settings.json\n!.vscode/tasks.json\n!.vscode/launch.json\n!.vscode/extensions.json\n\n# misc\n/.sass-cache\n/connect.lock\n/coverage\n/libpeerconnection.log\nnpm-debug.log\nyarn-error.log\ntestem.log\n/typings\nexceptions.log\nerror.log\ncombined.log\n/tools/output\n\n# System Files\n.DS_Store\nThumbs.db\n\n# ENV\n**/.env\n**/.env.local\n\n# Next.js\n.next\n\n# nx\nmigrations.json\n\n# Shouldn't happen, but backup since we have a script that generates these locally\n*.pem\ncerts/\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\npnpm lint-staged\n"
  },
  {
    "path": ".prettierignore",
    "content": "# Add files here to ignore them from prettier formatting\n/dist\n/coverage\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n    \"trailingComma\": \"es5\",\n    \"printWidth\": 100,\n    \"tabWidth\": 4,\n    \"semi\": false,\n    \"singleQuote\": true\n}\n"
  },
  {
    "path": ".storybook/main.js",
    "content": "module.exports = {\n    stories: [],\n    addons: ['@storybook/addon-essentials'],\n    // uncomment the property below if you want to apply some webpack config globally\n    // webpackFinal: async (config, { configType }) => {\n    //   // Make whatever fine-grained changes you need that should apply to all storybook configs\n\n    //   // Return the altered config\n    //   return config;\n    // },\n}\n"
  },
  {
    "path": ".storybook/tsconfig.json",
    "content": "{\n    \"extends\": \"../tsconfig.base.json\",\n    \"exclude\": [\"../**/*.spec.js\", \"../**/*.spec.ts\", \"../**/*.spec.tsx\", \"../**/*.spec.jsx\"],\n    \"include\": [\"../**/*\"]\n}\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n    \"recommendations\": [\n        \"nrwl.angular-console\",\n        \"esbenp.prettier-vscode\",\n        \"firsttris.vscode-jest-runner\",\n        \"dbaeumer.vscode-eslint\",\n        \"prisma.prisma\"\n    ]\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"type\": \"pwa-node\",\n            \"request\": \"launch\",\n            \"name\": \"Jest run current file\",\n            \"program\": \"${workspaceFolder}/node_modules/.bin/nx\",\n            \"cwd\": \"${workspaceFolder}\",\n            \"args\": [\n                \"test\",\n                \"--testPathPattern=${fileBasenameNoExtension}\",\n                \"--runInBand\",\n                \"--skip-nx-cache\"\n            ],\n            \"skipFiles\": [\"<node_internals>/**\", \"${workspaceFolder/node_modules/**/*}\"],\n            \"console\": \"integratedTerminal\",\n            \"internalConsoleOptions\": \"neverOpen\",\n            \"env\": {\n                \"IS_VSCODE_DEBUG\": \"true\",\n                \"NX_DATABASE_URL\": \"postgresql://maybe:maybe@localhost:5432/maybe_local\"\n            }\n        },\n        {\n            \"name\": \"server debug\",\n            \"type\": \"node\",\n            \"request\": \"attach\",\n            \"restart\": false,\n            \"port\": 9228,\n            \"address\": \"localhost\",\n            \"localRoot\": \"${workspaceFolder}\",\n            \"skipFiles\": [\"<node_internals>/**\"],\n            \"remoteRoot\": \"/app\"\n        },\n        {\n            \"name\": \"workers debug\",\n            \"type\": \"node\",\n            \"request\": \"attach\",\n            \"restart\": false,\n            \"port\": 9227,\n            \"address\": \"localhost\",\n            \"localRoot\": \"${workspaceFolder}\",\n            \"skipFiles\": [\"<node_internals>/**\"],\n            \"remoteRoot\": \"/app\"\n        }\n    ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n    \"css.customData\": [\".vscode/css_custom_data.json\"],\n    \"[typescript]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[typescriptreact]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"[html]\": {\n        \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n    },\n    \"typescript.enablePromptUseWorkspaceTsdk\": true,\n    \"typescript.tsdk\": \"node_modules/typescript/lib\"\n}\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Maybe\nIt means so much that you're interested in contributing to Maybe! Seriously. Thank you. The entire community benefits from these contributions!\n\nBefore submitting a new issue or PR, check if it already exists in [issues](https://github.com/maybe-finance/maybe/issues) or [PRs](https://github.com/maybe-finance/maybe/pulls) so you have an idea of where things stand.\n\nThen, once you're ready to begin work, submit a draft PR with your high-level plan (or the full solution).\n\nGiven the speed at which we're moving on the codebase, we don't assign issues or \"give\" issues to anyone. \n\nWhen multiple PRs are submitted for the same issue, we take the one that most succinctly & efficiently solves a given problem and stays within the scope of work.\n\nPriority is also generally given to previous committers as they've proven familiarity with the codebase and product.\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "**🚨 NOTE: This codebase is no longer being maintained. The repo we're actively working on is located at [maybe-finance/maybe](https://github.com/maybe-finance/maybe).**\n\n---\n\n![](https://github.com/maybe-finance/maybe/assets/35243/79d97b31-7fad-4031-9e83-5005bc1d7fd0)\n\n# Maybe: Open-source personal finance app\n\n<b>Get involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>\n\n## Backstory\n\nWe spent the better part of 2021/2022 building a personal finance + wealth management app called, Maybe. Very full-featured, including an \"Ask an Advisor\" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).\n\nThe business end of things didn't work out, and so we shut things down mid-2023.\n\nWe spent the better part of $1,000,000 building the app (employees + contractors, data providers/services, infrastructure, etc.).\n\nWe're now reviving the product as a fully open-source project. The goal is to let you run the app yourself, for free, and use it to manage your own finances and eventually offer a hosted version of the app for a small monthly fee.\n\n## End goal\n\nUltimately we want to rebuild this so that you can self-host, but we also have plans to offer a hosted version for a fee. That means some decisions will be made that don't explicitly make sense for self-hosted but _do_ support the goal of us offering a for-pay hosted version.\n\n## Features\n\nAs a personal finance + wealth management app, Maybe has a lot of features. Here's a brief overview of some of the main ones...\n\n-   Net worth tracking\n-   Financial account syncing\n-   Investment benchmarking\n-   Investment portfolio allocation\n-   Debt insights\n-   Retirement forecasting + planning\n-   Investment return simulation\n-   Manual account/investment tracking\n\nAnd dozens upon dozens of smaller features.\n\n## Getting started\n\nThis is the current state of building the app. We're actively working to make this process much more streamlined!\n\n_You'll need Docker installed to run the app locally._\n[Docker Desktop](https://www.docker.com/products/docker-desktop/) is an easy way to get started.\n\nFirst, copy the `.env.example` file to `.env`:\n\n```\ncp .env.example .env\n```\n\nThen, create a new secret using `openssl rand -base64 32` and populate `NEXTAUTH_SECRET` in your `.env` file with it.\n\nTo enable transactional emails, you'll need to create a [Postmark](https://postmarkapp.com/) account and add your API key to your `.env` file (`NX_EMAIL_PROVIDER_API_TOKEN`) and set `NX_EMAIL_PROVIDER` to `postmark`. You can also set the from and reply-to email addresses (`NX_EMAIL_FROM_ADDRESS` and `NX_EMAIL_REPLY_TO_ADDRESS`). If you want to run the app without email, you can set `NX_EMAIL_PROVIDER_API_TOKEN` to a dummy value or leave `NX_EMAIL_PROVIDER` blank. We also support SMTP for sending emails, see information about configuring environment variables in the `.env.example` file.\n\nMaybe uses [Teller](https://teller.io/) for connecting financial accounts. To get started with Teller, you'll need to create an account. Once you've created an account:\n\n-   Add your Teller application id to your `.env` file (`NEXT_PUBLIC_TELLER_APP_ID`).\n-   Download your authentication certificates from Teller, create a `certs` folder in the root of the project, and place your certs in that directory. You should have both a `certificate.pem` and `private_key.pem`. **NEVER** check these files into source control, the `.gitignore` file will prevent the `certs/` directory from being added, but please double-check.\n-   Set your `NEXT_PUBLIC_TELLER_ENV` and `NX_TELLER_ENV` to your desired environment. The default is `sandbox` which allows for testing with mock data. The login credentials for the sandbox environment are `username` and `password`. To connect to real financial accounts, you'll need to use the `development` environment.\n-   Webhooks are not implemented yet, but you can populate the `NX_TELLER_SIGNING_SECRET` with the value from your Teller account.\n-   We highly recommend checking out the [Teller docs](https://teller.io/docs) for more info.\n\nThen run the following pnpm commands:\n\n```shell\npnpm install\npnpm run dev:services:all\npnpm prisma:migrate:dev\npnpm prisma:seed\npnpm dev\n```\n\n## Set Up Ngrok\n\nExternal data providers require HTTPS/SSL webhook URLs for sending data.\n\nTo test this locally/during development, you will need to setup `ngrok`.\n\n1. Visit [ngrok.com](https://ngrok.com/)\n2. Create a free account\n3. Visit [this page](https://dashboard.ngrok.com/get-started/your-authtoken) to access your auth token\n4. Paste it into your `.env` file: `NGROK_AUTH_TOKEN=your_auth_token`\n\nYou should claim your free static domain to avoid needing to change the URL each time you start/stop the server.\n\nTo do so:\n\n1. Visit the [domains](https://dashboard.ngrok.com/cloud-edge/domains) page\n2. Click on Create Domain\n3. Copy the domain and paste it into your `.env` file: `NGROK_DOMAIN=your_domain`\n\nThat's it! As long as you run the project locally using `docker` with `pnpm dev:services:all` you'll be good to go.\n\n## External data\n\nTo pull market data in (for investments), you'll need a Polygon.io API key. You can get one for free [here](https://polygon.io/) and then add it to your `.env` file (`NX_POLYGON_API_KEY`). **Note:** If you're using the free \"basic\" plan, you'll need to manually sync stock tickers using the dev tools in the app the first time you run it. It will then be re-synced automatically every 24 hours. If you're using a paid tier, be sure to update your `.env` file with the correct tier (`NX_POLYGON_API_TIER`) and tickers and pricing will be synced automatically.\n\n## Tech stack\n\n-   Next.js\n-   Tailwind\n-   Node.js\n-   Express\n-   Postgres (w/ Timescale)\n\n## Credits\n\nThe original app was built by [Zach Gollwitzer](https://twitter.com/zg_dev), [Nick Arciero](https://www.narciero.com/) and [Tim Wilson](https://twitter.com/actualTimWilson), with design work by [Justin Farrugia](https://twitter.com/justinmfarrugia).\n\n## Copyright & license\n\nMaybe is distributed under an [AGPLv3 license](https://github.com/maybe-finance/maybe-archive/blob/main/LICENSE). \"Maybe\" is a trademark of Maybe Finance, Inc.\n"
  },
  {
    "path": "apps/.gitkeep",
    "content": ""
  },
  {
    "path": "apps/client/.babelrc.json",
    "content": "{\n    \"presets\": [\"next/babel\"],\n    \"plugins\": []\n}\n"
  },
  {
    "path": "apps/client/.eslintrc.json",
    "content": "{\n    \"extends\": [\n        \"plugin:@nrwl/nx/react-typescript\",\n        \"../../.eslintrc.json\",\n        \"next\",\n        \"next/core-web-vitals\"\n    ],\n    \"ignorePatterns\": [\n        \"!**/*\",\n        \"styles.css\",\n        \"**/*.csv\",\n        \"**/public/*\",\n        \"**/.next/*\",\n        \"**/*.sh\",\n        \"Dockerfile\"\n    ],\n    \"overrides\": [\n        {\n            \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n            \"rules\": {\n                \"@next/next/no-img-element\": \"off\",\n                \"@next/next/no-html-link-for-pages\": \"off\"\n            }\n        },\n        {\n            \"files\": [\"*.ts\", \"*.tsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        }\n    ],\n    \"env\": {\n        \"jest\": true\n    },\n    \"settings\": {\n        \"next\": {\n            \"rootDir\": \"apps/advisor\"\n        }\n    }\n}\n"
  },
  {
    "path": "apps/client/.storybook/main.js",
    "content": "const rootMain = require('../../../.storybook/main')\n\nmodule.exports = {\n    ...rootMain,\n    core: { ...rootMain.core, builder: 'webpack5' },\n    stories: ['../**/*.stories.@(js|jsx|ts|tsx)', '../stories/**/*.stories.@(js|jsx|ts|tsx)'],\n    addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'],\n    webpackFinal: async (config, { configType }) => {\n        // apply any global webpack configs that might have been specified in .storybook/main.js\n        if (rootMain.webpackFinal) {\n            config = await rootMain.webpackFinal(config, { configType })\n        }\n\n        // add your own webpack tweaks if needed\n\n        return config\n    },\n}\n"
  },
  {
    "path": "apps/client/.storybook/manager.js",
    "content": "import { addons } from '@storybook/addons'\nimport theme from './theme'\n\naddons.setConfig({\n    theme,\n})\n"
  },
  {
    "path": "apps/client/.storybook/preview.js",
    "content": "import '../styles.css'\n\nimport theme from './theme'\n\nexport const parameters = {\n    docs: {\n        theme,\n    },\n}\n"
  },
  {
    "path": "apps/client/.storybook/theme.js",
    "content": "import { create } from '@storybook/theming'\nimport logo from '../assets/logo.svg'\n\nexport default create({\n    base: 'dark',\n\n    brandTitle: 'Maybe',\n    brandUrl: 'https://maybe.co',\n    brandImage: logo,\n\n    fontBase: '\"General Sans\", sans-serif',\n\n    colorPrimary: '#4361EE',\n    colorSecondary: '#F12980',\n\n    appBg: '#1C1C20',\n    appContentBg: '#16161A',\n})\n"
  },
  {
    "path": "apps/client/.storybook/tsconfig.json",
    "content": "{\n    \"extends\": \"../tsconfig.json\",\n    \"compilerOptions\": {\n        \"allowImportingTsExtensions\": true\n    },\n    \"include\": [\"**/*.ts\", \"**/*.tsx\", \"**/**/*.ts\", \"**/**/*.tsx\"]\n}\n"
  },
  {
    "path": "apps/client/Dockerfile",
    "content": "# ------------------------------------------\n#                BUILD STAGE              \n# ------------------------------------------ \nFROM node:18-alpine3.18 as builder\n\nWORKDIR /app\nCOPY ./dist/apps/client ./prisma ./package.json ./\n# Install dependencies\nRUN npm install -g pnpm\n# nrwl/nx#20079, generated lockfile is completely broken\nRUN rm -f pnpm-lock.yaml\nRUN pnpm install --no-frozen-lockfile --production=false\n\n# ------------------------------------------\n#                PROD STAGE               \n# ------------------------------------------ \nFROM node:18-alpine3.18 as prod\n\nCOPY ./apps/client/env.sh /env.sh\nRUN chmod +x /env.sh\n\n# Used for container health checks and env handling\nRUN apk add --no-cache curl gawk bash\nWORKDIR /app\nUSER node \nCOPY --from=builder --chown=node:node /app  .\n\nENTRYPOINT [\"/env.sh\"]\nCMD [\"npx\", \"next\", \"start\"]\n"
  },
  {
    "path": "apps/client/components/APM.tsx",
    "content": "import { useEffect } from 'react'\nimport * as Sentry from '@sentry/react'\nimport { useSession } from 'next-auth/react'\n\nexport default function APM() {\n    const { data: session } = useSession()\n\n    // Identify Sentry user\n    useEffect(() => {\n        if (session && session.user) {\n            Sentry.setUser({\n                id: session.user['sub'] ?? undefined,\n                email: session.user['https://maybe.co'] ?? undefined,\n            })\n        }\n    }, [session])\n\n    return null\n}\n"
  },
  {
    "path": "apps/client/components/Maintenance.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport Maintenance from './Maintenance'\nimport React from 'react'\n\nexport default {\n    title: 'components/Maintenance.tsx',\n    component: Maintenance,\n} as Meta\n\nconst Template: Story = () => {\n    return (\n        <>\n            <Maintenance />\n        </>\n    )\n}\n\nexport const Base = Template.bind({})\n"
  },
  {
    "path": "apps/client/components/Maintenance.tsx",
    "content": "export default function Maintenance() {\n    return (\n        <div className=\"h-screen flex flex-col items-center justify-center p-4\">\n            <svg\n                width=\"117\"\n                height=\"92\"\n                viewBox=\"0 0 117 92\"\n                fill=\"none\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n            >\n                <rect x=\"17.719\" width=\"30.5852\" height=\"19.0308\" rx=\"9.5154\" fill=\"#4CC9F0\" />\n                <rect x=\"68.6948\" width=\"30.5852\" height=\"19.0308\" rx=\"9.5154\" fill=\"#4CC9F0\" />\n                <rect\n                    x=\"44.2266\"\n                    y=\"24.4677\"\n                    width=\"28.5462\"\n                    height=\"19.0308\"\n                    rx=\"9.5154\"\n                    fill=\"#4361EE\"\n                />\n                <rect\n                    x=\"44.2266\"\n                    y=\"48.9364\"\n                    width=\"28.5462\"\n                    height=\"19.0308\"\n                    rx=\"9.5154\"\n                    fill=\"#7209B7\"\n                />\n                <rect\n                    x=\"10.2429\"\n                    y=\"48.9364\"\n                    width=\"28.5462\"\n                    height=\"19.0308\"\n                    rx=\"9.5154\"\n                    fill=\"#7209B7\"\n                />\n                <rect\n                    x=\"78.2104\"\n                    y=\"48.9364\"\n                    width=\"28.5462\"\n                    height=\"19.0308\"\n                    rx=\"9.5154\"\n                    fill=\"#7209B7\"\n                />\n                <rect\n                    x=\"44.9065\"\n                    y=\"73.4041\"\n                    width=\"27.119\"\n                    height=\"18.5959\"\n                    rx=\"9.29794\"\n                    fill=\"#F72585\"\n                />\n                <rect\n                    x=\"0.727783\"\n                    y=\"24.4677\"\n                    width=\"38.0616\"\n                    height=\"19.0308\"\n                    rx=\"9.5154\"\n                    fill=\"#4361EE\"\n                />\n                <rect\n                    x=\"78.2104\"\n                    y=\"24.4677\"\n                    width=\"38.0616\"\n                    height=\"19.0308\"\n                    rx=\"9.5154\"\n                    fill=\"#4361EE\"\n                />\n            </svg>\n\n            <h1 className=\"mb-2 mt-10 font-extrabold text-base md:text-2xl text-white\">\n                We&apos;ll be back soon!\n            </h1>\n\n            <p className=\"mb-10 text-base text-center text-gray-50 max-w-md\">\n                We are currently doing some site maintenance and will be back up shortly.\n            </p>\n        </div>\n    )\n}\n"
  },
  {
    "path": "apps/client/components/Meta.tsx",
    "content": "import Head from 'next/head'\nimport React from 'react'\nimport env from '../env'\n\nexport default function Meta() {\n    return (\n        <Head>\n            {/* <!-- Primary Meta Tags --> */}\n            <title>Maybe</title>\n            <meta name=\"title\" content=\"Maybe\" />\n            <meta name=\"description\" content=\"Maybe is modern financial & investment planning\" />\n\n            {/* <!-- Open Graph / Facebook --> */}\n            <meta property=\"og:type\" content=\"website\" />\n            <meta property=\"og:url\" content=\"https://www.maybe.co\" />\n            <meta property=\"og:title\" content=\"Maybe\" />\n            <meta\n                property=\"og:description\"\n                content=\"Maybe is modern financial & investment planning\"\n            />\n\n            {/* <!-- Twitter --> */}\n            <meta property=\"twitter:card\" content=\"summary_large_image\" />\n            <meta property=\"twitter:url\" content=\"https://www.maybe.co\" />\n            <meta property=\"twitter:title\" content=\"Maybe\" />\n            <meta\n                property=\"twitter:description\"\n                content=\"Maybe is modern financial & investment planning\"\n            />\n\n            {/* <!-- Favicons - https://realfavicongenerator.net/favicon_checker#.YUNEifxKhhE --> */}\n            <link rel=\"manifest\" href=\"/assets/site.webmanifest\" />\n\n            {/* <!-- Safari --> */}\n            <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/assets/apple-touch-icon.png\" />\n            <link rel=\"mask-icon\" href=\"/assets/safari-pinned-tab.svg\" color=\"#4361ee\" />\n\n            {/* <!-- Chrome --> */}\n            <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/assets/favicon-32x32.png\" />\n            <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/assets/favicon-16x16.png\" />\n\n            <link\n                href=\"https://cdn.jsdelivr.net/npm/remixicon@2.5.0/fonts/remixicon.css\"\n                rel=\"stylesheet\"\n            />\n        </Head>\n    )\n}\n"
  },
  {
    "path": "apps/client/components/account-views/DefaultView.tsx",
    "content": "import type { ReactNode } from 'react'\nimport type { Account } from '@prisma/client'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type { SelectableDateRange, SelectableRangeKeys } from '@maybe-finance/design-system'\n\nimport { AccountMenu, PageTitle } from '@maybe-finance/client/features'\nimport { TSeries } from '@maybe-finance/client/shared'\nimport { DatePickerRange, getRangeDescription } from '@maybe-finance/design-system'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { DateTime } from 'luxon'\nimport { useMemo } from 'react'\n\nexport type DefaultViewProps = {\n    account?: SharedType.AccountDetail\n    balances?: SharedType.AccountBalanceResponse\n    dateRange: SharedType.DateRange\n    onDateChange: (range: SharedType.DateRange) => void\n    getContent: (accountId: Account['id']) => ReactNode\n    isLoading: boolean\n    isError: boolean\n    selectableDateRanges?: Array<SelectableDateRange | SelectableRangeKeys>\n}\n\nexport default function DefaultView({\n    account,\n    balances,\n    dateRange,\n    onDateChange,\n    getContent,\n    isLoading,\n    isError,\n    selectableDateRanges,\n}: DefaultViewProps) {\n    const allTimeRange = useMemo(() => {\n        return {\n            label: 'All',\n            labelShort: 'All',\n            start: balances?.minDate ?? DateTime.now().minus({ years: 2 }).toISODate(),\n            end: DateTime.now().toISODate(),\n        }\n    }, [balances])\n\n    return (\n        <div className=\"space-y-5\">\n            <div className=\"flex justify-between\">\n                <PageTitle\n                    isLoading={isLoading}\n                    title={account?.name}\n                    value={NumberUtil.format(balances?.today?.balance, 'currency')}\n                    trend={balances?.trend}\n                    trendLabel={getRangeDescription(dateRange, balances?.minDate)}\n                    trendNegative={account?.classification === 'liability'}\n                />\n                <AccountMenu account={account} />\n            </div>\n\n            <div className=\"flex justify-end\">\n                <DatePickerRange\n                    variant=\"tabs-custom\"\n                    minDate={balances?.minDate}\n                    maxDate={DateTime.now().toISODate()}\n                    value={dateRange}\n                    onChange={onDateChange}\n                    selectableRanges={\n                        selectableDateRanges\n                            ? [...selectableDateRanges, allTimeRange]\n                            : [\n                                  'last-30-days',\n                                  'last-6-months',\n                                  'last-365-days',\n                                  'last-3-years',\n                                  allTimeRange,\n                              ]\n                    }\n                />\n            </div>\n\n            <div className=\"h-96\">\n                <TSeries.Chart\n                    id=\"investment-chart\"\n                    isLoading={isLoading}\n                    isError={isError}\n                    dateRange={dateRange}\n                    interval={balances?.series.interval}\n                    data={balances?.series.data.map((v) => ({\n                        date: v.date,\n                        values: { balance: v.balance },\n                    }))}\n                    series={[\n                        {\n                            key: 'balances',\n                            accessorFn: (d) => d.values.balance?.toNumber(),\n                            negative: account?.classification === 'liability',\n                        },\n                    ]}\n                >\n                    <TSeries.Line seriesKey=\"balances\" />\n                </TSeries.Chart>\n            </div>\n\n            {account && <div>{getContent(account.id)}</div>}\n        </div>\n    )\n}\n"
  },
  {
    "path": "apps/client/components/account-views/InvestmentView.tsx",
    "content": "import {\n    type InsightCardOption,\n    InsightPopout,\n    usePopoutContext,\n    useAccountApi,\n    InsightGroup,\n    TSeries,\n} from '@maybe-finance/client/shared'\nimport {\n    AccountMenu,\n    Explainers,\n    HoldingList,\n    InvestmentTransactionList,\n    PageTitle,\n} from '@maybe-finance/client/features'\nimport {\n    Checkbox,\n    DatePickerRange,\n    getRangeDescription,\n    Listbox,\n} from '@maybe-finance/design-system'\nimport { type SharedType, NumberUtil } from '@maybe-finance/shared'\nimport { DateTime } from 'luxon'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport {\n    RiAddLine,\n    RiArrowUpDownLine,\n    RiCoinLine,\n    RiLineChartLine,\n    RiPercentLine,\n    RiScalesFill,\n    RiStackLine,\n    RiSubtractLine,\n} from 'react-icons/ri'\nimport type { IconType } from 'react-icons'\nimport classNames from 'classnames'\n\ntype Props = {\n    account?: SharedType.AccountDetail\n    balances?: SharedType.AccountBalanceResponse\n    dateRange: SharedType.DateRange\n    onDateChange: (range: SharedType.DateRange) => void\n    isLoading: boolean\n    isError: boolean\n}\n\nconst stockInsightCards: InsightCardOption[] = [\n    {\n        id: 'profit-loss',\n        display: 'Potential gain or loss',\n        category: 'General',\n        tooltip:\n            'The amount you would gain or lose if you sold this entire portfolio today.  This is commonly referred to as \"capital gains / losses\".',\n    },\n    {\n        id: 'avg-return',\n        display: 'Average return',\n        category: 'General',\n        tooltip:\n            'The average return you have achieved over the time period on this portfolio of holdings',\n    },\n    {\n        id: 'net-deposits',\n        display: 'Contributions',\n        category: 'General',\n        tooltip:\n            'The total amount you have contributed to this brokerage account.  Deposits increase this number and withdrawals decrease it.',\n    },\n    {\n        id: 'fees',\n        display: 'Fees',\n        category: 'Cost',\n        tooltip:\n            'The total brokerage and other fees you have incurred while buying and selling holdings',\n    },\n    {\n        id: 'sector-allocation',\n        display: 'Sector allocation',\n        category: 'Market',\n        tooltip: 'Shows how diverse your portfolio is',\n    },\n]\n\nconst transactionsFilters: {\n    name: string\n    icon: IconType\n    data: { category?: SharedType.InvestmentTransactionCategory }\n}[] = [\n    {\n        name: 'Show all',\n        icon: RiStackLine,\n        data: {},\n    },\n    {\n        name: 'Buys',\n        icon: RiAddLine,\n        data: { category: 'buy' },\n    },\n    {\n        name: 'Sales',\n        icon: RiSubtractLine,\n        data: { category: 'sell' },\n    },\n    {\n        name: 'Dividends',\n        icon: RiPercentLine,\n        data: { category: 'dividend' },\n    },\n    {\n        name: 'Transfers',\n        icon: RiArrowUpDownLine,\n        data: { category: 'transfer' },\n    },\n    {\n        name: 'Fees',\n        icon: RiCoinLine,\n        data: { category: 'fee' },\n    },\n]\n\nconst returnPeriods: { key: 'ytd' | '1y' | '1m'; display: string }[] = [\n    { key: 'ytd', display: 'This year' },\n    { key: '1y', display: 'Past year' },\n    { key: '1m', display: 'Past month' },\n]\n\nconst contributionPeriods = [\n    { key: 'ytd', display: 'This year' },\n    { key: 'lastYear', display: 'Last year' },\n]\n\nconst chartViews = [\n    { key: 'value', display: 'Value', icon: RiLineChartLine },\n    { key: 'return', display: 'Return', icon: RiLineChartLine },\n]\n\ntype Comparison = { ticker: string; display: string; color: string }\n\nconst comparisonTickers: Comparison[] = [\n    {\n        ticker: 'VOO',\n        display: 'S&P 500',\n        color: 'teal',\n    },\n    {\n        ticker: 'DIA',\n        display: 'Dow Jones Industrial Avg',\n        color: 'red',\n    },\n    {\n        ticker: 'VONE',\n        display: 'Russell 1000',\n        color: 'indigo',\n    },\n    {\n        ticker: 'QQQ',\n        display: 'NASDAQ 100',\n        color: 'grape',\n    },\n    {\n        ticker: 'VT',\n        display: 'Total World Stock Index',\n        color: 'yellow',\n    },\n    {\n        ticker: 'GLDM',\n        display: 'Gold',\n        color: 'blue',\n    },\n    {\n        ticker: 'X:BTCUSD',\n        display: 'Bitcoin',\n        color: 'orange',\n    },\n    {\n        ticker: 'X:ETHUSD',\n        display: 'Ethereum',\n        color: 'gray',\n    },\n]\n\nexport default function InvestmentView({\n    account,\n    balances,\n    dateRange,\n    onDateChange,\n    isLoading,\n    isError,\n}: Props) {\n    const [selectedComparisons, setSelectedComparisons] = useState<Comparison[]>([])\n    const [chartView, setChartView] = useState(chartViews[0])\n    const [showContributions, setShowContributions] = useState(false)\n\n    const { open: openPopout } = usePopoutContext()\n\n    const [returnPeriod, setReturnPeriod] = useState(returnPeriods[0])\n    const [contributionPeriod, setContributionPeriod] = useState(contributionPeriods[0])\n\n    const { useAccountInsights, useAccountReturns } = useAccountApi()\n\n    const returns = useAccountReturns(\n        {\n            id: account?.id ?? -1,\n            start: dateRange.start,\n            end: dateRange.end,\n            compare: selectedComparisons.map((c) => c.ticker),\n        },\n        {\n            enabled: !!account?.id,\n            keepPreviousData: true,\n        }\n    )\n\n    const insights = useAccountInsights(account?.id ?? -1, { enabled: !!account?.id })\n\n    const stocksAllocation = useMemo(() => {\n        const stockPercent =\n            insights.data?.portfolio?.holdingBreakdown\n                .find((hb) => hb.asset_class === 'stocks')\n                ?.percentage.toNumber() ?? 0\n\n        return {\n            stocks: Math.round(stockPercent * 100),\n            other: Math.round(100 - stockPercent * 100),\n        }\n    }, [insights.data])\n\n    const allTimeRange = useMemo(() => {\n        return {\n            label: 'All',\n            labelShort: 'All',\n            start: balances?.minDate ?? DateTime.now().minus({ years: 2 }).toISODate(),\n            end: DateTime.now().toISODate(),\n        }\n    }, [balances])\n\n    const returnColorAccessorFn = useCallback<\n        TSeries.AccessorFn<{ rateOfReturn: SharedType.Decimal }, string>\n    >((datum) => {\n        return datum.values?.rateOfReturn?.lessThan(0) ? '#FF8787' : '#38D9A9' // text-red and text-teal\n    }, [])\n\n    const [transactionFilter, setTransactionFilter] = useState(transactionsFilters[0])\n\n    // Whenever user modifies the comparisons dropdown, always go to \"Returns\" view\n    useEffect(() => {\n        if (selectedComparisons.length > 0) {\n            setChartView(chartViews[1])\n        }\n    }, [selectedComparisons])\n\n    return (\n        <div className=\"space-y-5\">\n            <div className=\"flex justify-between\">\n                <PageTitle\n                    isLoading={isLoading}\n                    title={account?.name}\n                    value={NumberUtil.format(balances?.today?.balance, 'currency')}\n                    trend={balances?.trend}\n                    trendLabel={getRangeDescription(dateRange, balances?.minDate)}\n                />\n                <AccountMenu account={account} />\n            </div>\n\n            <div className=\"flex justify-between flex-wrap gap-2\">\n                <div className=\"flex items-center flex-wrap gap-2\">\n                    <Listbox className=\"inline-block\" value={chartView} onChange={setChartView}>\n                        <Listbox.Button icon={chartView.icon}>{chartView.display}</Listbox.Button>\n                        <Listbox.Options>\n                            {chartViews.map((view) => (\n                                <Listbox.Option key={view.key} value={view}>\n                                    {view.display}\n                                </Listbox.Option>\n                            ))}\n                        </Listbox.Options>\n                    </Listbox>\n                    <Listbox value={selectedComparisons} onChange={setSelectedComparisons} multiple>\n                        <Listbox.Button icon={RiScalesFill}>\n                            <span className=\"text-white text-base font-medium\">Compare</span>\n                        </Listbox.Button>\n                        <Listbox.Options placement=\"bottom-start\" className=\"min-w-[210px]\">\n                            {comparisonTickers.map((comparison) => (\n                                <Listbox.Option\n                                    key={comparison.ticker}\n                                    value={comparison}\n                                    className=\"my-2 whitespace-nowrap\"\n                                >\n                                    {comparison.display}\n                                </Listbox.Option>\n                            ))}\n                        </Listbox.Options>\n                    </Listbox>\n                    {chartView.key === 'value' && (\n                        <div className=\"py-2\">\n                            <Checkbox\n                                label=\"Show contribution\"\n                                onChange={setShowContributions}\n                                checked={showContributions}\n                                className=\"ml-1\"\n                            />\n                        </div>\n                    )}\n                </div>\n\n                <DatePickerRange\n                    variant=\"tabs-custom\"\n                    minDate={balances?.minDate}\n                    maxDate={DateTime.now().toISODate()}\n                    value={dateRange}\n                    onChange={onDateChange}\n                    selectableRanges={[\n                        'last-7-days',\n                        'last-30-days',\n                        'last-90-days',\n                        'last-365-days',\n                        allTimeRange,\n                    ]}\n                />\n            </div>\n\n            <div className=\"h-96\">\n                <TSeries.Chart<Record<string, SharedType.Decimal>>\n                    id=\"investment-account-chart\"\n                    isLoading={isLoading}\n                    isError={isError || returns.isError}\n                    dateRange={dateRange}\n                    data={{\n                        balances:\n                            balances?.series.data.map((d) => ({\n                                date: d.date,\n                                values: { balance: d.balance },\n                            })) ?? [],\n                        returns:\n                            returns.data?.data.map((d) => ({\n                                date: d.date,\n                                values: { ...d.account, ...d.comparisons },\n                            })) ?? [],\n                    }}\n                    series={[\n                        {\n                            key: 'portfolio-balance',\n                            dataKey: 'balances',\n                            accessorFn: (d) => d?.values?.balance?.toNumber(),\n                            isActive: chartView.key === 'value',\n                        },\n                        {\n                            key: 'contributions',\n                            dataKey: 'returns',\n                            accessorFn: (d) => d.values.contributions?.toNumber(),\n                            isActive: showContributions && chartView.key === 'value',\n                            color: TSeries.tailwindScale('grape'),\n                        },\n                        {\n                            key: 'portfolio-return',\n                            dataKey: 'returns',\n                            accessorFn: (d) => d.values.rateOfReturn?.toNumber(),\n                            isActive: chartView.key === 'return',\n                            format: 'percent',\n                            label: 'Portfolio return',\n                            color:\n                                selectedComparisons.length > 0\n                                    ? TSeries.tailwindScale('cyan')\n                                    : returnColorAccessorFn,\n                        },\n                        ...selectedComparisons.map(({ ticker, display, color }) => ({\n                            key: ticker,\n                            dataKey: 'returns',\n                            accessorFn: (d) => {\n                                return d.values?.[ticker]?.toNumber()\n                            },\n                            isActive: chartView.key === 'return' && selectedComparisons.length > 0,\n                            label: `${display} return`,\n                            format: 'percent' as SharedType.FormatString,\n                            color: TSeries.tailwindScale(color),\n                        })),\n                    ]}\n                    y1Axis={\n                        <TSeries.AxisLeft\n                            tickFormat={(v) =>\n                                NumberUtil.format(\n                                    v as number,\n                                    chartView.key === 'return' ? 'percent' : 'short-currency'\n                                )\n                            }\n                        />\n                    }\n                    // If showing returns graph, render a date range for the tooltip title\n                    tooltipOptions={\n                        chartView.key === 'return' && returns.data && returns.data.data.length > 0\n                            ? {\n                                  tooltipTitle: (tooltipData) =>\n                                      `${DateTime.fromISO(returns.data.data[0].date).toFormat(\n                                          'MMM dd, yyyy'\n                                      )} - ${DateTime.fromISO(tooltipData.date).toFormat(\n                                          'MMM dd, yyyy'\n                                      )}`,\n                              }\n                            : undefined\n                    }\n                >\n                    <TSeries.Line seriesKey=\"portfolio-balance\" />\n\n                    <TSeries.Line seriesKey=\"portfolio-return\" />\n\n                    <TSeries.Line seriesKey=\"contributions\" strokeDasharray={4} />\n\n                    {selectedComparisons.map((comparison) => (\n                        <TSeries.Line key={comparison.ticker} seriesKey={comparison.ticker} />\n                    ))}\n                </TSeries.Chart>\n            </div>\n\n            {account && (\n                <div>\n                    <InsightGroup\n                        id=\"investment-account-insights\"\n                        options={stockInsightCards}\n                        initialInsights={['avg-return', 'profit-loss', 'net-deposits']}\n                    >\n                        <InsightGroup.Card\n                            id=\"avg-return\"\n                            isLoading={insights.isLoading}\n                            status=\"active\"\n                            onClick={() =>\n                                openPopout(\n                                    <InsightPopout>\n                                        <Explainers.AverageReturn />\n                                    </InsightPopout>\n                                )\n                            }\n                            headerRight={\n                                <Listbox\n                                    onChange={setReturnPeriod}\n                                    value={returnPeriod}\n                                    onClick={(e) => e.stopPropagation()}\n                                >\n                                    <Listbox.Button\n                                        size=\"small\"\n                                        onClick={(e) => e.stopPropagation()}\n                                    >\n                                        {returnPeriod.display}\n                                    </Listbox.Button>\n\n                                    <Listbox.Options>\n                                        {returnPeriods.map((rp) => (\n                                            <Listbox.Option key={rp.key} value={rp}>\n                                                {rp.display}\n                                            </Listbox.Option>\n                                        ))}\n                                    </Listbox.Options>\n                                </Listbox>\n                            }\n                        >\n                            {(() => {\n                                const returnValues =\n                                    insights.data?.portfolio?.return?.[returnPeriod.key]\n\n                                return (\n                                    <>\n                                        <h3>\n                                            {NumberUtil.format(\n                                                returnValues?.percentage,\n                                                'percent',\n                                                { signDisplay: 'auto', maximumFractionDigits: 1 }\n                                            )}\n                                        </h3>\n                                        <span className=\"text-gray-100 text-base\">\n                                            <span\n                                                className={classNames(\n                                                    returnValues?.direction === 'up'\n                                                        ? 'text-teal'\n                                                        : returnValues?.direction === 'down'\n                                                        ? 'text-red'\n                                                        : null\n                                                )}\n                                            >\n                                                {NumberUtil.format(\n                                                    returnValues?.amount,\n                                                    'currency',\n                                                    {\n                                                        signDisplay: 'exceptZero',\n                                                    }\n                                                )}\n                                            </span>{' '}\n                                            {returnPeriod.display.toLowerCase()}\n                                        </span>\n                                    </>\n                                )\n                            })()}\n                        </InsightGroup.Card>\n\n                        <InsightGroup.Card\n                            id=\"profit-loss\"\n                            isLoading={false}\n                            status=\"active\"\n                            onClick={() =>\n                                openPopout(\n                                    <InsightPopout>\n                                        <Explainers.PotentialGainLoss />\n                                    </InsightPopout>\n                                )\n                            }\n                        >\n                            <h3>\n                                {NumberUtil.format(\n                                    insights.data?.portfolio?.pnl?.amount,\n                                    'currency',\n                                    { signDisplay: 'exceptZero' }\n                                )}\n                            </h3>\n                            <span className=\"text-base text-gray-100\">as of today</span>\n                        </InsightGroup.Card>\n\n                        <InsightGroup.Card\n                            id=\"net-deposits\"\n                            isLoading={false}\n                            status=\"active\"\n                            onClick={() =>\n                                openPopout(\n                                    <InsightPopout>\n                                        <Explainers.Contributions />\n                                    </InsightPopout>\n                                )\n                            }\n                            headerRight={\n                                <Listbox\n                                    onChange={setContributionPeriod}\n                                    value={contributionPeriod}\n                                    onClick={(e) => e.stopPropagation()}\n                                >\n                                    <Listbox.Button\n                                        size=\"small\"\n                                        onClick={(e) => e.stopPropagation()}\n                                    >\n                                        {contributionPeriod.display}\n                                    </Listbox.Button>\n\n                                    <Listbox.Options>\n                                        {contributionPeriods.map((cp) => (\n                                            <Listbox.Option key={cp.key} value={cp}>\n                                                {cp.display}\n                                            </Listbox.Option>\n                                        ))}\n                                    </Listbox.Options>\n                                </Listbox>\n                            }\n                        >\n                            <h3>\n                                {NumberUtil.format(\n                                    insights?.data?.portfolio?.contributions[contributionPeriod.key]\n                                        .amount,\n                                    'currency',\n                                    { signDisplay: 'exceptZero' }\n                                )}\n                            </h3>\n                            <span className=\"text-gray-100 text-base\">Average:</span>\n                            <span className=\"text-gray-25 ml-1 text-base\">\n                                {NumberUtil.format(\n                                    insights?.data?.portfolio?.contributions[contributionPeriod.key]\n                                        .monthlyAvg,\n                                    'short-currency',\n                                    { signDisplay: 'exceptZero' }\n                                )}\n                                /mo\n                            </span>\n                        </InsightGroup.Card>\n\n                        <InsightGroup.Card\n                            id=\"fees\"\n                            isLoading={false}\n                            status=\"active\"\n                            onClick={() =>\n                                openPopout(\n                                    <InsightPopout>\n                                        <Explainers.TotalFees />\n                                    </InsightPopout>\n                                )\n                            }\n                        >\n                            <h3>\n                                {NumberUtil.format(insights.data?.portfolio?.fees, 'currency', {\n                                    signDisplay: 'auto',\n                                })}\n                            </h3>\n                            <span className=\"text-base text-gray-100\">all time</span>\n                        </InsightGroup.Card>\n\n                        <InsightGroup.Card\n                            id=\"sector-allocation\"\n                            isLoading={false}\n                            status={'active'}\n                            onClick={() =>\n                                openPopout(\n                                    <InsightPopout>\n                                        <Explainers.SectorAllocation />\n                                    </InsightPopout>\n                                )\n                            }\n                        >\n                            <h3>\n                                {stocksAllocation.stocks}/{stocksAllocation.other} split\n                            </h3>\n                            <span className=\"text-base text-gray-100\">\n                                {stocksAllocation.stocks}% in stocks and {stocksAllocation.other}%\n                                in other\n                            </span>\n                        </InsightGroup.Card>\n                    </InsightGroup>\n\n                    <h5 className=\"uppercase mb-5\">Holdings</h5>\n                    <HoldingList accountId={account.id} />\n\n                    <div className=\"flex items-center justify-between mt-2 mb-4\">\n                        <h5 className=\"uppercase mt-2 mb-4\">Transactions</h5>\n                        <Listbox value={transactionFilter} onChange={setTransactionFilter}>\n                            <Listbox.Button icon={transactionFilter.icon}>\n                                {transactionFilter.name}\n                            </Listbox.Button>\n                            <Listbox.Options placement=\"bottom-end\">\n                                {transactionsFilters.map((filter) => (\n                                    <Listbox.Option\n                                        key={filter.name}\n                                        value={filter}\n                                        icon={filter.icon}\n                                    >\n                                        {filter.name}\n                                    </Listbox.Option>\n                                ))}\n                            </Listbox.Options>\n                        </Listbox>\n                    </div>\n                    <InvestmentTransactionList\n                        accountId={account.id}\n                        filter={transactionFilter.data}\n                    />\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "apps/client/components/account-views/LoanView.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { AccountMenu, LoanDetail, PageTitle, TransactionList } from '@maybe-finance/client/features'\nimport { TSeries, useAccountContext } from '@maybe-finance/client/shared'\nimport { Button, DatePickerRange, getRangeDescription } from '@maybe-finance/design-system'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { DateTime } from 'luxon'\nimport { useMemo, useEffect, useState } from 'react'\n\nexport type LoanViewProps = {\n    account?: SharedType.AccountDetail\n    balances?: SharedType.AccountBalanceResponse\n    dateRange: SharedType.DateRange\n    onDateChange: (range: SharedType.DateRange) => void\n    isLoading: boolean\n    isError: boolean\n}\n\nexport default function LoanView({\n    account,\n    balances,\n    dateRange,\n    onDateChange,\n    isLoading,\n    isError,\n}: LoanViewProps) {\n    const { editAccount } = useAccountContext()\n\n    const [showOverlay, setShowOverlay] = useState(false)\n\n    const allTimeRange = useMemo(() => {\n        return {\n            label: 'All',\n            labelShort: 'All',\n            start: balances?.minDate ?? DateTime.now().minus({ years: 2 }).toISODate(),\n            end: DateTime.now().toISODate(),\n        }\n    }, [balances])\n\n    useEffect(() => {\n        const loanValid = ({ loan }: SharedType.AccountDetail) => {\n            return (\n                loan &&\n                loan.originationDate &&\n                loan.originationPrincipal &&\n                loan.maturityDate &&\n                loan.interestRate &&\n                loan.loanDetail\n            )\n        }\n\n        if (account && !loanValid(account)) {\n            setShowOverlay(true)\n        } else {\n            setShowOverlay(false)\n        }\n    }, [account, editAccount])\n\n    return (\n        <div className=\"space-y-5\">\n            <div className=\"flex justify-between\">\n                <PageTitle\n                    isLoading={isLoading}\n                    title={account?.name}\n                    value={NumberUtil.format(balances?.today?.balance, 'currency')}\n                    trend={balances?.trend}\n                    trendLabel={getRangeDescription(dateRange, balances?.minDate)}\n                    trendNegative={account?.classification === 'liability'}\n                />\n                <AccountMenu account={account} />\n            </div>\n\n            <div className=\"flex justify-end\">\n                <DatePickerRange\n                    variant=\"tabs-custom\"\n                    minDate={balances?.minDate}\n                    maxDate={DateTime.now().toISODate()}\n                    value={dateRange}\n                    onChange={onDateChange}\n                    selectableRanges={[\n                        'last-30-days',\n                        'last-6-months',\n                        'last-365-days',\n                        'last-3-years',\n                        allTimeRange,\n                    ]}\n                />\n            </div>\n\n            <div className=\"h-96\">\n                <TSeries.Chart\n                    id=\"loan-chart\"\n                    isLoading={isLoading}\n                    isError={isError}\n                    dateRange={dateRange}\n                    interval={balances?.series.interval}\n                    data={balances?.series.data.map((v) => ({\n                        date: v.date,\n                        values: { balance: v.balance },\n                    }))}\n                    series={[\n                        {\n                            key: 'balances',\n                            accessorFn: (d) => d.values.balance?.toNumber(),\n                            negative: true,\n                        },\n                    ]}\n                    renderOverlay={\n                        showOverlay && account\n                            ? () => (\n                                  <>\n                                      <h3 className=\"mb-2\">Chart unavailable</h3>\n                                      <div className=\"max-w-screen-xs\">\n                                          <p className=\"text-base text-gray-50 max-w-[450px]\">\n                                              Please provide us with more details for this account\n                                              so that we can build your chart with accurate values.\n                                          </p>\n                                      </div>\n                                      <Button onClick={() => editAccount(account)} className=\"mt-4\">\n                                          Add loan terms\n                                      </Button>\n                                  </>\n                              )\n                            : undefined\n                    }\n                >\n                    <TSeries.Line seriesKey=\"balances\" />\n                </TSeries.Chart>\n            </div>\n\n            {account && (\n                <div>\n                    <LoanDetail account={account} showComingSoon={!account.transactions.length} />\n                    {account.transactions.length > 0 && (\n                        <div className=\"mt-8\">\n                            <h5 className=\"uppercase mb-6\">Payments</h5>\n                            <TransactionList accountId={account.id} />\n                        </div>\n                    )}\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "apps/client/components/account-views/index.ts",
    "content": "export { default as DefaultView } from './DefaultView'\nexport { default as LoanView } from './LoanView'\n"
  },
  {
    "path": "apps/client/env.sh",
    "content": "#!/bin/bash\n# https://github.com/vercel/next.js/discussions/17641#discussioncomment-5919914\n\n# Config\nENVSH_ENV=\"${ENVSH_ENV:-\"./.env\"}\"\nENVSH_PREFIX=\"${ENVSH_PREFIX:-\"NEXT_PUBLIC_\"}\"\nENVSH_PREFIX_STRIP=\"${ENVSH_PREFIX_STRIP:-false}\"\n\n# Can be `window.__appenv = {` or `const APPENV = {` or whatever you want\nENVSH_PREPEND=\"${ENVSH_PREPEND:-\"window.__appenv = {\"}\"\nENVSH_APPEND=\"${ENVSH_APPEND:-\"}\"}\"\nENVSH_OUTPUT=\"${ENVSH_OUTPUT:-\"./public/__appenv.js\"}\"\n\n[ -f \"$ENVSH_ENV\" ] && INPUT=\"$ENVSH_ENV\" || INPUT=/dev/null\n\n# Add assignment\necho \"$ENVSH_PREPEND\" >\"$ENVSH_OUTPUT\"\n\ngawk -v PREFIX=\"$ENVSH_PREFIX\" -v STRIP_PREFIX=\"$ENVSH_PREFIX_STRIP\" '\nBEGIN {\n   OFS=\": \";\n   FS=\"=\";\n   PATTERN=\"^\" PREFIX;\n\n   for (v in ENVIRON)\n      if (v ~ PATTERN)\n         vars[v] = ENVIRON[v]\n}\n\n$0 ~ PATTERN {\n   v = $2;\n\n   for (i = 3; i <= NF; i++)\n      v = v FS $i;\n\n   vars[$1] = (vars[$1] ? vars[$1] : v);\n}\n\nEND {\n   for (v in vars) {\n      val = vars[v];\n      switch (val) {\n         case /^true$/:\n            break;\n\n         case /^false$/:\n            break;\n\n         case /^'\"'.*'\"'$/:\n            break;\n\n         case /^\".*\"$/:\n            break;\n\n         case /^[[:digit:]]+$/:\n            break;\n\n         default:\n            val = \"\\\"\" val \"\\\"\";\n            break;\n      }\n\n      val = val \",\"\n\n      if (STRIP_PREFIX == \"true\" || STRIP_PREFIX == \"1\")\n         v = gensub(PATTERN, \"\", 1, v)\n\n      print v, val;\n   }\n}\n' \"$INPUT\" >>\"$ENVSH_OUTPUT\"\n\necho \"$ENVSH_APPEND\" >>\"$ENVSH_OUTPUT\"\n\n# Accepting commands (for Docker)\nexec \"$@\""
  },
  {
    "path": "apps/client/env.ts",
    "content": "declare global {\n    interface Window {\n        __appenv: any\n    }\n}\n\nfunction isBrowser() {\n    return Boolean(typeof window !== 'undefined' && window.__appenv)\n}\n\nfunction getEnv(key: string): string | undefined {\n    if (!key.length) {\n        throw new Error('No env key provided')\n    }\n\n    if (isBrowser()) {\n        return window.__appenv[key]\n    }\n}\n\nconst env = {\n    NEXT_PUBLIC_API_URL:\n        getEnv('NEXT_PUBLIC_API_URL') || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333',\n    NEXT_PUBLIC_LD_CLIENT_SIDE_ID:\n        getEnv('NEXT_PUBLIC_LD_CLIENT_SIDE_ID') ||\n        process.env.NEXT_PUBLIC_LD_CLIENT_SIDE_ID ||\n        'REPLACE_THIS',\n    NEXT_PUBLIC_SENTRY_DSN: getEnv('NEXT_PUBLIC_SENTRY_DSN') || process.env.NEXT_PUBLIC_SENTRY_DSN,\n    NEXT_PUBLIC_SENTRY_ENV: getEnv('NEXT_PUBLIC_SENTRY_ENV') || process.env.NEXT_PUBLIC_SENTRY_ENV,\n}\n\nexport default env\n"
  },
  {
    "path": "apps/client/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n    displayName: 'client',\n    preset: '../../jest.preset.js',\n    transform: {\n        '^(?!.*\\\\.(js|jsx|ts|tsx|css|json)$)': '@nrwl/react/plugins/jest',\n        '^.+\\\\.[tj]sx?$': 'babel-jest',\n    },\n    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n    coverageDirectory: '../../coverage/apps/client',\n}\n"
  },
  {
    "path": "apps/client/next-env.d.ts",
    "content": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edited\n// see https://nextjs.org/docs/basic-features/typescript for more information.\n"
  },
  {
    "path": "apps/client/next.config.js",
    "content": "// eslint-disable-next-line @typescript-eslint/no-var-requires\nconst withNx = require('@nrwl/next/plugins/with-nx')\nconst withBundleAnalyzer = require('@next/bundle-analyzer')({\n    enabled: process.env.ANALYZE === 'true',\n})\n\n/**\n * @type {import('@nrwl/next/plugins/with-nx').WithNxOptions}\n **/\nconst nextConfig = {\n    nx: {\n        // Set this to true if you would like to to use SVGR\n        // See: https://github.com/gregberge/svgr\n        svgr: false,\n    },\n    images: {\n        loader: 'custom',\n    },\n    swcMinify: false,\n}\n\nmodule.exports = withBundleAnalyzer(withNx(nextConfig))\n"
  },
  {
    "path": "apps/client/pages/404.tsx",
    "content": "import { NotFoundPage } from '@maybe-finance/client/features'\n\nexport function Page404() {\n    return <NotFoundPage />\n}\n\nexport default Page404\n"
  },
  {
    "path": "apps/client/pages/_app.tsx",
    "content": "import { useEffect, type PropsWithChildren, type ReactElement } from 'react'\nimport type { AppProps } from 'next/app'\nimport { ErrorBoundary } from 'react-error-boundary'\nimport { Analytics } from '@vercel/analytics/react'\nimport {\n    AxiosProvider,\n    QueryProvider,\n    ErrorFallback,\n    LogProvider,\n    UserAccountContextProvider,\n} from '@maybe-finance/client/shared'\nimport { AccountsManager, OnboardingGuard } from '@maybe-finance/client/features'\nimport { AccountContextProvider } from '@maybe-finance/client/shared'\nimport * as Sentry from '@sentry/react'\nimport { BrowserTracing } from '@sentry/tracing'\nimport env from '../env'\nimport '../styles.css'\nimport { SessionProvider, useSession } from 'next-auth/react'\nimport Meta from '../components/Meta'\nimport APM from '../components/APM'\nimport { useRouter } from 'next/router'\n\nSentry.init({\n    dsn: env.NEXT_PUBLIC_SENTRY_DSN,\n    environment: env.NEXT_PUBLIC_SENTRY_ENV,\n    integrations: [\n        new BrowserTracing({\n            tracingOrigins: ['localhost', new URL(env.NEXT_PUBLIC_API_URL).hostname],\n        }),\n    ],\n    tracesSampleRate: 0.6,\n})\n\n// Providers and components only relevant to a logged-in user\nconst WithAuth = function ({ children }: PropsWithChildren) {\n    const { data: session, status } = useSession()\n    const router = useRouter()\n\n    useEffect(() => {\n        if (status === 'loading') return\n\n        if (!session) {\n            router.push('/login')\n        }\n    }, [session, status, router])\n\n    if (session && status === 'authenticated') {\n        return (\n            <OnboardingGuard>\n                <UserAccountContextProvider>\n                    <AccountContextProvider>\n                        {children}\n                        <AccountsManager />\n                    </AccountContextProvider>\n                </UserAccountContextProvider>\n            </OnboardingGuard>\n        )\n    }\n    return null\n}\n\nexport default function App({\n    Component: Page,\n    pageProps,\n}: AppProps & {\n    Component: AppProps['Component'] & {\n        getLayout?: (component: ReactElement) => JSX.Element\n        isPublic?: boolean\n    }\n}) {\n    const getLayout = Page.getLayout ?? ((page) => page)\n\n    return (\n        <LogProvider logger={console}>\n            <ErrorBoundary\n                FallbackComponent={ErrorFallback}\n                onError={(err) => {\n                    Sentry.captureException(err)\n                    console.error('React app crashed', err)\n                }}\n            >\n                <Meta />\n                <Analytics />\n                <QueryProvider>\n                    <SessionProvider>\n                        <AxiosProvider baseUrl={env.NEXT_PUBLIC_API_URL}>\n                            <>\n                                <APM />\n                                {Page.isPublic === true ? (\n                                    getLayout(<Page {...pageProps} />)\n                                ) : (\n                                    <WithAuth>{getLayout(<Page {...pageProps} />)}</WithAuth>\n                                )}\n                            </>\n                        </AxiosProvider>\n                    </SessionProvider>\n                </QueryProvider>\n            </ErrorBoundary>\n        </LogProvider>\n    )\n}\n"
  },
  {
    "path": "apps/client/pages/_document.tsx",
    "content": "import { Html, Head, Main, NextScript } from 'next/document'\n\nexport default function Document() {\n    return (\n        <Html lang=\"en\">\n            <Head>\n                {/* <!-- NEXT_PUBLIC_ env variables --> */}\n                {/* eslint-disable-next-line @next/next/no-sync-scripts */}\n                <script src=\"/__appenv.js\" />\n            </Head>\n            <body>\n                <Main />\n                <NextScript />\n            </body>\n        </Html>\n    )\n}\n"
  },
  {
    "path": "apps/client/pages/accounts/[accountId].tsx",
    "content": "import type { ReactElement } from 'react'\nimport type { SharedType } from '@maybe-finance/shared'\n\nimport { useEffect, useState } from 'react'\nimport {\n    WithSidebarLayout,\n    ValuationList,\n    TransactionList,\n    AccountSidebar,\n} from '@maybe-finance/client/features'\nimport { useRouter } from 'next/router'\nimport { DateTime } from 'luxon'\nimport {\n    MainContentOverlay,\n    useAccountApi,\n    useQueryParam,\n    useUserAccountContext,\n} from '@maybe-finance/client/shared'\nimport { DefaultView, LoanView } from '../../components/account-views'\n\nimport InvestmentView from '../../components/account-views/InvestmentView'\n\nconst initialRange = {\n    start: DateTime.now().minus({ days: 30 }).toISODate(),\n    end: DateTime.now().toISODate(),\n}\n\nexport default function AccountDetailPage() {\n    const router = useRouter()\n    const [range, setRange] = useState<SharedType.DateRange>(initialRange)\n\n    const { useAccount, useAccountBalances } = useAccountApi()\n    const { isReady, accountSyncing } = useUserAccountContext()\n\n    const accountId = useQueryParam('accountId', 'number')!\n    const accountQuery = useAccount(accountId, { enabled: !!accountId && isReady })\n    const accountBalancesQuery = useAccountBalances(\n        { id: accountId, ...range },\n        { enabled: !!accountId && isReady }\n    )\n\n    const isSyncing = accountSyncing(accountId)\n    const isLoading = accountQuery.isLoading || accountBalancesQuery.isLoading || isSyncing\n    const isError = accountQuery.isError || accountBalancesQuery.isError\n\n    useEffect(() => {\n        setRange(initialRange)\n    }, [accountId])\n\n    if (accountQuery.error || accountBalancesQuery.error) {\n        return (\n            <MainContentOverlay\n                title=\"Unable to load account\"\n                actionText=\"Back home\"\n                onAction={() => {\n                    router.push('/')\n                }}\n            >\n                <p>\n                    We&rsquo;re having some trouble loading this account. Please contact us if the\n                    issue persists...\n                </p>\n            </MainContentOverlay>\n        )\n    }\n\n    switch (accountQuery.data?.type) {\n        case 'LOAN':\n            return (\n                <LoanView\n                    account={accountQuery.data}\n                    balances={accountBalancesQuery.data}\n                    dateRange={range}\n                    onDateChange={setRange}\n                    isLoading={isLoading}\n                    isError={isError}\n                />\n            )\n        case 'INVESTMENT':\n            return (\n                <InvestmentView\n                    account={accountQuery.data}\n                    balances={accountBalancesQuery.data}\n                    dateRange={range}\n                    onDateChange={setRange}\n                    isLoading={isLoading}\n                    isError={isError}\n                />\n            )\n        case 'CREDIT':\n        case 'DEPOSITORY':\n            return (\n                <DefaultView\n                    account={accountQuery.data}\n                    balances={accountBalancesQuery.data}\n                    dateRange={range}\n                    onDateChange={setRange}\n                    getContent={(accountId) => {\n                        return (\n                            <>\n                                <h5 className=\"uppercase mb-6\">Transactions</h5>\n                                <TransactionList accountId={accountId} />\n                            </>\n                        )\n                    }}\n                    isLoading={isLoading}\n                    isError={isError}\n                    selectableDateRanges={[\n                        'last-7-days',\n                        'last-30-days',\n                        'last-90-days',\n                        'last-365-days',\n                        'this-year',\n                    ]}\n                />\n            )\n        default:\n            return (\n                <DefaultView\n                    account={accountQuery.data}\n                    balances={accountBalancesQuery.data}\n                    dateRange={range}\n                    onDateChange={setRange}\n                    getContent={(accountId: number) => {\n                        return (\n                            <ValuationList\n                                accountId={accountId}\n                                negative={accountQuery.data?.classification === 'liability'}\n                            />\n                        )\n                    }}\n                    isLoading={isLoading}\n                    isError={isError}\n                />\n            )\n    }\n}\n\nAccountDetailPage.getLayout = function getLayout(page: ReactElement) {\n    return <WithSidebarLayout sidebar={<AccountSidebar />}>{page}</WithSidebarLayout>\n}\n"
  },
  {
    "path": "apps/client/pages/accounts/index.tsx",
    "content": "import type { ReactElement } from 'react'\nimport { RiAddLine } from 'react-icons/ri'\n\nimport {\n    AccountGroupContainer,\n    ManualAccountGroup,\n    AccountDevTools,\n    ConnectedAccountGroup,\n    DisconnectedAccountGroup,\n    WithSidebarLayout,\n    AccountSidebar,\n} from '@maybe-finance/client/features'\nimport {\n    MainContentLoader,\n    MainContentOverlay,\n    useAccountApi,\n    useAccountContext,\n} from '@maybe-finance/client/shared'\nimport { Button } from '@maybe-finance/design-system'\nimport { AccountUtil } from '@maybe-finance/shared'\n\nexport default function AccountsPage() {\n    const { useAccounts } = useAccountApi()\n    const { addAccount } = useAccountContext()\n\n    const { isLoading, error, data, refetch } = useAccounts()\n\n    if (isLoading) {\n        return <MainContentLoader />\n    }\n\n    if (error || !data) {\n        return (\n            <MainContentOverlay\n                title=\"Unable to load accounts\"\n                actionText=\"Try again\"\n                onAction={() => refetch()}\n            >\n                <p>\n                    We&lsquo;re having some trouble loading your accounts. Please contact us{' '}\n                    <a href=\"mailto:hello@maybe.co\" className=\"underline text-cyan\">\n                        here\n                    </a>{' '}\n                    if the issue persists.\n                </p>\n            </MainContentOverlay>\n        )\n    }\n\n    const { accounts, connections } = data\n\n    const disconnected = connections.filter((c) => c.status === 'DISCONNECTED')\n    const connected = connections.filter((c) => c.status !== 'DISCONNECTED')\n\n    if (\n        !isLoading &&\n        disconnected.length === 0 &&\n        connected.length === 0 &&\n        accounts.length === 0\n    ) {\n        return (\n            <>\n                <AccountDevTools />\n                <MainContentOverlay\n                    title=\"No accounts yet\"\n                    actionText=\"Add account\"\n                    onAction={addAccount}\n                >\n                    <p>\n                        You currently have no connected or manual accounts. Start by adding an\n                        account.\n                    </p>\n                </MainContentOverlay>\n            </>\n        )\n    }\n\n    return (\n        <div>\n            <AccountDevTools />\n\n            <div className=\"flex items-center justify-between\">\n                <h3>Accounts</h3>\n                <Button onClick={addAccount} leftIcon={<RiAddLine size={20} />}>\n                    Add account\n                </Button>\n            </div>\n\n            <div className=\"mt-8 space-y-8\">\n                {connected.length > 0 && (\n                    <AccountGroupContainer title=\"CONNECTED\">\n                        {connected.map((connection) => (\n                            <ConnectedAccountGroup key={connection.id} connection={connection} />\n                        ))}\n                    </AccountGroupContainer>\n                )}\n\n                {accounts.length > 0 && (\n                    <AccountGroupContainer title=\"MANUAL\">\n                        {AccountUtil.groupAccountsByCategory(accounts).map(\n                            ({ category, subtitle, accounts }) => (\n                                <ManualAccountGroup\n                                    key={category}\n                                    title={category}\n                                    subtitle={subtitle}\n                                    accounts={accounts}\n                                />\n                            )\n                        )}\n                    </AccountGroupContainer>\n                )}\n\n                {disconnected.length > 0 && (\n                    <AccountGroupContainer title=\"DISCONNECTED\">\n                        {disconnected.map((connection) => (\n                            <DisconnectedAccountGroup key={connection.id} connection={connection} />\n                        ))}\n                    </AccountGroupContainer>\n                )}\n            </div>\n        </div>\n    )\n}\n\nAccountsPage.getLayout = function getLayout(page: ReactElement) {\n    return <WithSidebarLayout sidebar={<AccountSidebar />}>{page}</WithSidebarLayout>\n}\n"
  },
  {
    "path": "apps/client/pages/api/auth/[...nextauth].ts",
    "content": "import NextAuth from 'next-auth'\nimport type { SessionStrategy, NextAuthOptions } from 'next-auth'\nimport CredentialsProvider from 'next-auth/providers/credentials'\nimport { z } from 'zod'\nimport { PrismaClient, AuthUserRole } from '@prisma/client'\nimport type { Prisma } from '@prisma/client'\nimport { PrismaAdapter } from '@auth/prisma-adapter'\nimport type { SharedType } from '@maybe-finance/shared'\nimport bcrypt from 'bcrypt'\n\nlet prismaInstance: PrismaClient | null = null\n\nfunction getPrismaInstance() {\n    if (!prismaInstance) {\n        prismaInstance = new PrismaClient()\n    }\n    return prismaInstance\n}\n\nconst prisma = getPrismaInstance()\n\nasync function createAuthUser(data: Prisma.AuthUserCreateInput) {\n    const authUser = await prisma.authUser.create({ data: { ...data } })\n    return authUser\n}\n\nasync function getAuthUserByEmail(email: string) {\n    if (!email) throw new Error('No email provided.')\n    return await prisma.authUser.findUnique({\n        where: { email },\n    })\n}\n\nasync function validateCredentials(credentials: any): Promise<z.infer<typeof authSchema>> {\n    const authSchema = z.object({\n        firstName: z.string().optional(),\n        lastName: z.string().optional(),\n        email: z.string().email({ message: 'Invalid email address.' }),\n        password: z.string().min(6),\n        role: z.string().default('user'),\n    })\n\n    const parsed = authSchema.safeParse(credentials)\n    if (!parsed.success) {\n        throw new Error(parsed.error.issues.map((issue) => issue.message).join(', '))\n    }\n\n    return parsed.data\n}\n\nasync function createNewAuthUser(credentials: {\n    firstName: string\n    lastName: string\n    email: string\n    password: string\n    role: string\n}): Promise<SharedType.AuthUser> {\n    const { firstName, lastName, email, password, role } = credentials\n\n    if (!firstName || !lastName) {\n        throw new Error('Both first name and last name are required.')\n    }\n\n    const isDevelopment = process.env.NODE_ENV === 'development'\n\n    let userRole: AuthUserRole\n\n    if (role === AuthUserRole.admin && isDevelopment) {\n        userRole = AuthUserRole.admin\n    } else if (role === AuthUserRole.ci) {\n        userRole = AuthUserRole.ci\n    } else {\n        userRole = AuthUserRole.user\n    }\n\n    const hashedPassword = await bcrypt.hash(password, 10)\n    return createAuthUser({\n        firstName,\n        lastName,\n        name: `${firstName} ${lastName}`,\n        email,\n        password: hashedPassword,\n        role: userRole,\n    })\n}\n\nconst authPrisma = {\n    account: prisma.authAccount,\n    user: prisma.authUser,\n    session: prisma.authSession,\n    verificationToken: prisma.authVerificationToken,\n} as unknown as PrismaClient\n\nexport const authOptions = {\n    adapter: PrismaAdapter(authPrisma),\n    secret: process.env.NEXTAUTH_SECRET || 'CHANGE_ME',\n    pages: {\n        signIn: '/login',\n    },\n    session: {\n        strategy: 'jwt' as SessionStrategy,\n        maxAge: 1 * 24 * 60 * 60, // 1 Day\n    },\n    providers: [\n        CredentialsProvider({\n            name: 'Credentials',\n            type: 'credentials',\n            credentials: {\n                firstName: { label: 'First name', type: 'text', placeholder: 'First name' },\n                lastName: { label: 'Last name', type: 'text', placeholder: 'Last name' },\n                email: { label: 'Email', type: 'email', placeholder: 'hello@maybe.co' },\n                password: { label: 'Password', type: 'password' },\n                role: { label: 'Admin', type: 'text' },\n            },\n            async authorize(credentials) {\n                const { firstName, lastName, email, password, role } = await validateCredentials(\n                    credentials\n                )\n\n                const existingUser = await getAuthUserByEmail(email)\n                if (existingUser) {\n                    const isPasswordMatch = await bcrypt.compare(password, existingUser.password!)\n                    if (!isPasswordMatch) {\n                        throw new Error('Email or password is invalid.')\n                    }\n\n                    return existingUser\n                }\n\n                if (!firstName || !lastName) {\n                    throw new Error('Invalid credentials provided.')\n                }\n\n                return createNewAuthUser({ firstName, lastName, email, password, role })\n            },\n        }),\n    ],\n    callbacks: {\n        async jwt({ token, user: authUser }: { token: any; user: any }) {\n            if (authUser) {\n                token.sub = authUser.id\n                token['https://maybe.co/email'] = authUser.email\n                token.firstName = authUser.firstName\n                token.lastName = authUser.lastName\n                token.name = authUser.name\n                token.role = authUser.role\n            }\n            return token\n        },\n        async session({ session, token }: { session: any; token: any }) {\n            session.user = token.sub\n            session.sub = token.sub\n            session['https://maybe.co/email'] = token['https://maybe.co/email']\n            session.firstName = token.firstName\n            session.lastName = token.lastName\n            session.name = token.name\n            session.role = token.role\n            return session\n        },\n    },\n} as NextAuthOptions\n\nexport default NextAuth(authOptions)\n"
  },
  {
    "path": "apps/client/pages/api/card.tsx",
    "content": "import { ImageResponse } from '@vercel/og'\nimport { DateTime } from 'luxon'\nimport type { NextRequest } from 'next/server'\n\nexport const config = {\n    runtime: 'experimental-edge',\n}\n\nconst font = fetch(\n    new URL('../../public/assets/fonts/inter/Inter-Regular.ttf', import.meta.url)\n).then((res) => res.arrayBuffer())\n\nconst now = DateTime.now()\n\nexport default async function handler(req: NextRequest) {\n    const fontData = await font\n    const { headers } = req\n    const protocol = headers.get('x-forwarded-proto') || 'http'\n    const host = headers.get('host')\n    const baseUrl = `${protocol}://${host}`\n\n    try {\n        const { searchParams } = new URL(req.url)\n        const isTwitter = searchParams.has('twitter')\n        const date = searchParams.get('date')\n\n        return new ImageResponse(\n            (\n                <div\n                    style={{\n                        fontSize: 16,\n                        lineHeight: '24px',\n                        color: '#868E96',\n                        background: '#1c1c20',\n                        width: '100%',\n                        height: '100%',\n                        display: 'flex',\n                        textAlign: 'center',\n                        alignItems: 'center',\n                        justifyContent: 'center',\n                        fontFamily: '\"Inter\"',\n                        transform: `scale(${isTwitter ? 1 : 1.5})`,\n                    }}\n                >\n                    <div\n                        style={{\n                            width: '406px',\n                            height: '528px',\n                            display: 'flex',\n                            alignItems: 'center',\n                            justifyContent: 'center',\n                        }}\n                    >\n                        <img\n                            alt=\"\"\n                            src={`${baseUrl}/assets/maybe-card.png`}\n                            style={{ position: 'absolute', width: '100%' }}\n                        />\n                        <div\n                            style={{\n                                width: '276px',\n                                height: '398px',\n                                display: 'flex',\n                                flexDirection: 'column',\n                                justifyContent: 'flex-end',\n                                alignItems: 'center',\n                                padding: '24px',\n                            }}\n                        >\n                            <span style={{ fontSize: 12, lineHeight: '16px' }}>\n                                #{searchParams.get('number')?.padStart(3, '0') || '000'}\n                            </span>\n                            <span style={{ color: '#FFF', marginTop: '4px' }}>\n                                {searchParams.get('name') || 'Maybe User'}\n                            </span>\n                            <span>{searchParams.get('title') || ' '}</span>\n                            <span style={{ marginTop: '6px', fontSize: 12, lineHeight: '16px' }}>\n                                Joined {(date ? DateTime.fromISO(date) : now).toFormat('LL.dd.yy')}\n                            </span>\n                        </div>\n                    </div>\n                </div>\n            ),\n            {\n                width: 1200,\n                height: isTwitter ? 628 : 1200,\n                fonts: [\n                    {\n                        name: 'Inter',\n                        data: fontData,\n                        style: 'normal',\n                    },\n                ],\n            }\n        )\n    } catch (e) {\n        return new Response('Failed to generate image', { status: 500 })\n    }\n}\n"
  },
  {
    "path": "apps/client/pages/card/[id].tsx",
    "content": "import { MaybeCard } from '@maybe-finance/client/shared'\nimport { type SharedType, superjson } from '@maybe-finance/shared'\nimport { Button, LoadingSpinner, Tooltip } from '@maybe-finance/design-system'\nimport Head from 'next/head'\nimport { useState } from 'react'\nimport { RiAnticlockwise2Line } from 'react-icons/ri'\nimport env from '../../env'\nimport { NotFoundPage } from '@maybe-finance/client/features'\n\nexport default function Card({ rawData }: { rawData: any }) {\n    const [isFlipped, setIsFlipped] = useState(false)\n\n    if (!rawData.data) return <NotFoundPage />\n\n    const data = superjson.deserialize(rawData.data) as SharedType.UserMemberCardDetails\n\n    const title = data.name.trim()\n        ? data.name\n              .trim()\n              .split(' ')\n              .map((part, idx) => (idx > 0 ? part.substring(0, 1) : part))\n              .join(' ') + \"'s Maybe\"\n        : `Maybe Card #${data.memberNumber}`\n\n    return (\n        <>\n            <Head>\n                <title>{title}</title>\n                <meta property=\"og:image\" content={data.imageUrl} />\n                <meta property=\"twitter:image\" content={`${data.imageUrl}&twitter`} />\n            </Head>\n            <div className=\"fixed inset-0 flex flex-col items-center custom-gray-scroll pb-24\">\n                <a href=\"https://maybe.co\" className=\"mt-12 md:mt-32 shrink-0\">\n                    <img src=\"/assets/maybe-full.svg\" alt=\"Maybe\" className=\"h-8\" />\n                </a>\n                <div className=\"mt-8 w-[342px] sm:w-[406px] h-[464px] sm:h-[528px] shrink-0 flex justify-center items-center bg-gray-800 rounded-2xl overflow-hidden\">\n                    {data ? (\n                        <MaybeCard variant=\"default\" details={data} flipped={isFlipped} />\n                    ) : (\n                        <LoadingSpinner />\n                    )}\n                </div>\n                <div className=\"mt-4 shrink-0\">\n                    <Tooltip content=\"Flip card\" placement=\"bottom\">\n                        <div className=\"w-full\">\n                            <Button\n                                type=\"button\"\n                                variant=\"secondary\"\n                                className=\"w-24\"\n                                onClick={() => setIsFlipped((flipped) => !flipped)}\n                            >\n                                <RiAnticlockwise2Line className=\"w-5 h-5 text-gray-50\" />\n                            </Button>\n                        </div>\n                    </Tooltip>\n                </div>\n            </div>\n        </>\n    )\n}\n\nexport async function getServerSideProps(context) {\n    const { id } = context.query\n\n    return {\n        props: {\n            rawData: await fetch(`${env.NEXT_PUBLIC_API_URL}/v1/users/card/${id}`).then((data) =>\n                data.json()\n            ),\n        },\n    }\n}\n\nCard.isPublic = true\n"
  },
  {
    "path": "apps/client/pages/data-editor.tsx",
    "content": "import type { ReactElement } from 'react'\nimport { useMemo } from 'react'\nimport {\n    WithSidebarLayout,\n    AccountEditor,\n    TransactionEditor,\n    AccountSidebar,\n} from '@maybe-finance/client/features'\n\nimport { Tab } from '@maybe-finance/design-system'\nimport { useQueryParam } from '@maybe-finance/client/shared'\nimport { useRouter } from 'next/router'\n\nexport default function DataEditor() {\n    const currentTab = useQueryParam('tab', 'string')\n\n    const router = useRouter()\n\n    const selectedIndex = useMemo(() => {\n        switch (currentTab) {\n            case 'transactions':\n                return 1\n            default:\n                return 0\n        }\n    }, [currentTab])\n\n    return (\n        <div>\n            <h4>Fix my data</h4>\n            <p className=\"text-base text-gray-100 mt-2\">\n                Is one of your accounts misclassified? A transaction showing the wrong category?\n                Update your data below so we can show you the best insights possible.\n            </p>\n            <div className=\"mt-4\">\n                <Tab.Group\n                    onChange={(idx) => {\n                        switch (idx) {\n                            case 0:\n                                router.replace({ query: { tab: 'accounts' } })\n                                break\n                            case 1:\n                                router.replace({ query: { tab: 'transactions' } })\n                                break\n                        }\n                    }}\n                    selectedIndex={selectedIndex}\n                >\n                    <Tab.List>\n                        <Tab>Accounts</Tab>\n                        <Tab>Transactions</Tab>\n                    </Tab.List>\n                    <Tab.Panels className=\"mt-4\">\n                        <Tab.Panel>{router.isReady && <AccountEditor />}</Tab.Panel>\n                        <Tab.Panel>{router.isReady && <TransactionEditor />}</Tab.Panel>\n                    </Tab.Panels>\n                </Tab.Group>\n            </div>\n        </div>\n    )\n}\n\nDataEditor.getLayout = function getLayout(page: ReactElement) {\n    return <WithSidebarLayout sidebar={<AccountSidebar />}>{page}</WithSidebarLayout>\n}\n"
  },
  {
    "path": "apps/client/pages/index.tsx",
    "content": "import type { ReactElement } from 'react'\nimport { useState } from 'react'\nimport {\n    WithSidebarLayout,\n    NetWorthInsightDetail,\n    NetWorthPrimaryCardGroup,\n    PageTitle,\n    AccountSidebar,\n} from '@maybe-finance/client/features'\nimport {\n    MainContentOverlay,\n    useAccountApi,\n    useUserApi,\n    useAccountContext,\n    useUserAccountContext,\n    TSeries,\n} from '@maybe-finance/client/shared'\nimport { DateTime } from 'luxon'\nimport uniq from 'lodash/uniq'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { DatePickerRange, getRangeDescription, Listbox } from '@maybe-finance/design-system'\nimport { RiLineChartLine } from 'react-icons/ri'\nimport type { AccountCategory } from '@prisma/client'\n\nfunction NoAccounts() {\n    const { addAccount } = useAccountContext()\n\n    return (\n        <MainContentOverlay title=\"No accounts yet\" actionText=\"Add account\" onAction={addAccount}>\n            <p>You currently have no connected or manual accounts. Start by adding an account.</p>\n        </MainContentOverlay>\n    )\n}\n\nfunction AllAccountsDisabled() {\n    const { addAccount } = useAccountContext()\n\n    // This user does not have any active accounts\n    return (\n        <MainContentOverlay\n            title=\"No accounts enabled\"\n            actionText=\"Add account\"\n            onAction={addAccount}\n        >\n            <p>All your accounts are currently disabled. Enable one or connect a new account.</p>\n        </MainContentOverlay>\n    )\n}\n\nconst chartViews = [\n    {\n        value: 'net-worth',\n        display: 'Net worth value',\n    },\n    {\n        value: 'assets-debts',\n        display: 'Assets & debts',\n    },\n    {\n        value: 'all',\n        display: 'All categories',\n    },\n]\n\nconst categoryColors = ['blue', 'teal', 'orange', 'pink', 'grape', 'green', 'red', 'indigo', 'cyan']\n\nexport default function IndexPage() {\n    const [chartView, setChartView] = useState(chartViews[0])\n\n    const { useNetWorthSeries, useInsights } = useUserApi()\n    const { useAccounts } = useAccountApi()\n\n    const { isReady, someAccountsSyncing, noAccounts, allAccountsDisabled } =\n        useUserAccountContext()\n\n    const { dateRange, setDateRange } = useAccountContext()\n\n    const accountsQuery = useAccounts()\n    const insightsQuery = useInsights()\n\n    const netWorthQuery = useNetWorthSeries(\n        { start: dateRange.start, end: dateRange.end },\n        { enabled: isReady && !noAccounts && !allAccountsDisabled }\n    )\n\n    const isLoading = someAccountsSyncing || accountsQuery.isLoading || netWorthQuery.isLoading\n\n    if (netWorthQuery.error || accountsQuery.error) {\n        return (\n            <MainContentOverlay\n                title=\"Unable to load data\"\n                actionText=\"Try again\"\n                onAction={() => {\n                    netWorthQuery.refetch()\n                }}\n            >\n                <p>\n                    We&rsquo;re having some trouble loading your data. Please contact us if the\n                    issue persists...\n                </p>\n            </MainContentOverlay>\n        )\n    }\n\n    // This user has not created any accounts yet\n    if (noAccounts) {\n        return <NoAccounts />\n    }\n\n    if (allAccountsDisabled) {\n        return <AllAccountsDisabled />\n    }\n\n    const seriesData = netWorthQuery.data?.series.data ?? []\n\n    const categoriesWithData = uniq(\n        seriesData.flatMap((d) =>\n            Object.entries(d.categories)\n                .filter(([_category, amount]) => !amount.isZero())\n                .map(([category, _amount]) => category as AccountCategory)\n        ) ?? []\n    )\n\n    return (\n        <div className=\"space-y-5\">\n            <PageTitle\n                isLoading={isLoading}\n                title=\"Net worth\"\n                value={NumberUtil.format(netWorthQuery.data?.today?.netWorth, 'currency')}\n                trend={netWorthQuery.data?.trend}\n                trendLabel={getRangeDescription(dateRange, netWorthQuery.data?.minDate)}\n            />\n\n            <div className=\"flex flex-wrap justify-between items-center gap-2\">\n                <Listbox className=\"inline-block\" value={chartView} onChange={setChartView}>\n                    <Listbox.Button icon={RiLineChartLine}>{chartView.display}</Listbox.Button>\n                    <Listbox.Options>\n                        {chartViews.map((view) => (\n                            <Listbox.Option key={view.value} value={view}>\n                                {view.display}\n                            </Listbox.Option>\n                        ))}\n                    </Listbox.Options>\n                </Listbox>\n\n                <DatePickerRange\n                    variant=\"tabs-custom\"\n                    minDate={netWorthQuery.data && netWorthQuery.data.minDate}\n                    maxDate={DateTime.now().toISODate()}\n                    value={dateRange}\n                    onChange={setDateRange}\n                    selectableRanges={[\n                        'last-30-days',\n                        'last-90-days',\n                        'last-365-days',\n                        'this-year',\n                        {\n                            label: 'All time',\n                            labelShort: 'All',\n                            start: netWorthQuery.data\n                                ? netWorthQuery.data.minDate\n                                : DateTime.now().minus({ years: 3 }).toISODate(),\n                            end: DateTime.now().toISODate(),\n                        },\n                    ]}\n                />\n            </div>\n\n            <div className=\"h-96\">\n                <TSeries.Chart\n                    id=\"net-worth-chart\"\n                    dateRange={dateRange}\n                    interval={netWorthQuery.data?.series.interval}\n                    tooltipOptions={{ renderInPortal: true }}\n                    isLoading={netWorthQuery.isLoading}\n                    isError={netWorthQuery.isError}\n                    data={seriesData.map(({ date, ...values }) => ({ date, values }))}\n                    series={[\n                        {\n                            key: 'net-worth',\n                            label: chartView.value === 'net-worth' ? undefined : 'Net worth',\n                            accessorFn: (d) => d.values?.netWorth?.toNumber(),\n                            isActive: chartView.value !== 'all',\n                            color: TSeries.tailwindScale('cyan'),\n                        },\n                        {\n                            key: 'assets',\n                            label: 'Assets',\n                            accessorFn: (d) => d.values?.assets?.toNumber(),\n                            isActive: chartView.value === 'assets-debts',\n                            color: TSeries.tailwindScale('teal'),\n                        },\n                        {\n                            key: 'liabilities',\n                            label: 'Debts',\n                            accessorFn: (d) => d.values?.liabilities?.toNumber(),\n                            isActive: chartView.value === 'assets-debts',\n                            color: TSeries.tailwindScale('red'),\n                        },\n                        ...categoriesWithData.map((category, idx) => ({\n                            key: category,\n                            accessorFn: (d) => d.values?.categories?.[category]?.toNumber(),\n                            label: category,\n                            isActive: chartView.value === 'all',\n                            color: TSeries.tailwindScale(categoryColors[idx]),\n                        })),\n                    ]}\n                >\n                    <TSeries.Line seriesKey=\"assets\" gradientOpacity={0.1} />\n                    <TSeries.Line seriesKey=\"liabilities\" gradientOpacity={0.1} />\n\n                    {categoriesWithData.map((category) => (\n                        <TSeries.Line key={category} seriesKey={category} gradientOpacity={0.1} />\n                    ))}\n\n                    {/* Keep at bottom so net worth line always has the highest stack index */}\n                    <TSeries.Line\n                        seriesKey=\"net-worth\"\n                        gradientOpacity={chartView.value === 'net-worth' ? 0.2 : 0.1}\n                    />\n                </TSeries.Chart>\n            </div>\n\n            <div>\n                {insightsQuery.isError ? (\n                    <div className=\"my-6 p-10 rounded-lg bg-gray-800 text-gray-100\">\n                        <p>Something went wrong loading your metrics. Please contact us.</p>\n                    </div>\n                ) : (\n                    <div className=\"space-y-6\">\n                        <NetWorthPrimaryCardGroup query={insightsQuery} />\n                        <NetWorthInsightDetail query={insightsQuery} />\n                    </div>\n                )}\n            </div>\n        </div>\n    )\n}\n\nIndexPage.getLayout = function getLayout(page: ReactElement) {\n    return <WithSidebarLayout sidebar={<AccountSidebar />}>{page}</WithSidebarLayout>\n}\n"
  },
  {
    "path": "apps/client/pages/login.tsx",
    "content": "import { useState, type ReactElement } from 'react'\nimport { FullPageLayout } from '@maybe-finance/client/features'\nimport { Input, InputPassword, Button } from '@maybe-finance/design-system'\nimport { signIn, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/router'\nimport { useEffect } from 'react'\nimport Script from 'next/script'\nimport Link from 'next/link'\n\nexport default function LoginPage() {\n    const [email, setEmail] = useState('')\n    const [password, setPassword] = useState('')\n    const [isValid, setIsValid] = useState(false)\n    const [errorMessage, setErrorMessage] = useState<string | null>(null)\n    const [isLoading, setIsLoading] = useState(false)\n\n    const { data: session } = useSession()\n    const router = useRouter()\n\n    useEffect(() => {\n        if (session) {\n            router.push('/')\n        }\n    }, [session, router])\n\n    const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n        e.preventDefault()\n        setErrorMessage(null)\n        setPassword('')\n        setIsLoading(true)\n\n        const response = await signIn('credentials', {\n            email,\n            password,\n            redirect: false,\n        })\n\n        if (response && response.error) {\n            setErrorMessage(response.error)\n            setIsLoading(false)\n            setIsValid(false)\n        }\n    }\n\n    const onPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n        setErrorMessage(null)\n        setPassword(e.target.value)\n        setIsValid(e.target.value.length > 0)\n    }\n\n    return (\n        <>\n            <Script\n                src=\"https://cdnjs.cloudflare.com/ajax/libs/zxcvbn/4.4.2/zxcvbn.js\"\n                strategy=\"lazyOnload\"\n            />\n            <div className=\"absolute inset-0 flex flex-col items-center justify-center\">\n                <div className=\"p-px w-80 md:w-96 bg-white bg-opacity-10 card-light rounded-3xl radial-gradient-background\">\n                    <div className=\"bg-black bg-opacity-75 p-8 rounded-3xl w-full h-full items-center flex flex-col radial-gradient-background-dark\">\n                        <img\n                            className=\"mb-8\"\n                            src=\"/assets/maybe-box.svg\"\n                            alt=\"Maybe Finance Logo\"\n                            height={120}\n                            width={120}\n                        />\n                        <form className=\"space-y-4 w-full px-4\" onSubmit={onSubmit}>\n                            <Input\n                                type=\"text\"\n                                name=\"email\"\n                                label=\"Email\"\n                                value={email}\n                                onChange={(e) => setEmail(e.currentTarget.value)}\n                            />\n\n                            <InputPassword\n                                autoComplete=\"password\"\n                                name=\"password\"\n                                label=\"Password\"\n                                value={password}\n                                onChange={onPasswordChange}\n                                showComplexityBar={false}\n                            />\n\n                            {errorMessage && password.length === 0 ? (\n                                <div className=\"py-1 text-center text-red text-sm\">\n                                    {errorMessage}\n                                </div>\n                            ) : null}\n\n                            <Button\n                                type=\"submit\"\n                                fullWidth\n                                disabled={!isValid || isLoading}\n                                variant={isValid ? 'primary' : 'secondary'}\n                                isLoading={isLoading}\n                            >\n                                Log in\n                            </Button>\n                            <div className=\"text-sm text-gray-50 text-center\">\n                                <div>\n                                    Don&apos;t have an account?{' '}\n                                    <Link\n                                        className=\"hover:text-cyan-400 underline font-medium\"\n                                        href=\"/register\"\n                                    >\n                                        Sign up\n                                    </Link>\n                                </div>\n                            </div>\n                        </form>\n                    </div>\n                </div>\n            </div>\n        </>\n    )\n}\n\nLoginPage.getLayout = function getLayout(page: ReactElement) {\n    return <FullPageLayout>{page}</FullPageLayout>\n}\n\nLoginPage.isPublic = true\n"
  },
  {
    "path": "apps/client/pages/onboarding.tsx",
    "content": "import type { ReactElement } from 'react'\nimport { Transition } from '@headlessui/react'\nimport {\n    AddFirstAccount,\n    EmailVerification,\n    FullPageLayout,\n    Intro,\n    OtherAccounts,\n    Profile,\n    OnboardingNavbar,\n    Welcome,\n    YourMaybe,\n    OnboardingBackground,\n    type StepProps,\n} from '@maybe-finance/client/features'\nimport { MainContentOverlay, useQueryParam, useUserApi } from '@maybe-finance/client/shared'\nimport { LoadingSpinner } from '@maybe-finance/design-system'\nimport classNames from 'classnames'\nimport { useRouter } from 'next/router'\n\nfunction getStepComponent(stepKey?: string): (props: StepProps) => JSX.Element {\n    switch (stepKey) {\n        case 'profile':\n            return Profile\n        case 'verifyEmail':\n            return EmailVerification\n        case 'firstAccount':\n            return AddFirstAccount\n        case 'accountSelection':\n            return OtherAccounts\n        case 'maybe':\n            return YourMaybe\n        case 'welcome':\n            return Welcome\n        case 'intro':\n        default:\n            return Intro\n    }\n}\n\nexport default function OnboardingPage() {\n    const router = useRouter()\n    const { useOnboarding, useUpdateOnboarding } = useUserApi()\n\n    const stepParam = useQueryParam('step', 'string')\n\n    const onboarding = useOnboarding('main', {\n        onSuccess: (flow) => {\n            if (!stepParam) {\n                if (flow.currentStep) {\n                    router.push({\n                        pathname: '/onboarding',\n                        query: { step: flow.currentStep.key },\n                    })\n                } else {\n                    router.push('/')\n                }\n            }\n        },\n    })\n\n    const updateOnboarding = useUpdateOnboarding()\n\n    if (onboarding.isLoading || !stepParam) {\n        return (\n            <div className=\"absolute inset-0 flex items-center justify-center h-full\">\n                <LoadingSpinner />\n            </div>\n        )\n    }\n\n    if (onboarding.isError) {\n        return (\n            <MainContentOverlay\n                title=\"Unable to load onboarding flow\"\n                actionText=\"Try again\"\n                onAction={() => window.location.reload()}\n            >\n                <p>Contact us if this issue persists.</p>\n            </MainContentOverlay>\n        )\n    }\n\n    const { steps } = onboarding.data\n    const currentStep = steps.find((step) => step.key === stepParam)\n    const currentStepIdx = steps.findIndex((step) => step.key === stepParam)\n    const StepComponent = getStepComponent(stepParam)\n\n    if (!currentStep) throw new Error('Could not load onboarding')\n\n    async function prev() {\n        if (currentStepIdx > 0) {\n            router.push({ pathname: '/onboarding', query: { step: steps[currentStepIdx - 1].key } })\n        }\n    }\n\n    async function next() {\n        await updateOnboarding.mutateAsync({\n            flow: 'main',\n            updates: [{ key: currentStep!.key, markedComplete: true }],\n        })\n\n        if (currentStepIdx < steps.length - 1) {\n            router.push({ pathname: '/onboarding', query: { step: steps[currentStepIdx + 1].key } })\n        } else {\n            router.push('/')\n        }\n    }\n\n    return (\n        <>\n            <div className=\"fixed inset-0 z-1 overflow-hidden\">\n                <OnboardingBackground className=\"absolute -bottom-3 left-1/2 -translate-x-1/2\" />\n            </div>\n\n            {currentStep.group && currentStep.group !== 'account' && (\n                <OnboardingNavbar steps={steps} currentStep={currentStep} onBack={prev} />\n            )}\n\n            <Transition\n                key={currentStep.key}\n                className={classNames('px-6 mb-20 grow z-10')}\n                appear\n                show\n                enter=\"ease-in duration-100\"\n                enterFrom=\"opacity-0 translate-y-8\"\n                enterTo=\"opacity-100 translate-y-0\"\n                leave=\"ease-in duration-100\"\n                leaveFrom=\"opacity-100 translate-y-0\"\n                leaveTo=\"opacity-0 translate-y-8\"\n            >\n                <StepComponent title={currentStep.title} onNext={next} onPrev={prev} />\n            </Transition>\n        </>\n    )\n}\n\nOnboardingPage.getLayout = function getLayout(page: ReactElement) {\n    return <FullPageLayout>{page}</FullPageLayout>\n}\n"
  },
  {
    "path": "apps/client/pages/plans/[planId].tsx",
    "content": "import type { ReactElement } from 'react'\nimport {\n    WithSidebarLayout,\n    AccountSidebar,\n    RetirementPlanChart,\n    PlanEventList,\n    PlanRangeSelector,\n    PlanContext,\n    PlanMenu,\n    PlanMilestones,\n    AddPlanScenario,\n    RetirementMilestoneForm,\n} from '@maybe-finance/client/features'\nimport { useRouter } from 'next/router'\nimport { useState, useMemo } from 'react'\nimport {\n    BlurredContentOverlay,\n    MainContentOverlay,\n    useAccountContext,\n    usePlanApi,\n    usePopoutContext,\n    useQueryParam,\n    useUserAccountContext,\n    useUserApi,\n} from '@maybe-finance/client/shared'\nimport { Breadcrumb, Button, DialogV2, LoadingSpinner } from '@maybe-finance/design-system'\nimport { RiAddLine, RiAlertLine, RiLineChartLine } from 'react-icons/ri'\nimport { DateTime } from 'luxon'\nimport classNames from 'classnames'\nimport { DateUtil, NumberUtil, PlanUtil } from '@maybe-finance/shared'\n\nexport default function PlanDetailPage() {\n    const planId = useQueryParam('planId', 'string')!\n    const router = useRouter()\n    const { isReady, noAccounts, allAccountsDisabled } = useUserAccountContext()\n    const { addAccount } = useAccountContext()\n    const { close: closePopout } = usePopoutContext()\n    const { usePlan, useUpdatePlan, usePlanProjections, useUpdatePlanTemplate } = usePlanApi()\n    const { useProfile } = useUserApi()\n\n    const plan = usePlan(+planId, { enabled: !!planId })\n    const projections = usePlanProjections(+planId, { enabled: !!planId })\n    const userProfile = useProfile()\n\n    const updatePlan = useUpdatePlan()\n    const updatePlanTemplate = useUpdatePlanTemplate()\n\n    const [addScenario, setAddScenario] = useState<{ isOpen: boolean; scenarioYear?: number }>({\n        isOpen: false,\n    })\n\n    const [editMilestoneId, setEditMilestoneId] = useState<number | undefined>()\n\n    const [selectedYearRange, setSelectedYearRange] = useState<{ from: number; to: number } | null>(\n        null\n    )\n\n    const [mode, setMode] = useState<'age' | 'year'>('age')\n\n    const retirement = useMemo(() => {\n        const retirementMilestone = plan.data?.milestones.find(\n            (m) => m.category === PlanUtil.PlanMilestoneCategory.Retirement\n        )\n        if (!retirementMilestone) return null\n\n        const projectionIdx = projections.data?.projection.data.findIndex(\n            (p) => p.values.year === retirementMilestone.year\n        )\n        if (!projectionIdx || projectionIdx < 0) return null\n\n        const upper = projections.data?.simulations.at(-1)?.simulation.data?.[projectionIdx]\n        const lower = projections.data?.simulations.at(0)?.simulation.data?.[projectionIdx]\n        const projection = projections.data?.projection.data?.[projectionIdx]\n\n        if (!upper || !lower || !projection) return null\n\n        return {\n            milestone: retirementMilestone,\n            projection,\n            upper,\n            lower,\n        }\n    }, [projections.data, plan.data])\n\n    // Determines the maximum number of stackable icons that are present on any single year\n    const maxIconsPerDatum = useMemo(() => {\n        const eventCounts = projections.data?.projection.data.map(\n            (datum) => datum.values.events.length + datum.values.milestones.length\n        )\n\n        return eventCounts ? Math.max(...eventCounts) : 4 // default makes room for 4 stacked icons\n    }, [projections.data?.projection])\n\n    const milestones = retirement?.milestone\n        ? [\n              {\n                  ...retirement.milestone,\n                  confidence: PlanUtil.CONFIDENCE_INTERVAL,\n                  upper: retirement.upper.values.netWorth,\n                  lower: retirement.lower.values.netWorth,\n              },\n          ]\n        : []\n\n    const failureDate = projections.data?.projection.data.find((p) =>\n        p.values.netWorth.isNegative()\n    )?.date\n\n    if (plan.isError || userProfile.isError) {\n        return (\n            <MainContentOverlay\n                title=\"Unable to load plan\"\n                actionText=\"Back home\"\n                onAction={() => {\n                    router.push('/')\n                }}\n            >\n                <p>\n                    We&rsquo;re having some trouble loading this plan. Please contact us if the\n                    issue persists.\n                </p>\n            </MainContentOverlay>\n        )\n    }\n\n    // Don't render anything until we have a dob to use\n    if (userProfile.isLoading || plan.isLoading) {\n        return (\n            <div className=\"absolute w-full h-full flex flex-col items-center justify-center\">\n                <LoadingSpinner />\n            </div>\n        )\n    }\n\n    const planData = plan.data\n    const user = userProfile.data\n\n    const userAge = DateUtil.dobToAge(user.dob) ?? PlanUtil.DEFAULT_AGE\n    const planStartYear = DateTime.now().year\n    const planEndYear = DateUtil.ageToYear(\n        plan.data?.lifeExpectancy ?? PlanUtil.DEFAULT_LIFE_EXPECTANCY,\n        userAge\n    )\n    const selectedStartYear = selectedYearRange?.from ?? planStartYear\n    const selectedEndYear = selectedYearRange?.to ?? planEndYear\n\n    const projectionEndData = projections.data?.projection.data.find(\n        (p) => p.values.year === selectedEndYear\n    )\n\n    const insufficientData = isReady && (noAccounts || allAccountsDisabled)\n\n    return (\n        <div className={classNames('relative', insufficientData && 'max-h-full')}>\n            <PlanContext.Provider\n                value={{\n                    userAge,\n                    planStartYear,\n                    planEndYear,\n                    milestones,\n                }}\n            >\n                <div className=\"flex items-center justify-between mb-5\">\n                    <Breadcrumb.Group>\n                        <Breadcrumb href=\"/plans\">Plans</Breadcrumb>\n                        <Breadcrumb>{planData.name}</Breadcrumb>\n                    </Breadcrumb.Group>\n                    <PlanMenu plan={planData} />\n                </div>\n\n                <h3 className=\"max-w-lg\">\n                    At this rate, you&lsquo;ll have\n                    {retirement?.projection.values.netWorth ? (\n                        ` ${NumberUtil.format(\n                            retirement?.projection.values.netWorth,\n                            'short-currency'\n                        )} by ${retirement.milestone.year}`\n                    ) : projectionEndData ? (\n                        ` ${NumberUtil.format(\n                            projectionEndData.values.netWorth,\n                            'short-currency'\n                        )} by ${DateTime.fromISO(projectionEndData.date).year}`\n                    ) : (\n                        <span className=\"animate-pulse\">...</span>\n                    )}\n                </h3>\n\n                {failureDate != null && (\n                    <div className=\"bg-red bg-opacity-10 rounded px-4 py-2 my-4 flex items-center gap-3 text-red\">\n                        <RiAlertLine size={18} className=\"shrink-0\" />\n                        <p className=\"text-base\">\n                            Your plan is failing. This usually means that your current expenses\n                            exceed your income. To fix this, please edit your income and expense\n                            events.\n                        </p>\n                    </div>\n                )}\n\n                {/* Range selector */}\n                <div className=\"mt-4\">\n                    <PlanRangeSelector\n                        fromYear={selectedStartYear}\n                        toYear={selectedEndYear}\n                        onChange={(range) => setSelectedYearRange(range)}\n                        mode={mode}\n                        onModeChange={setMode}\n                    />\n                </div>\n\n                {/* Chart area */}\n                <div className=\"mt-4 mb-7 h-[450px]\">\n                    <RetirementPlanChart\n                        isLoading={projections.isLoading || projections.isRefetching}\n                        isError={projections.isError}\n                        data={projections.data}\n                        dateRange={{\n                            start: DateTime.fromObject({ year: selectedStartYear }),\n                            end: DateTime.fromObject({ year: selectedEndYear }),\n                        }}\n                        retirement={retirement}\n                        onAddEvent={(date) => {\n                            setAddScenario({\n                                isOpen: true,\n                                scenarioYear: DateTime.fromISO(date).year,\n                            })\n                        }}\n                        maxStackCount={maxIconsPerDatum}\n                        failsEarly={failureDate != null}\n                        mode={mode}\n                    />\n                </div>\n\n                <div className=\"flex items-center justify-between\">\n                    <h5 className=\"uppercase\">Milestones</h5>\n                    <Button\n                        className=\"py-1 px-[8px]\" // override default size\n                        leftIcon={<RiAddLine className=\"w-5 h-5\" />}\n                        onClick={() => setAddScenario({ isOpen: true })}\n                    >\n                        New\n                    </Button>\n                </div>\n\n                {/* TODO: Once we add the ability to create multiple, arbitrary milestones, we will need to update this data array */}\n                <PlanMilestones\n                    isLoading={projections.isLoading || plan.isLoading}\n                    onAdd={() => setAddScenario({ isOpen: true })}\n                    onEdit={(id) => setEditMilestoneId(id)}\n                    onDelete={(id) => {\n                        updatePlan.mutate({\n                            id: planData.id,\n                            data: {\n                                milestones: {\n                                    delete: [id],\n                                },\n                            },\n                        })\n                    }}\n                    milestones={milestones}\n                    events={plan.data?.events ?? []}\n                />\n\n                {/* TODO - this will eventually need to be a switch statement to determine which form to open based on the milestone type  */}\n                {plan.data && (\n                    <DialogV2\n                        open={editMilestoneId !== undefined}\n                        title=\"Retirement\"\n                        onClose={() => setEditMilestoneId(undefined)}\n                    >\n                        <RetirementMilestoneForm\n                            mode=\"update\"\n                            defaultValues={{\n                                age: DateUtil.yearToAge(\n                                    planData.milestones.find(\n                                        (m) =>\n                                            m.category === PlanUtil.PlanMilestoneCategory.Retirement\n                                    )?.year ?? PlanUtil.RETIREMENT_MILESTONE_AGE,\n                                    userAge\n                                ),\n                            }}\n                            onSubmit={async (data) => {\n                                await updatePlan.mutateAsync({\n                                    id: planData.id,\n                                    data: {\n                                        milestones: {\n                                            update: [\n                                                {\n                                                    id: editMilestoneId,\n                                                    data: { type: 'year', year: data.year },\n                                                },\n                                            ],\n                                        },\n                                    },\n                                })\n\n                                setEditMilestoneId(undefined)\n                            }}\n                        />\n                    </DialogV2>\n                )}\n\n                {plan.data && (\n                    <AddPlanScenario\n                        plan={planData}\n                        isOpen={addScenario.isOpen}\n                        scenarioYear={\n                            addScenario.scenarioYear ??\n                            retirement?.projection.values.year ??\n                            DateUtil.ageToYear(\n                                PlanUtil.RETIREMENT_MILESTONE_AGE,\n                                userAge ?? PlanUtil.DEFAULT_AGE\n                            )\n                        }\n                        onClose={() => setAddScenario({ isOpen: false })}\n                        onSubmit={async (data) => {\n                            switch (data.scenario) {\n                                case 'retirement': {\n                                    const { year, monthlySpending } = data.data\n\n                                    await updatePlanTemplate.mutateAsync({\n                                        id: planData.id,\n                                        data: {\n                                            type: 'retirement',\n                                            data: {\n                                                retirementYear: year,\n                                                annualRetirementExpenses: monthlySpending * -12,\n                                            },\n                                        },\n                                    })\n\n                                    break\n                                }\n                                case 'income':\n                                case 'expense': {\n                                    await updatePlan.mutateAsync({\n                                        id: planData.id,\n                                        data: {\n                                            events: {\n                                                create: [data.data],\n                                            },\n                                        },\n                                    })\n\n                                    closePopout()\n\n                                    break\n                                }\n                                default: {\n                                    throw new Error('Scenario handler not implemented')\n                                }\n                            }\n\n                            setAddScenario({ isOpen: false })\n                        }}\n                    />\n                )}\n\n                {/* Income/Expense Events */}\n\n                <PlanEventList\n                    className=\"mt-16\"\n                    isLoading={projections.isLoading}\n                    events={planData.events}\n                    projection={projections.data?.projection}\n                    onCreate={(data) => {\n                        updatePlan.mutate({\n                            id: planData.id,\n                            data: {\n                                events: {\n                                    create: [data],\n                                },\n                            },\n                        })\n                    }}\n                    onUpdate={(id, data) => {\n                        updatePlan.mutate({\n                            id: planData.id,\n                            data: {\n                                events: {\n                                    update: [{ id, data }],\n                                },\n                            },\n                        })\n                    }}\n                    onDelete={(id) => {\n                        updatePlan.mutate({\n                            id: planData.id,\n                            data: {\n                                events: {\n                                    delete: [id],\n                                },\n                            },\n                        })\n                    }}\n                />\n\n                {insufficientData && (\n                    <BlurredContentOverlay icon={RiLineChartLine} title=\"Not enough data\">\n                        <p>\n                            You haven&rsquo;t added enough assets or debts yet to be able to\n                            generate a plan. Once you do, you&rsquo;ll be able to see your plan and\n                            test different parameters.\n                        </p>\n                        <Button\n                            className=\"w-full mt-6\"\n                            onClick={addAccount}\n                            data-testid=\"not-enough-data-add-account-button\"\n                        >\n                            Add account\n                        </Button>\n                    </BlurredContentOverlay>\n                )}\n            </PlanContext.Provider>\n        </div>\n    )\n}\n\nPlanDetailPage.getLayout = function getLayout(page: ReactElement) {\n    return <WithSidebarLayout sidebar={<AccountSidebar />}>{page}</WithSidebarLayout>\n}\n"
  },
  {
    "path": "apps/client/pages/plans/create.tsx",
    "content": "import type { ReactElement } from 'react'\nimport { useRouter } from 'next/router'\nimport { WithSidebarLayout, AccountSidebar, NewPlanForm } from '@maybe-finance/client/features'\nimport { usePlanApi } from '@maybe-finance/client/shared'\n\nexport default function CreatePlanPage() {\n    const { useCreatePlan } = usePlanApi()\n    const createPlan = useCreatePlan()\n\n    const router = useRouter()\n\n    return (\n        <div>\n            <h3 className=\"mb-6\">New plan</h3>\n            <div className=\"max-w-sm\">\n                <NewPlanForm\n                    initialValues={{\n                        name: 'Retirement',\n                        lifeExpectancy: 85,\n                    }}\n                    onSubmit={async (data) => {\n                        const plan = await createPlan.mutateAsync(data)\n                        router.push(`/plans/${plan.id}`)\n                    }}\n                />\n            </div>\n        </div>\n    )\n}\n\nCreatePlanPage.getLayout = function getLayout(page: ReactElement) {\n    return <WithSidebarLayout sidebar={<AccountSidebar />}>{page}</WithSidebarLayout>\n}\n"
  },
  {
    "path": "apps/client/pages/plans/index.tsx",
    "content": "import type { ReactElement } from 'react'\nimport { useEffect } from 'react'\n\nimport { WithSidebarLayout, AccountSidebar } from '@maybe-finance/client/features'\nimport { LoadingSpinner } from '@maybe-finance/design-system'\nimport { usePlanApi } from '@maybe-finance/client/shared'\nimport { useRouter } from 'next/router'\n\nexport default function PlansPage() {\n    const { usePlans } = usePlanApi()\n    const plans = usePlans()\n\n    const router = useRouter()\n\n    useEffect(() => {\n        if (plans.data) {\n            router.push(`/plans/${plans.data.plans[0].id}`)\n        }\n    }, [plans.data, router])\n\n    if (plans.isLoading) {\n        return (\n            <div className=\"absolute inset-0 flex items-center justify-center h-full\">\n                <LoadingSpinner />\n            </div>\n        )\n    }\n\n    /** @todo add back UI when users are able to manage plans */\n    return (\n        <></>\n        // <div className=\"h-full flex flex-col items-center justify-center\">\n        //     <h4 className=\"mb-2\">No plans yet</h4>\n        //     <div className=\"text-base text-gray-100 max-w-sm text-center mb-4\">\n        //         There are no plans yet. Start by creating a new one or start with sample data to get\n        //         a preview.\n        //     </div>\n        //     <div className=\"flex space-x-3\">\n        //         <Link href=\"/plans/sample\">\n        //             <Button as=\"a\" variant=\"secondary\">\n        //                 Use sample data\n        //             </Button>\n        //         </Link>\n        //         <Link href=\"/plans/create\">\n        //             <Button as=\"a\" leftIcon={<RiAddLine className=\"w-5 h-5\" />}>\n        //                 Create new plan\n        //             </Button>\n        //         </Link>\n        //     </div>\n        // </div>\n    )\n}\n\nPlansPage.getLayout = function getLayout(page: ReactElement) {\n    return <WithSidebarLayout sidebar={<AccountSidebar />}>{page}</WithSidebarLayout>\n}\n"
  },
  {
    "path": "apps/client/pages/register.tsx",
    "content": "import { useState, type ReactElement } from 'react'\nimport { Input, InputPassword, Button } from '@maybe-finance/design-system'\nimport {\n    FullPageLayout,\n    UserDevTools,\n    completedOnboarding,\n    onboardedProfile,\n} from '@maybe-finance/client/features'\nimport { signIn, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/router'\nimport { useEffect } from 'react'\nimport Script from 'next/script'\nimport Link from 'next/link'\nimport { useUserApi } from '@maybe-finance/client/shared'\n\nexport default function RegisterPage() {\n    const [firstName, setFirstName] = useState('')\n    const [lastName, setLastName] = useState('')\n    const [email, setEmail] = useState('')\n    const [password, setPassword] = useState('')\n    const [isValid, setIsValid] = useState(false)\n    const [errorMessage, setErrorMessage] = useState<string | null>(null)\n    const [isLoading, setIsLoading] = useState(false)\n    const [isAdmin, setIsAdmin] = useState<boolean>(false)\n    const [isOnboarded, setIsOnboarded] = useState<boolean>(false)\n\n    const { useUpdateOnboarding, useUpdateProfile } = useUserApi()\n    const { data: session } = useSession()\n    const router = useRouter()\n\n    const updateOnboarding = useUpdateOnboarding()\n    const updateProfile = useUpdateProfile()\n\n    useEffect(() => {\n        if (session) router.push('/')\n    }, [session, router])\n\n    const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n        e.preventDefault()\n\n        setErrorMessage(null)\n        setFirstName('')\n        setLastName('')\n        setEmail('')\n        setPassword('')\n        setIsLoading(true)\n\n        const response = await signIn('credentials', {\n            email,\n            password,\n            firstName,\n            lastName,\n            role: isAdmin ? 'admin' : 'user',\n            redirect: false,\n        })\n\n        if (isOnboarded && response?.ok) {\n            await updateProfile.mutateAsync(onboardedProfile)\n            await updateOnboarding.mutateAsync(completedOnboarding)\n        }\n\n        if (response && response.error) {\n            setErrorMessage(response.error)\n            setIsLoading(false)\n        }\n    }\n\n    return (\n        <>\n            <Script\n                src=\"https://cdnjs.cloudflare.com/ajax/libs/zxcvbn/4.4.2/zxcvbn.js\"\n                strategy=\"lazyOnload\"\n            />\n            <div className=\"absolute inset-0 flex flex-col items-center justify-center\">\n                <div className=\"p-px w-96 bg-white bg-opacity-10 card-light rounded-3xl radial-gradient-background\">\n                    <div className=\"bg-black bg-opacity-75 p-8 rounded-3xl w-full h-full items-center flex flex-col radial-gradient-background-dark\">\n                        <img\n                            className=\"mb-8\"\n                            src=\"/assets/maybe-box.svg\"\n                            alt=\"Maybe Finance Logo\"\n                            height={120}\n                            width={120}\n                        />\n                        <form className=\"space-y-4 w-full px-4\" onSubmit={onSubmit}>\n                            <Input\n                                type=\"text\"\n                                name=\"firstName\"\n                                label=\"First name\"\n                                value={firstName}\n                                onChange={(e) => setFirstName(e.currentTarget.value)}\n                            />\n                            <Input\n                                type=\"text\"\n                                name=\"lastName\"\n                                label=\"Last name\"\n                                value={lastName}\n                                onChange={(e) => setLastName(e.currentTarget.value)}\n                            />\n                            <Input\n                                type=\"text\"\n                                name=\"email\"\n                                label=\"Email\"\n                                value={email}\n                                onChange={(e) => setEmail(e.currentTarget.value)}\n                            />\n\n                            <InputPassword\n                                autoComplete=\"password\"\n                                name=\"password\"\n                                label=\"Password\"\n                                value={password}\n                                showPasswordRequirements={!isValid}\n                                onValidityChange={(checks) => {\n                                    const passwordValid =\n                                        checks.filter((c) => !c.isValid).length === 0\n                                    setIsValid(passwordValid)\n                                }}\n                                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n                                    setPassword(e.target.value)\n                                }\n                            />\n\n                            {errorMessage && password.length === 0 ? (\n                                <div className=\"py-1 text-center text-red text-sm\">\n                                    {errorMessage}\n                                </div>\n                            ) : null}\n\n                            <UserDevTools\n                                isAdmin={isAdmin}\n                                setIsAdmin={setIsAdmin}\n                                isOnboarded={isOnboarded}\n                                setIsOnboarded={setIsOnboarded}\n                            />\n\n                            <Button\n                                type=\"submit\"\n                                fullWidth\n                                disabled={!isValid}\n                                variant={isValid ? 'primary' : 'secondary'}\n                                isLoading={isLoading}\n                            >\n                                Register\n                            </Button>\n                            <div className=\"text-sm text-gray-50 text-center\">\n                                <div>\n                                    Already have an account?{' '}\n                                    <Link\n                                        className=\"hover:text-cyan-400 underline font-medium\"\n                                        href=\"/login\"\n                                    >\n                                        Sign in\n                                    </Link>\n                                </div>\n                            </div>\n                        </form>\n                    </div>\n                </div>\n            </div>\n        </>\n    )\n}\n\nRegisterPage.getLayout = function getLayout(page: ReactElement) {\n    return <FullPageLayout>{page}</FullPageLayout>\n}\n\nRegisterPage.isPublic = true\n"
  },
  {
    "path": "apps/client/pages/settings.tsx",
    "content": "import type { ReactElement } from 'react'\nimport { useQueryParam } from '@maybe-finance/client/shared'\nimport {\n    AccountSidebar,\n    BillingPreferences,\n    SecurityPreferences,\n    UserDetails,\n    WithSidebarLayout,\n} from '@maybe-finance/client/features'\nimport { Tab } from '@maybe-finance/design-system'\nimport { useRouter } from 'next/router'\nimport Script from 'next/script'\n\nexport default function SettingsPage() {\n    const router = useRouter()\n\n    const tabs = ['details', 'notifications', 'security', 'documents', 'billing']\n\n    const currentTab = useQueryParam('tab', 'string')\n\n    return (\n        <>\n            <Script\n                src=\"https://cdnjs.cloudflare.com/ajax/libs/zxcvbn/4.4.2/zxcvbn.js\"\n                strategy=\"lazyOnload\"\n            />\n            <section className=\"space-y-4\">\n                <h3>Settings</h3>\n                <Tab.Group\n                    onChange={(idx) => {\n                        router.replace({ query: { tab: tabs[idx] } })\n                    }}\n                    selectedIndex={tabs.findIndex((tab) => tab === currentTab)}\n                >\n                    <Tab.List>\n                        <Tab>Details</Tab>\n                        <Tab>Security</Tab>\n                        {process.env.STRIPE_API_KEY && <Tab>Billing</Tab>}\n                    </Tab.List>\n                    <Tab.Panels>\n                        <Tab.Panel>\n                            <UserDetails />\n                        </Tab.Panel>\n                        <Tab.Panel>\n                            <div className=\"mt-6 max-w-lg\">\n                                <SecurityPreferences />\n                            </div>\n                        </Tab.Panel>\n                        {process.env.STRIPE_API_KEY && (\n                            <Tab.Panel>\n                                <BillingPreferences />\n                            </Tab.Panel>\n                        )}\n                    </Tab.Panels>\n                </Tab.Group>\n            </section>\n        </>\n    )\n}\n\nSettingsPage.getLayout = function getLayout(page: ReactElement) {\n    return <WithSidebarLayout sidebar={<AccountSidebar />}>{page}</WithSidebarLayout>\n}\n"
  },
  {
    "path": "apps/client/pages/upgrade.tsx",
    "content": "import { UpgradeTakeover } from '@maybe-finance/client/features'\nimport { useUserApi } from '@maybe-finance/client/shared'\nimport { useRouter } from 'next/router'\n\nexport default function UpgradePage() {\n    const router = useRouter()\n\n    const { useSubscription } = useUserApi()\n    const subscription = useSubscription()\n\n    return (\n        <UpgradeTakeover\n            open\n            onClose={() =>\n                router.push(\n                    !subscription.data || subscription.data?.subscribed\n                        ? '/'\n                        : '/settings?tab=billing'\n                )\n            }\n        />\n    )\n}\n"
  },
  {
    "path": "apps/client/postcss.config.js",
    "content": "module.exports = {\n    plugins: {\n        tailwindcss: { config: './apps/client/tailwind.config.js' },\n        autoprefixer: {},\n    },\n}\n"
  },
  {
    "path": "apps/client/public/.gitkeep",
    "content": ""
  },
  {
    "path": "apps/client/public/__appenv.js",
    "content": "window.__appenv = {}"
  },
  {
    "path": "apps/client/public/assets/browserconfig.xml",
    "content": "<!-- This is just another favicon config for Windows OS, generated by https://realfavicongenerator.net/ -->\n<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig>\n    <msapplication>\n        <tile>\n            <square150x150logo src=\"/mstile-150x150.png\"/>\n            <TileColor>#da532c</TileColor>\n        </tile>\n    </msapplication>\n</browserconfig>\n"
  },
  {
    "path": "apps/client/public/assets/site.webmanifest",
    "content": "{\n    \"name\": \"Maybe\",\n    \"short_name\": \"Maybe\",\n    \"description\": \"Modern day financial planning & wealth management\",\n    \"icons\": [\n        {\n            \"src\": \"/android-chrome-192x192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"/android-chrome-512x512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\"\n        }\n    ],\n    \"theme_color\": \"#242629\",\n    \"background_color\": \"#242629\",\n    \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "apps/client/stories/404.stories.tsx",
    "content": "import React from 'react'\nimport type { Story, Meta } from '@storybook/react'\nimport Page404 from '../pages/404'\n\nexport default {\n    title: 'pages/Page404',\n    component: Page404,\n} as Meta\n\nexport const Default: Story = () => <Page404 />\n"
  },
  {
    "path": "apps/client/styles.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/* https://tailwindcss.com/docs/adding-base-styles#using-css */\n@layer base {\n    @font-face {\n        font-family: 'Monument Extended';\n        font-weight: 100;\n        src: url('/assets/fonts/monument/MonumentExtended-Thin.woff2') format('woff2');\n    }\n\n    @font-face {\n        font-family: 'Monument Extended';\n        font-weight: 200;\n        src: url('/assets/fonts/monument/MonumentExtended-Light.woff2') format('woff2');\n    }\n\n    @font-face {\n        font-family: 'Monument Extended';\n        font-weight: 300;\n        src: url('/assets/fonts/monument/MonumentExtended-Book.woff2') format('woff2');\n    }\n\n    @font-face {\n        font-family: 'Monument Extended';\n        font-weight: 400;\n        src: url('/assets/fonts/monument/MonumentExtended-Regular.woff2') format('woff2');\n    }\n\n    @font-face {\n        font-family: 'Monument Extended';\n        font-weight: 500;\n        src: url('/assets/fonts/monument/MonumentExtended-Medium.woff2') format('woff2');\n    }\n\n    @font-face {\n        font-family: 'Monument Extended';\n        font-weight: 700;\n        src: url('/assets/fonts/monument/MonumentExtended-Bold.woff2') format('woff2');\n    }\n\n    @font-face {\n        font-family: 'Monument Extended';\n        font-weight: 800;\n        src: url('/assets/fonts/monument/MonumentExtended-Black.woff2') format('woff2');\n    }\n\n    @font-face {\n        font-family: 'Monument Extended';\n        font-weight: 900;\n        src: url('/assets/fonts/monument/MonumentExtended-Heavy.woff2') format('woff2');\n    }\n\n    @font-face {\n        font-family: 'Inter';\n        src: url('/assets/fonts/inter/Inter-Variable.woff2') format('woff2-variations');\n        font-weight: 100 900;\n    }\n\n    body {\n        @apply text-white bg-black;\n        overflow: hidden;\n    }\n\n    input {\n        outline: 0;\n    }\n\n    /* This prevents inputs from having a white background when user autofills them\n       TODO: Move this into design system\n       https://stackoverflow.com/a/41148494 */\n    input:-webkit-autofill,\n    input:-webkit-autofill:hover,\n    input:-webkit-autofill:focus {\n        border: none !important;\n        caret-color: white !important;\n        -webkit-text-fill-color: white !important;\n        -webkit-box-shadow: 0 0 0px 1000px transparent inset !important;\n        transition: background-color 5000s ease-in-out 0s;\n    }\n\n    h1 {\n        @apply text-5xl font-bold font-display;\n    }\n\n    h2 {\n        @apply text-4xl font-bold font-display;\n    }\n\n    h3 {\n        @apply text-3xl font-bold font-display;\n    }\n\n    h4 {\n        @apply text-2xl font-bold font-display;\n    }\n\n    h5 {\n        @apply text-lg font-bold font-display;\n    }\n\n    h6 {\n        @apply text-base font-bold font-display;\n    }\n\n    .scrollbar-none {\n        overflow: -moz-scrollbars-none;\n    }\n\n    .scrollbar-none::-webkit-scrollbar {\n        width: 0 !important;\n    }\n\n    .custom-gray-scroll::-webkit-scrollbar {\n        height: 14px;\n        width: 14px;\n    }\n\n    .custom-gray-scroll::-webkit-scrollbar-thumb {\n        border: 4px solid rgba(0, 0, 0, 0);\n        background-clip: content-box;\n        @apply bg-gray-200 rounded-full;\n    }\n\n    .custom-gray-scroll {\n        overflow: overlay !important;\n    }\n\n    @-moz-document url-prefix() {\n        .custom-gray-scroll {\n            overflow: auto !important;\n        }\n    }\n\n    .show-on-hover-custom-gray-scroll::-webkit-scrollbar {\n        height: 14px;\n        width: 14px;\n    }\n\n    .show-on-hover-custom-gray-scroll::-webkit-scrollbar-thumb {\n        border: 4px solid rgba(0, 0, 0, 0);\n        background-clip: content-box;\n        @apply bg-transparent rounded-full;\n    }\n\n    .show-on-hover-custom-gray-scroll {\n        overflow: overlay !important;\n    }\n\n    .show-on-hover-custom-gray-scroll:hover::-webkit-scrollbar-thumb {\n        @apply bg-gray-200;\n    }\n\n    @-moz-document url-prefix() {\n        .show-on-hover-custom-gray-scroll {\n            overflow: auto !important;\n        }\n    }\n}\n\n/* tiptap / prosemirror customization */\n.ProseMirror:focus {\n    outline: none;\n}\n\n.ProseMirror p.placeholder:first-child::before {\n    content: attr(data-placeholder);\n    float: left;\n    height: 0;\n    pointer-events: none;\n}\n\n.radial-gradient-background {\n    background-image: radial-gradient(\n        60% 200% at 50% 50%,\n        rgba(67, 97, 238, 0.5) 0%,\n        transparent 100%\n    );\n}\n\n.radial-gradient-background-dark {\n    background-image: radial-gradient(\n        100% 100% at clamp(20%, calc(30% + var(--mx) * 0.05), 40%)\n            clamp(50%, calc(50% + var(--my) * 0.05), 60%),\n        #4361ee33 0%,\n        #16161af4 120%\n    );\n}\n"
  },
  {
    "path": "apps/client/tailwind.config.js",
    "content": "const { join } = require('path')\nconst merge = require('lodash/fp/merge')\n\n// https://blog.nrwl.io/setup-next-js-to-use-tailwind-with-nx-849b7e21d8d0\nconst { createGlobPatternsForDependencies } = require('@nrwl/next/tailwind')\n\n// TODO: Figure out how to simplify this import with scope\nconst designSystemConfig = require(__dirname + '/../../libs/design-system/tailwind.config.js')\n\nmodule.exports = merge(designSystemConfig, {\n    content: [\n        join(__dirname, 'pages/**/*.{js,ts,jsx,tsx}'),\n        join(__dirname, 'components/**/*.{js,ts,jsx,tsx}'),\n        ...createGlobPatternsForDependencies(__dirname, '/**/!(*.stories|*.spec).{tsx,ts,jsx,js}'),\n    ],\n    theme: {\n        extend: {\n            transitionProperty: {\n                width: 'width',\n            },\n            keyframes: {\n                wave: {\n                    '0%, 100%': { transform: 'rotate(0deg)' },\n                    '50%': { transform: 'rotate(20deg)' },\n                },\n                float: {\n                    from: { transform: 'rotate(0deg) translateX(2px) rotate(0deg)' },\n                    to: { transform: 'rotate(360deg) translateX(2px) rotate(-360deg)' },\n                },\n            },\n            animation: {\n                wave: '3 wave 0.6s ease-in-out',\n                float: 'float 4s infinite linear',\n            },\n            typography: () => {\n                const { white, gray, cyan } = designSystemConfig.theme.colors\n                return {\n                    light: {\n                        css: {\n                            '--tw-prose-body': white,\n                            '--tw-prose-headings': white,\n                            '--tw-prose-lead': white,\n                            '--tw-prose-links': cyan['600'],\n                            '--tw-prose-bold': white,\n                            '--tw-prose-counters': white,\n                            '--tw-prose-bullets': gray['50'],\n                            '--tw-prose-hr': gray['500'],\n                            '--tw-prose-quotes': gray['50'],\n                            '--tw-prose-quote-borders': gray['50'],\n                            '--tw-prose-captions': white,\n                            '--tw-prose-code': gray['200'],\n                            '--tw-prose-pre-code': gray['100'],\n                            '--tw-prose-pre-bg': gray['600'],\n                            '--tw-prose-th-borders': gray['50'],\n                            '--tw-prose-td-borders': gray['50'],\n                            '--tw-prose-invert-body': white,\n                            '--tw-prose-invert-headings': white,\n                            '--tw-prose-invert-lead': white,\n                            '--tw-prose-invert-links': cyan['600'],\n                            '--tw-prose-invert-bold': white,\n                            '--tw-prose-invert-counters': white,\n                            '--tw-prose-invert-bullets': gray['50'],\n                            '--tw-prose-invert-hr': gray['500'],\n                            '--tw-prose-invert-quotes': gray['50'],\n                            '--tw-prose-invert-quote-borders': gray['50'],\n                            '--tw-prose-invert-captions': white,\n                            '--tw-prose-invert-code': gray['200'],\n                            '--tw-prose-invert-pre-code': gray['100'],\n                            '--tw-prose-invert-pre-bg': gray['600'],\n                            '--tw-prose-invert-th-borders': gray['50'],\n                            '--tw-prose-invert-td-borders': gray['50'],\n                        },\n                    },\n                }\n            },\n        },\n    },\n    plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],\n})\n"
  },
  {
    "path": "apps/client/tsconfig.json",
    "content": "{\n    \"extends\": \"../../tsconfig.base.json\",\n    \"compilerOptions\": {\n        \"jsx\": \"preserve\",\n        \"allowJs\": true,\n        \"esModuleInterop\": true,\n        \"allowSyntheticDefaultImports\": true,\n        \"types\": [\"node\", \"jest\"],\n        \"strict\": false,\n        \"strictNullChecks\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"noEmit\": true,\n        \"resolveJsonModule\": true,\n        \"isolatedModules\": true,\n        \"incremental\": true,\n        \"plugins\": [\n            {\n                \"name\": \"next\"\n            }\n        ]\n    },\n    \"include\": [\n        \"**/*.ts\",\n        \"**/*.tsx\",\n        \"**/*.js\",\n        \"**/*.jsx\",\n        \"next-env.d.ts\",\n        \".next/types/**/*.ts\"\n    ],\n    \"exclude\": [\"node_modules\", \"jest.config.ts\"]\n}\n"
  },
  {
    "path": "apps/client/tsconfig.spec.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../dist/out-tsc\",\n        \"module\": \"commonjs\",\n        \"types\": [\"jest\", \"node\"],\n        \"jsx\": \"react\"\n    },\n    \"include\": [\n        \"**/*.spec.ts\",\n        \"**/*.test.ts\",\n        \"**/*.spec.tsx\",\n        \"**/*.test.tsx\",\n        \"**/*.spec.js\",\n        \"**/*.test.js\",\n        \"**/*.spec.jsx\",\n        \"**/*.test.jsx\",\n        \"**/*.d.ts\"\n    ]\n}\n"
  },
  {
    "path": "apps/e2e/.eslintrc.json",
    "content": "{\n    \"extends\": [\"plugin:cypress/recommended\", \"../../.eslintrc.json\"],\n    \"ignorePatterns\": [\"!**/*\"],\n    \"overrides\": [\n        {\n            \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"src/plugins/index.js\"],\n            \"rules\": {\n                \"@typescript-eslint/no-var-requires\": \"off\",\n                \"no-undef\": \"off\"\n            }\n        }\n    ]\n}\n"
  },
  {
    "path": "apps/e2e/cypress.config.ts",
    "content": "import { defineConfig } from 'cypress'\nimport { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset'\n\nexport default defineConfig({\n    e2e: {\n        ...nxE2EPreset(__dirname),\n        video: false,\n        screenshotsFolder: '../../dist/cypress/apps/e2e/screenshots',\n        viewportWidth: 1280,\n        viewportHeight: 720,\n        baseUrl: 'http://localhost:4200',\n        env: {\n            API_URL: 'http://localhost:3333/v1',\n            NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,\n            NEXT_PUBLIC_NEXTAUTH_URL: 'http://localhost:4200',\n            NEXTAUTH_URL: process.env.NEXTAUTH_URL,\n            STRIPE_WEBHOOK_SECRET: 'REPLACE_THIS',\n            STRIPE_CUSTOMER_ID: 'REPLACE_THIS',\n            STRIPE_SUBSCRIPTION_ID: 'REPLACE_THIS',\n        },\n        specPattern: 'src/e2e/**/*.cy.{js,jsx,ts,tsx}',\n        supportFile: 'src/support/e2e.ts',\n        fixturesFolder: './src/fixtures',\n        setupNodeEvents(on, config) {\n            on('task', {\n                log(message) {\n                    console.log(message)\n                    return null\n                },\n            })\n        },\n    },\n})\n"
  },
  {
    "path": "apps/e2e/src/e2e/accounts.cy.ts",
    "content": "import { DateTime } from 'luxon'\n\nconst assertSidebarAccounts = (expectedRows: [string, string][]) => {\n    cy.getByTestId('account-accordion-row', { timeout: 60000 }).each((row, index) => {\n        cy.wrap(row)\n            .find('[data-testid=\"account-accordion-row-name\"]', { timeout: 60000 })\n            .should('contain.text', expectedRows[index][0])\n        cy.wrap(row)\n            .find('[data-testid=\"account-accordion-row-balance\"]', { timeout: 60000 })\n            .should('contain.text', expectedRows[index][1])\n    })\n}\n\nfunction openEditAccountModal() {\n    cy.getByTestId('account-menu').within(() => {\n        cy.getByTestId('account-menu-btn').click()\n        cy.contains('Edit').click()\n    })\n}\n\ndescribe('Accounts', () => {\n    it('should interpolate and display manual vehicle account data', () => {\n        cy.getByTestId('add-account-button').click()\n        cy.contains('h4', 'Add account')\n        cy.getByTestId('vehicle-form-add-account').click()\n        cy.contains('h4', 'Add vehicle')\n\n        // Details\n        cy.get('input[name=\"make\"]').type('Tesla')\n        cy.get('input[name=\"model\"]').type('Model 3')\n        cy.get('input[name=\"year\"]').type('2022')\n\n        // Purchase date\n        cy.get('input[name=\"startDate\"]')\n            .clear()\n            .type(DateTime.now().minus({ months: 1 }).toFormat('MMddyyyy'))\n\n        // Valuation\n        cy.get('input[name=\"originalBalance\"]').type('60000')\n        cy.get('input[name=\"currentBalance\"]').type('50000')\n\n        // Add account\n        cy.getByTestId('vehicle-form-submit').click()\n\n        // Check account sidebar names and balances\n        assertSidebarAccounts([\n            ['Assets', '$50,000'],\n            ['Vehicles', '$50,000'],\n            ['Tesla Model 3', '$50,000'],\n        ])\n\n        cy.visit('/')\n\n        cy.getByTestId('current-data-value', { timeout: 30000 }).should('contain.text', '$50,000')\n\n        // Visit individual account page\n        cy.contains('a', 'Tesla Model 3').click()\n        cy.getByTestId('current-data-value').should('contain.text', '$50,000.00')\n\n        openEditAccountModal()\n\n        cy.getByTestId('vehicle-form').within(() => {\n            cy.get('input[name=\"make\"]').should('have.value', 'Tesla').clear().type('Honda')\n            cy.get('input[name=\"model\"]').should('have.value', 'Model 3').clear().type('Civic')\n            cy.get('input[name=\"year\"]').should('have.value', '2022').clear().type('2020')\n            cy.root().submit()\n        })\n    })\n\n    it('should interpolate and display manual property account data', () => {\n        cy.getByTestId('add-account-button').click()\n        cy.contains('h4', 'Add account')\n        cy.getByTestId('property-form-add-account').click()\n        cy.contains('h4', 'Add real estate')\n\n        // Details\n        cy.contains('label', 'Country').click()\n        cy.contains('button', 'Uganda').click()\n        cy.get('input[name=\"line1\"]').focus().type('123 Example St')\n        cy.get('input[name=\"city\"]').type('New York')\n        cy.get('input[name=\"state\"]').type('NY')\n        cy.get('input[name=\"zip\"]').type('12345')\n\n        // Purchase date\n        cy.get('input[name=\"startDate\"]')\n            .clear()\n            .type(DateTime.now().minus({ months: 1 }).toFormat('MMddyyyy'))\n\n        // Valuation\n        cy.get('input[name=\"originalBalance\"]').type('900000')\n        cy.get('input[name=\"currentBalance\"]').type('1000000')\n\n        // Add account\n        cy.getByTestId('property-form-submit').click()\n\n        // Check account sidebar names and balances\n        assertSidebarAccounts([\n            ['Assets', '$1,000,000'],\n            ['Real Estate', '$1,000,000'],\n            ['123 Example St', '$1,000,000'],\n        ])\n\n        cy.visit('/')\n\n        cy.getByTestId('current-data-value', { timeout: 30000 }).should(\n            'contain.text',\n            '$1,000,000'\n        )\n\n        // Visit individual account page\n        cy.contains('a', '123 Example St').click()\n        cy.getByTestId('current-data-value').should('contain.text', '$1,000,000.00')\n\n        openEditAccountModal()\n\n        cy.getByTestId('property-form').within(() => {\n            cy.get('input[name=\"country\"]').should('have.value', 'UG')\n            cy.contains('label', 'Country').click()\n            cy.contains('button', 'United States').click()\n            cy.get('input[name=\"line1\"]')\n                .should('have.value', '123 Example St')\n                .clear()\n                .type('456 Example St')\n            cy.get('input[name=\"city\"]')\n                .should('have.value', 'New York')\n                .clear()\n                .type('Los Angeles')\n            cy.get('input[name=\"state\"]').should('have.value', 'NY').clear().type('CA')\n            cy.get('input[name=\"zip\"]').should('have.value', '12345').clear().type('56789')\n            cy.root().submit()\n        })\n    })\n\n    it('should interpolate and display manual \"other asset\" account data', () => {\n        cy.getByTestId('add-account-button').click()\n        cy.contains('h4', 'Add account')\n        cy.getByTestId('manual-add-account').click()\n        cy.contains('h4', 'Add account')\n        cy.getByTestId('manual-add-asset').click()\n        cy.contains('h4', 'Manual asset')\n\n        // Details\n        cy.get('input[name=\"name\"]').type('Some Asset')\n\n        // Purchase date\n        cy.get('input[name=\"startDate\"]')\n            .clear()\n            .type(DateTime.now().minus({ months: 1 }).toFormat('MMddyyyy'))\n\n        // Valuation\n        cy.get('input[name=\"originalBalance\"]').type('5000')\n        cy.get('input[name=\"currentBalance\"]').type('10000')\n\n        // Add account\n        cy.getByTestId('asset-form-submit').click()\n\n        // Check account sidebar names and balances\n        assertSidebarAccounts([\n            ['Assets', '$10,000'],\n            ['Cash', '$10,000'],\n            ['Some Asset', '$10,000'],\n        ])\n\n        cy.visit('/')\n\n        cy.getByTestId('current-data-value', { timeout: 30000 }).should('contain.text', '$10,000')\n\n        // Visit individual account page\n        cy.contains('a', 'Some Asset').click()\n        cy.getByTestId('current-data-value').should('contain.text', '$10,000.00')\n\n        openEditAccountModal()\n\n        cy.getByTestId('asset-form').within(() => {\n            cy.get('input[name=\"name\"]')\n                .should('have.value', 'Some Asset')\n                .clear()\n                .type('Updated Asset')\n            cy.get('input[name=\"categoryUser\"]').should('have.value', 'cash')\n            cy.root().submit()\n        })\n    })\n\n    it('should interpolate and display manual \"other liability\" account data', () => {\n        cy.getByTestId('add-account-button').click()\n        cy.contains('h4', 'Add account')\n        cy.getByTestId('manual-add-account').click()\n        cy.contains('h4', 'Add account')\n        cy.getByTestId('manual-add-debt').click()\n        cy.contains('h4', 'Manual debt')\n\n        // Details\n        cy.get('input[name=\"name\"]').type('Some Liability')\n\n        // Purchase date\n        cy.get('input[name=\"startDate\"]')\n            .clear()\n            .type(DateTime.now().minus({ months: 1 }).toFormat('MMddyyyy'))\n\n        // Valuation\n        cy.get('input[name=\"originalBalance\"]').type('5000')\n        cy.get('input[name=\"currentBalance\"]').type('10000')\n\n        // Add account\n        cy.getByTestId('liability-form-submit').click()\n\n        // Check account sidebar names and balances\n        assertSidebarAccounts([\n            ['Debts', '$10,000'],\n            ['Other', '$10,000'],\n            ['Some Liability', '$10,000'],\n        ])\n\n        cy.visit('/')\n\n        cy.getByTestId('current-data-value', { timeout: 30000 }).should('contain.text', '-$10,000')\n\n        // Visit individual account page\n        cy.contains('a', 'Some Liability').click()\n        cy.getByTestId('current-data-value').should('contain.text', '$10,000.00')\n\n        openEditAccountModal()\n\n        cy.getByTestId('liability-form').within(() => {\n            cy.get('input[name=\"name\"]')\n                .should('have.value', 'Some Liability')\n                .clear()\n                .type('Updated Liability')\n            cy.get('input[name=\"categoryUser\"]').should('have.value', 'other')\n            cy.root().submit()\n        })\n    })\n\n    it('should interpolate and display manual \"loan\" account data', () => {\n        cy.getByTestId('add-account-button').click()\n        cy.contains('h4', 'Add account')\n        cy.getByTestId('manual-add-account').click()\n        cy.contains('h4', 'Add account')\n        cy.getByTestId('manual-add-debt').click()\n        cy.contains('h4', 'Manual debt')\n        cy.contains('label', 'Category').click()\n        cy.contains('button', 'Loans').click()\n\n        // Details\n        cy.get('input[name=\"name\"]').type('Manual Loan')\n\n        // Purchase date\n        cy.get('input[name=\"startDate\"]')\n            .clear()\n            .type(DateTime.now().minus({ years: 1 }).toFormat('MMddyyyy'))\n\n        // Valuation\n        cy.get('input[name=\"originalBalance\"]').type('10000')\n        cy.get('input[name=\"currentBalance\"]').type('9000')\n\n        // Loan terms\n        cy.get('input[name=\"maturityDate\"]').type('360')\n        cy.get('input[name=\"interestRate\"]').clear().type('6')\n\n        // Add account\n        cy.getByTestId('liability-form-submit').click()\n\n        // Check account sidebar names and balances\n        assertSidebarAccounts([\n            ['Debts', '$9,000'],\n            ['Loans', '$9,000'],\n            ['Manual Loan', '$9,000'],\n        ])\n\n        cy.visit('/')\n\n        cy.getByTestId('current-data-value', { timeout: 30000 }).should('contain.text', '-$9,000')\n\n        // Visit individual account page\n        cy.contains('a', 'Manual Loan').click()\n        cy.getByTestId('current-data-value').should('contain.text', '$9,000.00')\n\n        openEditAccountModal()\n\n        cy.getByTestId('liability-form').within(() => {\n            cy.get('input[name=\"name\"]')\n                .should('have.value', 'Manual Loan')\n                .clear()\n                .type('Updated Loan')\n            cy.get('input[name=\"categoryUser\"]').should('have.value', 'loan')\n            cy.get('input[name=\"loanType\"]').should('have.value', 'mortgage')\n            cy.get('input[name=\"interestType\"]').should('have.value', 'fixed')\n            cy.root().submit()\n        })\n\n        cy.getByTestId('loan-detail-cards').within(() => {\n            cy.get('h3').should(($h3) => {\n                expect($h3).to.have.length(3)\n                expect($h3[0]).to.have.text('$10,000.00')\n                expect($h3[1]).to.have.text('$9,000.00')\n                expect($h3[2]).to.have.text('30 years')\n            })\n        })\n    })\n})\n"
  },
  {
    "path": "apps/e2e/src/e2e/auth.cy.ts",
    "content": "describe('Auth', () => {\n    beforeEach(() => cy.visit('/'))\n\n    describe('Logging in', () => {\n        it('should show the home page of an authenticated user', () => {\n            cy.contains('h5', 'Assets & Debts')\n        })\n    })\n})\n"
  },
  {
    "path": "apps/e2e/src/e2e/subscription.cy.ts",
    "content": "import { checkoutSessionCompleted, customerSubscriptionCreated } from '../fixtures/stripe'\nimport Stripe from 'stripe'\nimport { customerSubscriptionDeleted } from '../fixtures/stripe/customerSubscriptionDeleted'\n\nconst stripe = new Stripe('sk_test_12345', { apiVersion: '2022-08-01' })\n\nfunction getAuth0Id(): string {\n    const keys = Object.keys(localStorage)\n    const auth0Key = keys.find((key) => key.startsWith('@@auth0spajs@@'))\n    const data = JSON.parse(localStorage.getItem(auth0Key))\n    return data.body.decodedToken.user.sub\n}\n\nfunction sendWebhook(payload: Record<string, any>) {\n    const payloadString = JSON.stringify(payload)\n    const signature = stripe.webhooks.generateTestHeaderString({\n        payload: payloadString,\n        secret: Cypress.env('STRIPE_WEBHOOK_SECRET'),\n    })\n\n    return cy\n        .apiRequest({\n            method: 'POST',\n            url: '/stripe/webhook',\n            headers: {\n                ['stripe-signature']: signature,\n            },\n            body: payload,\n        })\n        .its('status')\n        .should('equal', 200)\n}\n\ndescribe('Subscriptions', () => {\n    it.skip('should recognize a trialing user', () => {\n        cy.visit('/')\n\n        cy.visit('/settings?tab=billing')\n\n        // Trial is recognized\n        cy.contains('14 days left in your free trial', { timeout: 10000 })\n\n        // Subscriber features are accessible\n        cy.visit('/')\n        cy.contains('h4', 'No accounts yet')\n    })\n\n    it.skip('should recognize a lapsed trial', () => {\n        // Reset user to lapsed trial\n        cy.apiRequest({\n            method: 'POST',\n            url: 'e2e/reset',\n            body: {\n                trialLapsed: true,\n            },\n        }).then((response) => {\n            expect(response.status).to.equal(200)\n        })\n\n        cy.visit('/')\n\n        cy.contains('Choose annual or monthly billing to start', { timeout: 10000 })\n    })\n\n    it.skip('should recognize a canceled user', () => {\n        sendWebhook(checkoutSessionCompleted(getAuth0Id()))\n        sendWebhook(customerSubscriptionCreated())\n        sendWebhook(customerSubscriptionDeleted())\n\n        cy.visit('/')\n\n        cy.contains('Choose annual or monthly billing to start', { timeout: 10000 })\n    })\n})\n"
  },
  {
    "path": "apps/e2e/src/fixtures/stripe/checkoutSessionCompleted.ts",
    "content": "export function checkoutSessionCompleted(auth0Id: string) {\n    return {\n        id: 'evt_1M7QkDKXgym9ohnqSTEo8Ld7',\n        object: 'event',\n        api_version: '2020-08-27',\n        created: 1669239877,\n        data: {\n            object: {\n                id: 'cs_test_a1xaDeO7ZjBfzeHax678rFBTSzKOz6KXLzRT67eIuV8vSLUtfYBeuNK3wO',\n                object: 'checkout.session',\n                after_expiration: null,\n                allow_promotion_codes: null,\n                amount_subtotal: 0,\n                amount_total: 0,\n                automatic_tax: {\n                    enabled: false,\n                    status: null,\n                },\n                billing_address_collection: null,\n                cancel_url: 'http://localhost:4200/settings?tab=billing&status=cancelled',\n                client_reference_id: auth0Id,\n                consent: null,\n                consent_collection: null,\n                created: 1669239822,\n                currency: 'usd',\n                custom_text: {\n                    shipping_address: null,\n                    submit: null,\n                },\n                customer: Cypress.env('STRIPE_CUSTOMER_ID'),\n                customer_creation: null,\n                customer_details: {\n                    address: {\n                        city: null,\n                        country: 'US',\n                        line1: null,\n                        line2: null,\n                        postal_code: '15116',\n                        state: null,\n                    },\n                    email: 'josh@maybe.co',\n                    name: 'Josh Pigford',\n                    phone: null,\n                    tax_exempt: 'none',\n                    tax_ids: [],\n                },\n                customer_email: null,\n                expires_at: 1669326222,\n                livemode: false,\n                locale: null,\n                metadata: {},\n                mode: 'subscription',\n                payment_intent: null,\n                payment_link: null,\n                payment_method_collection: 'always',\n                payment_method_options: {},\n                payment_method_types: ['card'],\n                payment_status: 'paid',\n                phone_number_collection: {\n                    enabled: false,\n                },\n                recovered_from: null,\n                setup_intent: 'seti_1M7QkBKXgym9ohnqvu77sXgO',\n                shipping: null,\n                shipping_address_collection: null,\n                shipping_options: [],\n                shipping_rate: null,\n                status: 'complete',\n                submit_type: null,\n                subscription: Cypress.env('STRIPE_SUBSCRIPTION_ID'),\n                success_url: 'http://localhost:4200/settings?tab=billing&status=success',\n                total_details: {\n                    amount_discount: 0,\n                    amount_shipping: 0,\n                    amount_tax: 0,\n                },\n                url: null,\n            },\n        },\n        livemode: false,\n        pending_webhooks: 4,\n        request: {\n            id: null,\n            idempotency_key: null,\n        },\n        type: 'checkout.session.completed',\n    }\n}\n"
  },
  {
    "path": "apps/e2e/src/fixtures/stripe/customerSubscriptionCreated.ts",
    "content": "import { DateTime } from 'luxon'\n\nexport function customerSubscriptionCreated() {\n    const now = DateTime.now()\n\n    return {\n        id: 'evt_1M7QkFKXgym9ohnqUzWQn344',\n        object: 'event',\n        api_version: '2020-08-27',\n        created: 1669239877,\n        data: {\n            object: {\n                id: Cypress.env('STRIPE_SUBSCRIPTION_ID'),\n                object: 'subscription',\n                application: null,\n                application_fee_percent: null,\n                automatic_tax: {\n                    enabled: false,\n                },\n                billing_cycle_anchor: 1670451360,\n                billing_thresholds: null,\n                cancel_at: null,\n                cancel_at_period_end: false,\n                canceled_at: null,\n                collection_method: 'charge_automatically',\n                created: 1669846560,\n                currency: 'usd',\n                current_period_end: now.plus({ days: 7 }).toSeconds(),\n                current_period_start: now.toSeconds(),\n                customer: Cypress.env('STRIPE_CUSTOMER_ID'),\n                days_until_due: null,\n                default_payment_method: 'pm_1M7QTUKXgym9ohnqNbCbpJ7s',\n                default_source: null,\n                default_tax_rates: [],\n                description: null,\n                discount: null,\n                ended_at: null,\n                items: {\n                    object: 'list',\n                    data: [\n                        {\n                            id: 'si_Mr924Z5RPKb8g3',\n                            object: 'subscription_item',\n                            billing_thresholds: null,\n                            created: 1669846560,\n                            metadata: {},\n                            plan: {\n                                id: 'price_1M2FhWKXgym9ohnqSw68g0iP',\n                                object: 'plan',\n                                active: true,\n                                aggregate_usage: null,\n                                amount: 29900,\n                                amount_decimal: '29900',\n                                billing_scheme: 'per_unit',\n                                created: 1668005786,\n                                currency: 'usd',\n                                interval: 'year',\n                                interval_count: 1,\n                                livemode: false,\n                                metadata: {},\n                                nickname: null,\n                                product: 'prod_MlnIlne4bcx8Hd',\n                                tiers_mode: null,\n                                transform_usage: null,\n                                trial_period_days: null,\n                                usage_type: 'licensed',\n                            },\n                            price: {\n                                id: 'price_1M2FhWKXgym9ohnqSw68g0iP',\n                                object: 'price',\n                                active: true,\n                                billing_scheme: 'per_unit',\n                                created: 1668005786,\n                                currency: 'usd',\n                                custom_unit_amount: null,\n                                livemode: false,\n                                lookup_key: null,\n                                metadata: {},\n                                nickname: null,\n                                product: 'prod_MlnIlne4bcx8Hd',\n                                recurring: {\n                                    aggregate_usage: null,\n                                    interval: 'year',\n                                    interval_count: 1,\n                                    trial_period_days: null,\n                                    usage_type: 'licensed',\n                                },\n                                tax_behavior: 'unspecified',\n                                tiers_mode: null,\n                                transform_quantity: null,\n                                type: 'recurring',\n                                unit_amount: 29900,\n                                unit_amount_decimal: '29900',\n                            },\n                            quantity: 1,\n                            subscription: Cypress.env('STRIPE_SUBSCRIPTION_ID'),\n                            tax_rates: [],\n                        },\n                    ],\n                    has_more: false,\n                    total_count: 1,\n                    url: `/v1/subscription_items?subscription=${Cypress.env(\n                        'STRIPE_SUBSCRIPTION_ID'\n                    )}`,\n                },\n                latest_invoice: 'in_1M7QkCKXgym9ohnqvqUdrT7g',\n                livemode: false,\n                metadata: {},\n                next_pending_invoice_item_invoice: null,\n                on_behalf_of: null,\n                pause_collection: null,\n                payment_settings: {\n                    payment_method_options: null,\n                    payment_method_types: null,\n                    save_default_payment_method: 'off',\n                },\n                pending_invoice_item_interval: null,\n                pending_setup_intent: null,\n                pending_update: null,\n                plan: {\n                    id: 'price_1M2FhWKXgym9ohnqSw68g0iP',\n                    object: 'plan',\n                    active: true,\n                    aggregate_usage: null,\n                    amount: 29900,\n                    amount_decimal: '29900',\n                    billing_scheme: 'per_unit',\n                    created: 1668005786,\n                    currency: 'usd',\n                    interval: 'year',\n                    interval_count: 1,\n                    livemode: false,\n                    metadata: {},\n                    nickname: null,\n                    product: 'prod_MlnIlne4bcx8Hd',\n                    tiers_mode: null,\n                    transform_usage: null,\n                    trial_period_days: null,\n                    usage_type: 'licensed',\n                },\n                quantity: 1,\n                schedule: null,\n                start_date: 1669846560,\n                status: 'active',\n                test_clock: null,\n                transfer_data: null,\n                trial_end: null,\n                trial_start: null,\n            },\n        },\n        livemode: false,\n        pending_webhooks: 4,\n        request: {\n            id: null,\n            idempotency_key: null,\n        },\n        type: 'customer.subscription.created',\n    }\n}\n"
  },
  {
    "path": "apps/e2e/src/fixtures/stripe/customerSubscriptionDeleted.ts",
    "content": "import { DateTime } from 'luxon'\n\nexport function customerSubscriptionDeleted() {\n    const now = DateTime.now()\n    const oneWeekAgo = now.minus({ days: 7 })\n    const twoWeeksAgo = now.minus({ days: 14 })\n\n    return {\n        id: 'evt_1M7iAQKXgym9ohnq1Vd688Ve',\n        object: 'event',\n        api_version: '2020-08-27',\n        created: 1669306850,\n        data: {\n            object: {\n                id: Cypress.env('STRIPE_SUBSCRIPTION_ID'),\n                object: 'subscription',\n                application: null,\n                application_fee_percent: null,\n                automatic_tax: {\n                    enabled: false,\n                },\n                billing_cycle_anchor: 1669306647,\n                billing_thresholds: null,\n                cancel_at: oneWeekAgo.toSeconds(),\n                cancel_at_period_end: true,\n                canceled_at: oneWeekAgo.toSeconds(),\n                collection_method: 'charge_automatically',\n                created: 1668701847,\n                currency: 'usd',\n                current_period_end: oneWeekAgo.toSeconds(),\n                current_period_start: twoWeeksAgo.toSeconds(),\n                customer: Cypress.env('STRIPE_CUSTOMER_ID'),\n                days_until_due: null,\n                default_payment_method: 'pm_1M5AmGKXgym9ohnqCjsJ0w6f',\n                default_source: null,\n                default_tax_rates: [],\n                description: null,\n                discount: null,\n                ended_at: oneWeekAgo.toSeconds(),\n                items: {\n                    object: 'list',\n                    data: [\n                        {\n                            id: 'si_MooPDUyv3MdHpZ',\n                            object: 'subscription_item',\n                            billing_thresholds: null,\n                            created: 1668701847,\n                            metadata: {},\n                            plan: {\n                                id: 'price_1M2FhWKXgym9ohnqDY9uzwrc',\n                                object: 'plan',\n                                active: true,\n                                aggregate_usage: null,\n                                amount: 2900,\n                                amount_decimal: '2900',\n                                billing_scheme: 'per_unit',\n                                created: 1668005786,\n                                currency: 'usd',\n                                interval: 'month',\n                                interval_count: 1,\n                                livemode: false,\n                                metadata: {},\n                                nickname: null,\n                                product: 'prod_MlnIlne4bcx8Hd',\n                                tiers_mode: null,\n                                transform_usage: null,\n                                trial_period_days: null,\n                                usage_type: 'licensed',\n                            },\n                            price: {\n                                id: 'price_1M2FhWKXgym9ohnqDY9uzwrc',\n                                object: 'price',\n                                active: true,\n                                billing_scheme: 'per_unit',\n                                created: 1668005786,\n                                currency: 'usd',\n                                custom_unit_amount: null,\n                                livemode: false,\n                                lookup_key: null,\n                                metadata: {},\n                                nickname: null,\n                                product: 'prod_MlnIlne4bcx8Hd',\n                                recurring: {\n                                    aggregate_usage: null,\n                                    interval: 'month',\n                                    interval_count: 1,\n                                    trial_period_days: null,\n                                    usage_type: 'licensed',\n                                },\n                                tax_behavior: 'unspecified',\n                                tiers_mode: null,\n                                transform_quantity: null,\n                                type: 'recurring',\n                                unit_amount: 2900,\n                                unit_amount_decimal: '2900',\n                            },\n                            quantity: 1,\n                            subscription: Cypress.env('STRIPE_SUBSCRIPTION_ID'),\n                            tax_rates: [],\n                        },\n                    ],\n                    has_more: false,\n                    total_count: 1,\n                    url: `/v1/subscription_items?subscription=${Cypress.env(\n                        'STRIPE_SUBSCRIPTION_ID'\n                    )}`,\n                },\n                latest_invoice: 'in_1M5AmJKXgym9ohnqKk5EsFQ2',\n                livemode: false,\n                metadata: {},\n                next_pending_invoice_item_invoice: null,\n                on_behalf_of: null,\n                pause_collection: null,\n                payment_settings: {\n                    payment_method_options: null,\n                    payment_method_types: null,\n                    save_default_payment_method: 'off',\n                },\n                pending_invoice_item_interval: null,\n                pending_setup_intent: null,\n                pending_update: null,\n                plan: {\n                    id: 'price_1M2FhWKXgym9ohnqDY9uzwrc',\n                    object: 'plan',\n                    active: true,\n                    aggregate_usage: null,\n                    amount: 2900,\n                    amount_decimal: '2900',\n                    billing_scheme: 'per_unit',\n                    created: 1668005786,\n                    currency: 'usd',\n                    interval: 'month',\n                    interval_count: 1,\n                    livemode: false,\n                    metadata: {},\n                    nickname: null,\n                    product: 'prod_MlnIlne4bcx8Hd',\n                    tiers_mode: null,\n                    transform_usage: null,\n                    trial_period_days: null,\n                    usage_type: 'licensed',\n                },\n                quantity: 1,\n                schedule: null,\n                start_date: twoWeeksAgo.toSeconds(),\n                status: 'canceled',\n                test_clock: null,\n                transfer_data: null,\n                trial_end: null,\n                trial_start: null,\n            },\n        },\n        livemode: false,\n        pending_webhooks: 4,\n        request: {\n            id: null,\n            idempotency_key: null,\n        },\n        type: 'customer.subscription.deleted',\n    }\n}\n"
  },
  {
    "path": "apps/e2e/src/fixtures/stripe/index.ts",
    "content": "export * from './checkoutSessionCompleted'\nexport * from './customerSubscriptionCreated'\n"
  },
  {
    "path": "apps/e2e/src/support/commands.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/no-namespace\ndeclare namespace Cypress {\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    interface Chainable<Subject> {\n        login(): Chainable<any>\n        apiRequest(...params: Parameters<typeof cy.request>): Chainable<any>\n        nextApiRequest(...params: Parameters<typeof cy.request>): Chainable<any>\n        getByTestId(...parameters: Parameters<typeof cy.get>): Chainable<any>\n        selectDate(date: Date): Chainable<any>\n        preserveAccessToken(): Chainable<any>\n        restoreAccessToken(): Chainable<any>\n    }\n}\n\nCypress.Commands.add('getByTestId', (testId, ...rest) => {\n    return cy.get(`[data-testid=\"${testId}\"]`, ...rest)\n})\n\nCypress.Commands.add('apiRequest', ({ url, headers = {}, ...options }, ...rest) => {\n    return cy.request(\n        {\n            url: `${Cypress.env('API_URL')}/${url}`,\n            headers: {\n                ...headers,\n            },\n            ...options,\n        },\n        ...rest\n    )\n})\n\nCypress.Commands.add('login', () => {\n    cy.visit('/login')\n    cy.get('input[name=\"email\"]').type('test@test.com')\n    cy.get('input[name=\"password\"]').type('TestPassword123')\n    cy.get('button[type=\"submit\"]').click()\n    //eslint-disable-next-line cypress/no-unnecessary-waiting\n    cy.wait(1000)\n})\n"
  },
  {
    "path": "apps/e2e/src/support/e2e.ts",
    "content": "import './commands'\n\nbeforeEach(() => {\n    authenticateCIUser()\n    cy.apiRequest({\n        method: 'POST',\n        url: 'e2e/reset',\n        body: {},\n    }).then((response) => {\n        expect(response.status).to.equal(200)\n    })\n    cy.visit('/')\n})\n\nafter(() => {\n    authenticateCIUser()\n    cy.apiRequest({\n        method: 'POST',\n        url: 'e2e/clean',\n        body: {},\n    }).then((response) => {\n        expect(response.status).to.equal(200)\n    })\n})\n\nfunction authenticateCIUser() {\n    cy.request({\n        method: 'GET',\n        url: 'api/auth/csrf',\n    }).then((response) => {\n        let csrfCookies = response.headers['set-cookie']\n        if (Array.isArray(csrfCookies) && csrfCookies.length > 1) {\n            csrfCookies = csrfCookies.map((cookie) => cookie.split(';')[0]).join('; ')\n        }\n        const csrfToken = response.body.csrfToken.trim()\n\n        cy.request({\n            method: 'POST',\n            form: true,\n            headers: {\n                Cookie: `${csrfCookies}`,\n            },\n            url: `api/auth/callback/credentials`,\n            body: {\n                email: 'test@test.com',\n                firstName: 'Test',\n                lastName: 'User',\n                password: 'TestPassword123',\n                role: 'ci',\n                csrfToken: csrfToken,\n                json: 'true',\n            },\n        }).then((response) => {\n            expect(response.status).to.equal(200)\n        })\n    })\n}\n"
  },
  {
    "path": "apps/e2e/src/support/index.ts",
    "content": "import './commands'\nimport './e2e'\n"
  },
  {
    "path": "apps/e2e/tsconfig.json",
    "content": "{\n    \"extends\": \"../../tsconfig.base.json\",\n    \"compilerOptions\": {\n        \"sourceMap\": false,\n        \"outDir\": \"../../dist/out-tsc\",\n        \"allowJs\": true,\n        \"types\": [\"cypress\", \"node\"]\n    },\n    \"include\": [\"src/**/*.ts\", \"src/**/*.js\", \"cypress.config.ts\"]\n}\n"
  },
  {
    "path": "apps/server/.eslintrc.json",
    "content": "{\n    \"extends\": [\"../../.eslintrc.json\"],\n    \"ignorePatterns\": [\"!**/*\", \"**/*.csv\", \"Dockerfile\"],\n    \"overrides\": [\n        {\n            \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.ts\", \"*.tsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        }\n    ]\n}\n"
  },
  {
    "path": "apps/server/Dockerfile",
    "content": "# ------------------------------------------\n#                BUILD STAGE              \n# ------------------------------------------ \nFROM node:18-alpine3.18 as builder\n\nWORKDIR /app\nCOPY ./dist/apps/server ./prisma ./\nRUN npm install -g pnpm\n# nrwl/nx#20079, generated lockfile is completely broken\nRUN rm -f pnpm-lock.yaml\nRUN pnpm install --prod --no-frozen-lockfile && pnpm add ejs\n\n# ------------------------------------------\n#                PROD STAGE               \n# ------------------------------------------ \nFROM node:18-alpine3.18 as prod\n\n# Used for container health checks\nRUN apk add --no-cache curl\nWORKDIR /app\nUSER node \nCOPY --from=builder /app  .\n\nCMD [\"node\", \"--es-module-specifier-resolution=node\", \"./main.js\"]"
  },
  {
    "path": "apps/server/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n    displayName: 'server',\n    preset: '../../jest.preset.js',\n    globals: {\n        'ts-jest': {\n            tsconfig: '<rootDir>/tsconfig.spec.json',\n        },\n    },\n    testEnvironment: 'node',\n    transform: {\n        '^.+\\\\.[tj]s$': 'ts-jest',\n    },\n    moduleFileExtensions: ['ts', 'js', 'html'],\n    coverageDirectory: '../../coverage/apps/server',\n}\n"
  },
  {
    "path": "apps/server/src/app/__tests__/account.integration.spec.ts",
    "content": "import type { AxiosInstance } from 'axios'\nimport { PrismaClient, type Account, type AccountConnection, type User } from '@prisma/client'\nimport { DateTime } from 'luxon'\nimport {\n    InvestmentTransactionBalanceSyncStrategy,\n    AccountQueryService,\n    AccountService,\n    type IBalanceSyncStrategyFactory,\n} from '@maybe-finance/server/features'\nimport { InMemoryQueueFactory, PgService, type IQueueFactory } from '@maybe-finance/server/shared'\nimport { createLogger, transports } from 'winston'\nimport nock from 'nock'\nimport Decimal from 'decimal.js'\nimport { startServer, stopServer } from './utils/server'\nimport { getAxiosClient } from './utils/axios'\nimport { resetUser } from './utils/user'\nimport { createTestInvestmentAccount } from './utils/account'\n\njest.mock('bull')\n\nconst prisma = new PrismaClient()\n\nconst authId = '__TEST_USER_ID__'\nlet axios: AxiosInstance\nlet user: User\n\n// When debugging, we don't want the tests to time out\nif (process.env.IS_VSCODE_DEBUG === 'true') {\n    jest.setTimeout(100000)\n}\n\nbeforeEach(async () => {\n    // Clears old user and data, creates new user\n    user = await resetUser(authId)\n})\n\ndescribe('/v1/accounts API', () => {\n    beforeAll(async () => {\n        await startServer()\n        axios = await getAxiosClient()\n\n        nock.disableNetConnect()\n        nock.enableNetConnect((host) => {\n            return (\n                host.includes('127.0.0.1') ||\n                host.includes('maybe-finance-development.us.auth0.com')\n            )\n        })\n    }, 10_000)\n\n    afterAll(async () => {\n        await stopServer()\n    })\n\n    it('Can create, retrieve, and delete account', async () => {\n        const testPurchaseDate = DateTime.utc().startOf('day').minus({ days: 5 })\n\n        const startValue = 20_000\n        const endValue = 21_000\n\n        const res = await axios.post<Account>(`/accounts`, {\n            type: 'VEHICLE',\n            name: 'Test account',\n            categoryUser: 'vehicle',\n            startDate: testPurchaseDate.toISODate(),\n            valuations: {\n                originalBalance: 20_000,\n                currentBalance: 21_000,\n                currentDate: DateTime.utc().startOf('day').toISODate(),\n            },\n            vehicleMeta: {\n                track: false,\n                make: 'Honda',\n                model: 'Civic',\n                year: 2010,\n            },\n        })\n\n        expect(res.status).toEqual(200)\n\n        // Replace this with /accounts/:id once PR 169 is merged\n        const getAccountsResponse = await axios.get<{\n            connections: AccountConnection\n            accounts: Account[]\n        }>(`/accounts`)\n\n        expect(getAccountsResponse.status).toEqual(200)\n\n        const account = getAccountsResponse.data.accounts[0]\n\n        expect(account).toMatchObject({\n            startDate: testPurchaseDate.toJSDate(),\n            type: 'VEHICLE',\n            provider: 'user',\n            classification: 'asset',\n            category: 'vehicle',\n            subcategory: 'other',\n            accountConnectionId: null,\n            userId: user!.id,\n            name: 'Test account',\n            mask: null,\n            isActive: true,\n            syncStatus: 'IDLE',\n            currencyCode: 'USD',\n            currentBalance: new Decimal(21_000),\n            availableBalance: null,\n            vehicleMeta: {\n                track: false,\n                make: 'Honda',\n                model: 'Civic',\n                year: 2010,\n            },\n            propertyMeta: null,\n        })\n\n        const balanceResponse = await axios.get(\n            `/accounts/${\n                account.id\n            }/balances?start=${testPurchaseDate.toISODate()}&end=${DateTime.utc().toISODate()}`\n        )\n        const balances = balanceResponse.data.series.data\n\n        const interpolationStep = (endValue - startValue) / 5 // 5 intervals between start/end\n\n        expect(balances).toHaveLength(6)\n        expect(balances[0].balance).toEqual(new Decimal(startValue))\n        expect(balances[2].balance).toEqual(new Decimal(startValue + interpolationStep * 2))\n        expect(balances[balances.length - 1].balance).toEqual(new Decimal(endValue))\n\n        const deleteResponse = await axios.delete(`/accounts/${account.id}`)\n        expect(deleteResponse.status).toEqual(200)\n    })\n})\n\ndescribe('account service', () => {\n    let accountService: AccountService\n\n    beforeEach(() => {\n        const logger = createLogger({ transports: [new transports.Console()] })\n        const balanceSyncStrategyFactory: IBalanceSyncStrategyFactory = {\n            for: () => new InvestmentTransactionBalanceSyncStrategy(logger, prisma),\n        }\n        const queueFactory: IQueueFactory = new InMemoryQueueFactory()\n\n        accountService = new AccountService(\n            logger,\n            prisma,\n            new AccountQueryService(logger, new PgService(logger)),\n            queueFactory.createQueue('sync-account'),\n            queueFactory.createQueue('sync-account-connection'),\n            balanceSyncStrategyFactory\n        )\n    })\n\n    describe('account returns', () => {\n        it('calculates contributions for account w/ full history', async () => {\n            const account = await createTestInvestmentAccount(prisma, user, 'portfolio-1')\n\n            const series = await accountService.getReturns(account.id, '2021-12-30', '2022-03-31')\n\n            const data = (date: string) => series.find((b) => b.date === date)!\n\n            expect(series).toHaveLength(92)\n            expect(data('2021-12-30').account.contributions?.toNumber()).toEqual(0)\n            expect(data('2021-12-31').account.contributions?.toNumber()).toEqual(5000)\n            expect(data('2022-02-04').account.contributions?.toNumber()).toEqual(5000)\n            expect(data('2022-02-05').account.contributions?.toNumber()).toEqual(4000)\n            expect(data('2022-03-07').account.contributions?.toNumber()).toEqual(4000)\n            expect(data('2022-03-08').account.contributions?.toNumber()).toEqual(8000)\n            expect(data('2022-03-31').account.contributions?.toNumber()).toEqual(8000)\n        })\n\n        it('calculates contributions for account w/ partial history', async () => {\n            const account = await createTestInvestmentAccount(prisma, user, 'portfolio-2')\n\n            const series = await accountService.getReturns(account.id, '2022-08-01', '2022-08-24')\n\n            expect(series).toHaveLength(24)\n            expect(series.map((d) => d.account.contributions?.toNumber())).toEqual([\n                // 8/1 -> 8/7\n                0, 0, 0, 0, 0, 0, 0,\n                // 8/8 -> 8/14\n                0, 0, 2_000, 2_000, 7_000, 7_000, 7_000,\n                // 8/15 -> 8/21\n                9_000, 9_000, 9_000, 7_000, 7_000, 7_000, 7_000,\n                // 8/22 -> 8/24\n                8_000, 8_000, 8_000,\n            ])\n        })\n\n        it('calculates returns for account w/ full history', async () => {\n            const account = await createTestInvestmentAccount(prisma, user, 'portfolio-1')\n\n            await accountService.syncBalances(account.id)\n\n            const series = await accountService.getReturns(\n                account.id,\n                '2021-12-30', // any dates prior to the start date should result in 0% return\n                '2022-04-01'\n            )\n\n            /**\n             * Use Google Sheet and copy data from return column and paste into file\n             * https://docs.google.com/spreadsheets/d/1xL1MnvLhvVqea9JfEO_FYjcv9AIfkz5NnwIh5nMMZRg/edit?usp=sharing\n             *\n             * with open('/path/to/file', 'r') as fp:\n             *   s = fp.read()\n             *\n             * s.replace('\\n', ',')\n             */\n            const expectedReturns = [\n                0.0, 0.0, 0.0, 0.0, 0.001, 0.002, 0.002, 0.002, 0.002, 0.002, 0.002, 0.002, 0.002,\n                0.004, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008, 0.008,\n                0.008, 0.008, 0.004, 0.004, 0.004, 0.004, 0.004, 0.004, 0.004, 0.004, 0.004, 0.004,\n                0.005, 0.005, 0.0195, 0.0155, 0.0155, 0.0155, 0.0155, 0.0155, 0.0155, 0.0155,\n                0.0155, 0.0155, 0.023, 0.023, 0.023, 0.023, 0.023, 0.023, 0.023, 0.031, 0.031,\n                0.031, 0.031, 0.0335, 0.0335, 0.036, 0.036, 0.036, 0.036, 0.036, 0.036, 0.018,\n                0.0213, 0.0481, 0.0738, 0.0803, 0.0803, 0.0803, 0.0503, 0.0503, 0.0503, 0.0503,\n                0.0503, 0.0503, 0.0503, 0.0503, 0.0503, 0.0353, 0.0353, 0.0353, 0.0353, 0.0353,\n                0.0578, 0.0578, 0.0578, 0.0578,\n            ]\n\n            expect(series.map((d) => d.account.rateOfReturn.toNumber())).toEqual(expectedReturns)\n        })\n\n        it('calculates returns and contributions for partial history of account', async () => {\n            const account = await createTestInvestmentAccount(prisma, user, 'portfolio-1')\n\n            await accountService.syncBalances(account.id)\n\n            const series = await accountService.getReturns(\n                account.id,\n                '2022-03-04', // any dates prior to the start date should result in 0% return\n                '2022-03-15'\n            )\n\n            /**\n             * https://docs.google.com/spreadsheets/d/1xL1MnvLhvVqea9JfEO_FYjcv9AIfkz5NnwIh5nMMZRg/edit?usp=sharing\n             * (see \"Historical Balances\" column AI)\n             */\n            const expectedReturns = [\n                0.0, 0.0, 0.0, 0.0, 0.0, 0.0063, 0.0582, 0.1076, 0.1202, 0.1202, 0.1202, 0.0623,\n            ]\n\n            const expectedCumulativeContributions = [\n                4000, 4000, 4000, 4000, 8000, 8000, 8000, 8000, 8000, 8000, 8000, 8000,\n            ]\n\n            const expectedPeriodContributions = [\n                0, 0, 0, 0, 4000, 4000, 4000, 4000, 4000, 4000, 4000, 4000,\n            ]\n\n            expect(series.map((d) => d.account.rateOfReturn.toNumber())).toEqual(expectedReturns)\n            expect(series.map((d) => d.account.contributions?.toNumber())).toEqual(\n                expectedCumulativeContributions\n            )\n            expect(series.map((d) => d.account.contributionsPeriod?.toNumber())).toEqual(\n                expectedPeriodContributions\n            )\n        })\n    })\n})\n"
  },
  {
    "path": "apps/server/src/app/__tests__/balance-sync.integration.spec.ts",
    "content": "import type { User } from '@prisma/client'\nimport { InvestmentTransactionCategory } from '@prisma/client'\nimport { PrismaClient } from '@prisma/client'\nimport { createLogger, transports } from 'winston'\nimport { DateTime } from 'luxon'\nimport {\n    AccountQueryService,\n    AccountService,\n    BalanceSyncStrategyFactory,\n    LoanBalanceSyncStrategy,\n    TransactionBalanceSyncStrategy,\n    ValuationBalanceSyncStrategy,\n} from '@maybe-finance/server/features'\nimport { InvestmentTransactionBalanceSyncStrategy } from '@maybe-finance/server/features'\nimport { resetUser } from './utils/user'\nimport { createTestInvestmentAccount } from './utils/account'\nimport { PgService } from '@maybe-finance/server/shared'\nimport type { SharedType } from '@maybe-finance/shared'\n\nconst prisma = new PrismaClient()\nconst logger = createLogger({ transports: [new transports.Console()] })\n\nlet user: User\n\nconst transactionStrategy = new TransactionBalanceSyncStrategy(logger, prisma)\n\nconst investmentTransactionStrategy = new InvestmentTransactionBalanceSyncStrategy(logger, prisma)\n\nconst valuationStrategy = new ValuationBalanceSyncStrategy(logger, prisma)\n\nconst loanStrategy = new LoanBalanceSyncStrategy(logger, prisma)\n\nconst balanceSyncStrategyFactory = new BalanceSyncStrategyFactory({\n    INVESTMENT: investmentTransactionStrategy,\n    DEPOSITORY: transactionStrategy,\n    CREDIT: transactionStrategy,\n    LOAN: loanStrategy,\n    PROPERTY: valuationStrategy,\n    VEHICLE: valuationStrategy,\n    OTHER_ASSET: valuationStrategy,\n    OTHER_LIABILITY: valuationStrategy,\n})\n\nconst pgService = new PgService(logger)\n\nconst queryService = new AccountQueryService(logger, pgService)\n\nconst accountService = new AccountService(\n    logger,\n    prisma,\n    queryService,\n    {} as any,\n    {} as any,\n    balanceSyncStrategyFactory\n)\n\nbeforeEach(async () => {\n    user = await resetUser()\n})\n\nafterAll(async () => {\n    await prisma.$disconnect()\n})\n\ndescribe('balance sync strategies', () => {\n    describe('investment accounts', () => {\n        it('syncs balances', async () => {\n            const account = await createTestInvestmentAccount(prisma, user, 'portfolio-1')\n\n            await accountService.syncBalances(account.id)\n\n            const balances = await accountService.getBalances(\n                account.id,\n                '2021-12-30',\n                '2022-04-01',\n                'days'\n            )\n\n            const actualBalances = balances.series.data.map((b) => b.balance.toNumber())\n\n            // // 12/30/21 => 4/1/22 balances (see google sheet)\n            const expectedBalances = [\n                0, 5000, 5000, 5000, 5005, 5010, 5010, 5010, 5010, 5010, 5010, 5010, 5010, 5020,\n                5040, 5040, 5040, 5040, 5040, 5040, 5040, 5040, 5040, 5040, 5040, 5040, 5040, 5020,\n                5020, 5020, 5020, 5020, 5020, 5020, 5020, 5020, 5020, 4020, 4020, 4078, 4062, 4062,\n                4062, 4062, 4062, 4062, 4062, 4062, 4062, 4092, 4092, 4092, 4092, 4092, 4092, 4092,\n                4124, 4124, 4124, 4124, 4134, 4134, 4144, 4144, 4144, 4144, 4144, 4144, 8144, 8170,\n                8385, 8590, 8642, 8642, 8642, 8402, 8402, 8402, 8402, 8402, 8402, 8402, 8402, 8402,\n                8282, 8282, 8282, 8282, 8282, 8462, 8462, 8462, 8462,\n            ]\n\n            expect(actualBalances).toEqual(expectedBalances)\n        })\n\n        it('syncs balances w/ txn amt/qty sign mismatch', async () => {\n            const account = await prisma.$transaction(async (tx) => {\n                const security = await tx.security.create({\n                    data: {\n                        symbol: 'AAPL',\n                        pricing: {\n                            create: {\n                                date: DateTime.fromISO('2023-02-01').toJSDate(),\n                                priceClose: 10,\n                            },\n                        },\n                    },\n                })\n\n                return tx.account.create({\n                    data: {\n                        name: 'test investment account',\n                        provider: 'user',\n                        type: 'INVESTMENT',\n                        currentBalanceProvider: 50,\n                        availableBalanceProvider: 0,\n                        holdings: {\n                            create: [\n                                {\n                                    securityId: security.id,\n                                    quantity: 5,\n                                    value: 50,\n                                },\n                            ],\n                        },\n                        investmentTransactions: {\n                            create: [\n                                {\n                                    date: DateTime.fromISO('2023-02-01').toJSDate(),\n                                    securityId: security.id,\n                                    name: 'buy - seed',\n                                    amount: 100,\n                                    quantity: 10,\n                                    price: 10,\n                                    category: InvestmentTransactionCategory.buy,\n                                },\n                                {\n                                    date: DateTime.fromISO('2023-02-04').toJSDate(),\n                                    securityId: security.id,\n                                    name: 'sell - amt/qty sign mismatch',\n                                    amount: -50,\n                                    quantity: 5,\n                                    price: 10,\n                                    category: InvestmentTransactionCategory.sell,\n                                },\n                                {\n                                    date: DateTime.fromISO('2023-02-04').toJSDate(),\n                                    name: 'withdraw - cash out',\n                                    amount: 50,\n                                    quantity: 50,\n                                    price: 1,\n                                    category: InvestmentTransactionCategory.other,\n                                },\n                            ],\n                        },\n                    },\n                })\n            })\n\n            await accountService.syncBalances(account.id)\n\n            const balances = await accountService.getBalances(\n                account.id,\n                '2023-02-01',\n                '2023-02-07',\n                'days'\n            )\n\n            expect(balances.series.data.map((d) => +d.balance)).toEqual([\n                100, 100, 100, 50, 50, 50, 50,\n            ])\n        })\n\n        it.each`\n            current | available | expected\n            ${123}  | ${null}   | ${123}\n            ${123}  | ${0}      | ${123}\n        `(\n            'syncs account w/ no holdings or transactions (current=$current available=$available)',\n            async ({ current, available, expected }) => {\n                const account = await prisma.account.create({\n                    data: {\n                        name: 'test investment account',\n                        provider: 'user',\n                        type: 'INVESTMENT',\n                        currentBalanceProvider: current,\n                        availableBalanceProvider: available,\n                    },\n                })\n\n                await accountService.syncBalances(account.id)\n\n                const balances = await accountService.getBalances(\n                    account.id,\n                    '2023-01-01',\n                    '2023-01-07',\n                    'days'\n                )\n\n                expect(balances.series.data.map((d) => +d.balance)).toEqual(Array(7).fill(expected))\n            }\n        )\n    })\n\n    it('syncs depository balances', async () => {\n        expect(1).toEqual(1)\n    })\n\n    it('syncs valuation balances', async () => {\n        expect(1).toEqual(1)\n    })\n\n    describe('loan accounts', () => {\n        it('syncs loan w/ transactions', async () => {\n            // we want to test a loan account that has transaction data\n            const account = await prisma.account.create({\n                data: {\n                    user: { connect: { id: user.id } },\n                    name: 'test loan balance sync strategy',\n                    type: 'LOAN',\n                    provider: 'user',\n                    currentBalanceProvider: 50,\n                    availableBalanceProvider: 0,\n                    startDate: DateTime.fromISO('2022-07-31').toJSDate(),\n                    loanUser: {\n                        interestRate: { type: 'fixed', rate: 0.1 },\n                        loanDetail: { type: 'other' },\n                        originationDate: '2022-07-31',\n                        originationPrincipal: 200,\n                    } as SharedType.Loan,\n                    transactions: {\n                        createMany: {\n                            data: [\n                                {\n                                    date: DateTime.fromISO('2022-08-05').toJSDate(),\n                                    amount: -50,\n                                    name: 'PAYMENT',\n                                },\n                            ],\n                        },\n                    },\n                },\n            })\n\n            await accountService.syncBalances(account.id)\n\n            const balances = await accountService.getBalances(\n                account.id,\n                '2022-08-01',\n                '2022-08-07',\n                'days'\n            )\n\n            expect(balances.series.data.map((d) => d.balance.toNumber())).toEqual([\n                175, 150, 125, 100, 50, 50, 50,\n            ])\n        })\n\n        it('syncs loan without loan data', async () => {\n            const account = await prisma.account.create({\n                data: {\n                    userId: user.id,\n                    name: 'test loan balance sync strategy',\n                    type: 'LOAN',\n                    provider: 'user',\n                    currentBalanceProvider: 0,\n                    availableBalanceProvider: 0,\n                    startDate: DateTime.fromISO('2022-07-31').toJSDate(),\n                },\n            })\n\n            await accountService.syncBalances(account.id)\n\n            const balances = await accountService.getBalances(\n                account.id,\n                '2022-08-01',\n                '2022-08-07',\n                'days'\n            )\n\n            expect(balances.series.data.map((d) => d.balance.toNumber())).toEqual([\n                0, 0, 0, 0, 0, 0, 0,\n            ])\n        })\n    })\n})\n"
  },
  {
    "path": "apps/server/src/app/__tests__/connection.integration.spec.ts",
    "content": "import type { AxiosInstance } from 'axios'\nimport type { Prisma, AccountConnection, User } from '@prisma/client'\nimport { AccountConnectionType, AccountSyncStatus } from '@prisma/client'\nimport { startServer, stopServer } from './utils/server'\nimport { getAxiosClient } from './utils/axios'\nimport prisma from '../lib/prisma'\nimport { InMemoryQueue } from '@maybe-finance/server/shared'\nimport nock from 'nock'\nimport { resetUser } from './utils/user'\n\njest.mock('../lib/teller.ts')\n\nconst authId = '__TEST_USER_ID__'\nlet axios: AxiosInstance\nlet user: User | null\nlet connection: AccountConnection\nlet connectionData: Prisma.AccountConnectionCreateArgs\n\n// When debugging, we don't want the tests to time out\nif (process.env.IS_VSCODE_DEBUG === 'true') {\n    jest.setTimeout(100000)\n}\n\nbeforeAll(async () => {\n    await startServer()\n    axios = await getAxiosClient()\n\n    nock.enableNetConnect()\n    nock.disableNetConnect()\n    nock.enableNetConnect((host) => {\n        return host.includes('127.0.0.1') || host.includes('maybe-finance-development.us.auth0.com')\n    })\n})\n\nafterAll(async () => {\n    await stopServer()\n})\n\nbeforeEach(async () => {\n    user = await resetUser(authId)\n\n    connectionData = {\n        data: {\n            name: 'Chase Test',\n            type: AccountConnectionType.teller,\n            tellerEnrollmentId: 'test-teller-item-workers',\n            tellerInstitutionId: 'chase_test',\n            tellerAccessToken:\n                'U2FsdGVkX1+WMq9lfTS9Zkbgrn41+XT1hvSK5ain/udRPujzjVCAx/lyPG7EumVZA+nVKXPauGwI+d7GZgtqTA9R3iCZNusU6LFPnmFOCE4=', // need correct encoding here\n            userId: user.id,\n            syncStatus: AccountSyncStatus.PENDING,\n        },\n    }\n\n    connection = await prisma.accountConnection.create(connectionData)\n})\n\nafterEach(async () => {\n    if (user) {\n        await prisma.user.delete({ where: { id: user.id } })\n    }\n})\n\ndescribe('/v1/connections API', () => {\n    it('POST /:id/sync', async () => {\n        const queueSpy = jest.spyOn(InMemoryQueue.prototype, 'add')\n\n        const res = await axios.post<AccountConnection>(`/connections/${connection.id}/sync`)\n\n        expect(res.status).toEqual(200)\n\n        expect(queueSpy).toBeCalledWith(\n            'sync-connection',\n            expect.objectContaining({\n                accountConnectionId: res.data.id,\n            })\n        )\n\n        // Partial equality\n        expect(res.data).toMatchObject({\n            ...connectionData.data,\n            syncStatus: 'PENDING',\n        })\n    })\n\n    it('DELETE /:id', async () => {\n        const res = await axios.delete<AccountConnection>(`/connections/${connection.id}`)\n\n        expect(res.status).toEqual(200)\n\n        const res2 = await axios.get<AccountConnection>(`/connections/${connection.id}`)\n\n        // Should not be able to retrieve a connection after deletion\n        expect(res2.status).toEqual(500)\n    })\n})\n"
  },
  {
    "path": "apps/server/src/app/__tests__/insights.integration.spec.ts",
    "content": "import type { User } from '@prisma/client'\nimport { AssetClass, InvestmentTransactionCategory, Prisma, PrismaClient } from '@prisma/client'\nimport { createLogger, transports } from 'winston'\nimport { DateTime } from 'luxon'\nimport type {\n    IBalanceSyncStrategyFactory,\n    IInsightService,\n    ITransactionService,\n} from '@maybe-finance/server/features'\nimport { InvestmentTransactionBalanceSyncStrategy } from '@maybe-finance/server/features'\nimport { InsightService, TransactionService } from '@maybe-finance/server/features'\nimport { resetUser } from './utils/user'\nimport { createTestInvestmentAccount } from './utils/account'\n\nconst prisma = new PrismaClient()\n\nconst date = (s: string) => DateTime.fromISO(s, { zone: 'utc' }).toJSDate()\n\nlet user: User\n\nbeforeEach(async () => {\n    // Clears old user and data, creates new user\n    user = await resetUser()\n})\n\nafterAll(async () => {\n    await prisma.$disconnect()\n})\n\ndescribe('insight service', () => {\n    let transactionService: ITransactionService\n    let insightService: IInsightService\n    let balanceSyncStrategyFactory: IBalanceSyncStrategyFactory\n\n    beforeEach(async () => {\n        const logger = createLogger({ transports: [new transports.Console()] })\n\n        transactionService = new TransactionService(logger, prisma)\n        insightService = new InsightService(logger, prisma)\n        balanceSyncStrategyFactory = {\n            for: () => new InvestmentTransactionBalanceSyncStrategy(logger, prisma),\n        }\n\n        await prisma.user.update({\n            where: { id: user.id },\n            data: {\n                accounts: { deleteMany: {} },\n            },\n        })\n    })\n\n    describe('user insights', () => {\n        it('calculates transaction summary correctly', async () => {\n            // create accounts\n            const [checking, savings, credit, loan] = await Promise.all([\n                prisma.account.create({\n                    data: {\n                        userId: user.id,\n                        name: 'Checking Account',\n                        type: 'DEPOSITORY',\n                        provider: 'user',\n                        startDate: date('2022-07-01'),\n                        currentBalanceProvider: 1_000,\n                    },\n                }),\n                prisma.account.create({\n                    data: {\n                        userId: user.id,\n                        name: 'Savings Account',\n                        type: 'DEPOSITORY',\n                        provider: 'user',\n                        startDate: date('2022-07-01'),\n                        currentBalanceProvider: 2_000,\n                    },\n                }),\n                prisma.account.create({\n                    data: {\n                        userId: user.id,\n                        name: 'Credit Card',\n                        type: 'CREDIT',\n                        provider: 'user',\n                        startDate: date('2022-07-01'),\n                        currentBalanceProvider: 10,\n                    },\n                }),\n                prisma.account.create({\n                    data: {\n                        userId: user.id,\n                        name: 'Mortgage',\n                        type: 'LOAN',\n                        provider: 'user',\n                        startDate: date('2022-07-01'),\n                        currentBalanceProvider: 100_000,\n                    },\n                }),\n            ])\n\n            // create transactions\n            await prisma.transaction.createMany({\n                data: [\n                    {\n                        accountId: checking.id,\n                        amount: 1_000,\n                        name: 'Mortgage Payment',\n                        date: date('2022-07-01'),\n                    },\n                    {\n                        accountId: loan.id,\n                        amount: -1_000,\n                        name: 'Mortgage Payment from Checking',\n                        date: date('2022-07-02'),\n                    },\n                    {\n                        accountId: checking.id,\n                        amount: -100,\n                        name: 'Payroll Direct Deposit',\n                        date: date('2022-07-04'),\n                    },\n                    {\n                        accountId: savings.id,\n                        amount: -100,\n                        name: 'Payroll Direct Deposit',\n                        date: date('2022-07-04'),\n                    },\n                    {\n                        accountId: checking.id,\n                        amount: 100,\n                        name: 'Transfer to Savings',\n                        date: date('2022-07-04'),\n                    },\n                    {\n                        accountId: savings.id,\n                        amount: -100,\n                        name: 'Transfer from Checking',\n                        date: date('2022-07-05'),\n                    },\n                    {\n                        accountId: credit.id,\n                        amount: 100,\n                        name: 'Purchase w/ Credit Card',\n                        date: date('2022-07-04'),\n                    },\n                    {\n                        accountId: credit.id,\n                        amount: 50,\n                        name: 'Purchase w/ Credit Card',\n                        date: date('2022-07-05'),\n                    },\n                    {\n                        accountId: checking.id,\n                        amount: 100,\n                        name: 'Payment to Credit Card',\n                        date: date('2022-07-07'),\n                    },\n                    {\n                        accountId: credit.id,\n                        amount: -100,\n                        name: 'Payment from Checking',\n                        date: date('2022-07-07'),\n                    },\n                    {\n                        accountId: credit.id,\n                        amount: 1_000,\n                        name: 'Purchase w/ Credit Card',\n                        date: date('2022-07-09'),\n                        excluded: true,\n                    },\n                ],\n            })\n\n            const { transactionSummary } = await insightService.getUserInsights({\n                userId: user.id,\n                now: DateTime.fromISO('2022-08-02'),\n            })\n\n            expect(transactionSummary.income).toEqual(new Prisma.Decimal(300))\n            expect(transactionSummary.expenses).toEqual(new Prisma.Decimal(1350))\n            expect(transactionSummary.payments).toEqual(new Prisma.Decimal(1_000))\n\n            await transactionService.markTransfers(user.id)\n            const { transactionSummary: summary2 } = await insightService.getUserInsights({\n                userId: user.id,\n                now: DateTime.fromISO('2022-08-02'),\n            })\n\n            expect(summary2.income).toEqual(new Prisma.Decimal(100))\n            expect(summary2.expenses).toEqual(new Prisma.Decimal(150))\n            expect(summary2.payments).toEqual(new Prisma.Decimal(1_000))\n        })\n    })\n\n    describe('account insights', () => {\n        it('calculates PnL, cost basis, and holdings breakdown', async () => {\n            const account = await prisma.account.create({\n                data: {\n                    userId: user.id,\n                    name: 'Investment Account',\n                    type: 'INVESTMENT',\n                    provider: 'user',\n                    startDate: date('2022-07-01'),\n                    currentBalanceProvider: 500,\n                    holdings: {\n                        create: [\n                            {\n                                security: {\n                                    create: { symbol: 'AAPL', assetClass: AssetClass.stocks },\n                                },\n                                quantity: 1,\n                                costBasisUser: 100,\n                                value: 200,\n                            },\n                            {\n                                security: {\n                                    create: { symbol: 'NFLX', assetClass: AssetClass.stocks },\n                                },\n                                quantity: 10,\n                                costBasisUser: 200,\n                                value: 300,\n                            },\n                            {\n                                security: {\n                                    create: { symbol: 'SHOP', assetClass: AssetClass.stocks },\n                                },\n                                quantity: 2,\n                                costBasisUser: 100,\n                                value: 50,\n                            },\n                        ],\n                    },\n                },\n            })\n\n            const { portfolio } = await insightService.getAccountInsights({\n                accountId: account.id,\n            })\n\n            expect(portfolio).toBeDefined()\n            expect(portfolio!.pnl).toMatchObject({\n                amount: new Prisma.Decimal(150),\n                percentage: new Prisma.Decimal(0.375),\n                direction: 'up',\n            })\n            expect(portfolio!.costBasis).toEqual(new Prisma.Decimal(400))\n            expect(portfolio!.holdingBreakdown).toHaveLength(1)\n            expect(portfolio!.holdingBreakdown[0]).toMatchObject({\n                asset_class: 'stocks',\n                amount: new Prisma.Decimal(550),\n                percentage: new Prisma.Decimal(1),\n            })\n        })\n\n        it('calculates returns on basic sample portfolio', async () => {\n            const account = await createTestInvestmentAccount(prisma, user, 'portfolio-1')\n\n            await balanceSyncStrategyFactory.for(account).syncAccountBalances(account)\n\n            const { portfolio } = await insightService.getAccountInsights({\n                accountId: account.id,\n                now: DateTime.fromISO('2022-03-31', { zone: 'utc' }),\n            })\n\n            expect(portfolio).toBeDefined()\n            expect(portfolio!.return.ytd).toBeDefined()\n            expect(portfolio!.return.ytd!.amount).toEqual(new Prisma.Decimal(462))\n            expect(portfolio!.return.ytd!.percentage).toEqual(new Prisma.Decimal(0.0851))\n            expect(portfolio!.return.ytd!.direction).toBe('up')\n        })\n    })\n\n    describe('holding insights', () => {\n        it('calculates holdings insights', async () => {\n            const account = await prisma.account.create({\n                data: {\n                    userId: user.id,\n                    name: 'Investment Account',\n                    type: 'INVESTMENT',\n                    provider: 'user',\n                    startDate: date('2022-07-01'),\n                    currentBalanceProvider: 7000,\n                    availableBalanceProvider: 0,\n                    holdings: {\n                        create: [\n                            {\n                                security: { create: { name: 'Apple', symbol: 'AAPL_TEST' } },\n                                quantity: 50,\n                                costBasisUser: 100,\n                                value: 5000,\n                            },\n                            {\n                                security: { create: { name: 'Netflix', symbol: 'NFLX_TEST' } },\n                                quantity: 10,\n                                costBasisUser: 200,\n                                value: 2000,\n                            },\n                        ],\n                    },\n                },\n                include: { holdings: { include: { security: true } } },\n            })\n\n            const AAPL = account.holdings.find((h) => h.security.symbol === 'AAPL_TEST')!\n            const NFLX = account.holdings.find((h) => h.security.symbol === 'NFLX_TEST')!\n\n            await prisma.investmentTransaction.createMany({\n                data: [\n                    {\n                        accountId: account.id,\n                        securityId: AAPL.securityId,\n                        date: date('2022-06-01'),\n                        name: 'Buy AAPL',\n                        amount: 50 * 100,\n                        quantity: 50,\n                        price: 100,\n                        category: InvestmentTransactionCategory.buy,\n                    },\n                    {\n                        accountId: account.id,\n                        securityId: NFLX.securityId,\n                        date: date('2022-06-02'),\n                        name: 'Buy NFLX',\n                        amount: 10 * 200,\n                        quantity: 10,\n                        price: 200,\n                        category: InvestmentTransactionCategory.buy,\n                    },\n                    {\n                        accountId: account.id,\n                        securityId: AAPL.securityId,\n                        date: date('2022-06-20'),\n                        name: 'AAPL Dividend',\n                        amount: -20.22,\n                        quantity: 0,\n                        price: 0,\n                        category: InvestmentTransactionCategory.dividend,\n                    },\n                    {\n                        accountId: account.id,\n                        securityId: AAPL.securityId,\n                        date: date('2022-06-28'),\n                        name: 'AAPL Dividend',\n                        amount: -22.85,\n                        quantity: 0,\n                        price: 0,\n                        category: InvestmentTransactionCategory.dividend,\n                    },\n                ],\n            })\n\n            const { allocation, dividends } = await insightService.getHoldingInsights({\n                holding: AAPL,\n            })\n\n            //clean up\n            await prisma.security.deleteMany({\n                where: { symbol: { in: ['AAPL_TEST', 'NFLX_TEST'] } },\n            })\n            await prisma.account.delete({ where: { id: account.id } })\n\n            expect(allocation?.toNumber()).toEqual(5000 / 7000)\n            expect(dividends?.toNumber()).toEqual(-20.22 + -22.85)\n        })\n    })\n\n    describe('plan insights', () => {\n        it('calculates correctly', async () => {\n            // update user income/expenses\n            await prisma.user.update({\n                where: { id: user.id },\n                data: {\n                    monthlyIncomeUser: 10_000,\n                    monthlyExpensesUser: 5_000,\n                    monthlyDebtUser: 0,\n                },\n            })\n\n            // create accounts\n            await prisma.account.createMany({\n                data: [\n                    {\n                        userId: user.id,\n                        name: 'Checking Account',\n                        type: 'DEPOSITORY',\n                        provider: 'user',\n                        startDate: date('2022-01-01'),\n                        currentBalanceProvider: 10_000,\n                    },\n                    {\n                        userId: user.id,\n                        name: 'Credit Card',\n                        type: 'CREDIT',\n                        provider: 'user',\n                        startDate: date('2022-01-01'),\n                        currentBalanceProvider: 1_000,\n                    },\n                    {\n                        userId: user.id,\n                        name: 'Mortgage',\n                        type: 'LOAN',\n                        provider: 'user',\n                        startDate: date('2022-01-01'),\n                        currentBalanceProvider: 800_000,\n                    },\n                    {\n                        userId: user.id,\n                        name: 'House',\n                        type: 'PROPERTY',\n                        provider: 'user',\n                        startDate: date('2022-01-01'),\n                        currentBalanceProvider: 1_000_000,\n                    },\n                ],\n            })\n\n            const { projectionAssetBreakdown, projectionLiabilityBreakdown, income, expenses } =\n                await insightService.getPlanInsights({\n                    userId: user.id,\n                    now: DateTime.fromISO('2022-01-01', { zone: 'utc' }),\n                })\n\n            expect(income).toEqual(new Prisma.Decimal(120_000))\n            expect(expenses).toEqual(new Prisma.Decimal(60_000))\n\n            expect(projectionAssetBreakdown).toContainEqual({\n                type: 'cash',\n                amount: new Prisma.Decimal(10_000),\n            })\n            expect(projectionAssetBreakdown).toContainEqual({\n                type: 'property',\n                amount: new Prisma.Decimal(1_000_000),\n            })\n\n            expect(projectionLiabilityBreakdown).toContainEqual({\n                type: 'credit',\n                amount: new Prisma.Decimal(1_000),\n            })\n            expect(projectionLiabilityBreakdown).toContainEqual({\n                type: 'loan',\n                amount: new Prisma.Decimal(800_000),\n            })\n        })\n    })\n})\n"
  },
  {
    "path": "apps/server/src/app/__tests__/net-worth.integration.spec.ts",
    "content": "import type { AccountCategory, AccountProvider, AccountType, User } from '@prisma/client'\nimport { PrismaClient, Prisma } from '@prisma/client'\nimport { createLogger, transports } from 'winston'\nimport { DateTime } from 'luxon'\nimport { PgService } from '@maybe-finance/server/shared'\nimport { AccountQueryService, UserService } from '@maybe-finance/server/features'\nimport { resetUser } from './utils/user'\n\nconst prisma = new PrismaClient()\n\nconst date = (s: string) => DateTime.fromISO(s, { zone: 'utc' }).toJSDate()\n\nlet user: User\n\nbeforeEach(async () => {\n    user = await resetUser()\n})\n\nafterAll(async () => {\n    await prisma.$disconnect()\n})\n\ndescribe('user net worth', () => {\n    let userService: UserService\n\n    beforeEach(async () => {\n        const logger = createLogger({ transports: [new transports.Console()] })\n\n        userService = new UserService(\n            logger,\n            prisma,\n            new AccountQueryService(logger, new PgService(logger)),\n            {\n                for: () => ({ syncAccountBalances: () => Promise.resolve() }),\n            },\n            {} as any,\n            {} as any,\n            {} as any\n        )\n\n        await prisma.user.update({\n            where: { id: user.id },\n            data: {\n                accounts: { deleteMany: {} },\n            },\n        })\n    })\n\n    describe('single account net worth', () => {\n        it('has 0 balance prior to account start date', async () => {\n            await prisma.user.update({\n                where: { id: user.id },\n                data: {\n                    accounts: {\n                        create: {\n                            name: 'Test Account',\n                            type: 'OTHER_ASSET',\n                            provider: 'user',\n                            currencyCode: 'USD',\n                            startDate: date('2022-01-02'),\n                            currentBalanceProvider: 200,\n                            categoryProvider: 'cash',\n                            balances: {\n                                create: [\n                                    { date: date('2022-01-01'), balance: 100 },\n                                    { date: date('2022-01-02'), balance: 200 },\n                                    { date: date('2022-01-03'), balance: 300 },\n                                ],\n                            },\n                        },\n                    },\n                },\n            })\n\n            const expectedNetWorths: [string, number][] = [\n                ['2022-01-01', 0],\n                ['2022-01-02', 200],\n                ['2022-01-03', 300],\n            ]\n\n            const {\n                series: { data },\n            } = await userService.getNetWorthSeries(user.id, '2022-01-01', '2022-01-03')\n\n            expect(data).toHaveLength(3)\n\n            expectedNetWorths.forEach(([date, netWorth], idx) => {\n                expect(data[idx]).toMatchObject<Partial<typeof data[0]>>({\n                    date,\n                    netWorth: new Prisma.Decimal(netWorth),\n                })\n            })\n        })\n\n        it('only returns dates within requested range (inclusive)', async () => {\n            await prisma.user.update({\n                where: { id: user.id },\n                data: {\n                    accounts: {\n                        create: {\n                            name: 'Test Account',\n                            type: 'OTHER_ASSET',\n                            provider: 'user',\n                            currencyCode: 'USD',\n                            categoryProvider: 'cash',\n                            balances: {\n                                create: [\n                                    { date: date('2015-07-31'), balance: 0 },\n                                    { date: date('2015-08-01'), balance: 800 },\n                                    { date: date('2015-09-01'), balance: 900 },\n                                ],\n                            },\n                        },\n                    },\n                },\n            })\n\n            const {\n                series: { data },\n            } = await userService.getNetWorthSeries(user.id, '2015-07-31', '2022-02-03', 'years')\n\n            // check bounds of returned data\n            expect(data[0]).toMatchObject({\n                date: '2015-07-31',\n                netWorth: new Prisma.Decimal(0),\n            })\n\n            expect(data[data.length - 1]).toMatchObject({\n                date: '2022-02-03',\n                netWorth: new Prisma.Decimal(900),\n            })\n        })\n\n        it.skip('computes the same net worth for a date regardless of the interval', async () => {\n            await prisma.user.update({\n                where: { id: user.id },\n                data: {\n                    accounts: {\n                        create: [\n                            {\n                                name: 'Test Account A',\n                                type: 'OTHER_ASSET',\n                                provider: 'user',\n                                currencyCode: 'USD',\n                                categoryProvider: 'cash',\n                                balances: {\n                                    create: [\n                                        { date: date('2015-07-31'), balance: 50 },\n                                        { date: date('2015-08-01'), balance: 60 },\n                                        { date: date('2020-02-04'), balance: 70 },\n                                    ],\n                                },\n                            },\n                            {\n                                name: 'Test Account B',\n                                type: 'OTHER_ASSET',\n                                provider: 'user',\n                                currencyCode: 'USD',\n                                categoryProvider: 'cash',\n                                balances: {\n                                    create: [\n                                        { date: date('2020-02-10'), balance: 100 },\n                                        { date: date('2020-02-11'), balance: 110 },\n                                        { date: date('2021-02-04'), balance: 120 },\n                                    ],\n                                },\n                            },\n                        ],\n                    },\n                },\n            })\n\n            const [start, end] = ['2020-02-04', '2022-02-04']\n\n            const {\n                series: { data: dataDays },\n            } = await userService.getNetWorthSeries(user.id, start, end, 'days')\n\n            const {\n                series: { data: dataWeeks },\n            } = await userService.getNetWorthSeries(user.id, start, end, 'weeks')\n\n            expect(dataWeeks.length).toBeLessThan(dataDays.length)\n\n            // ensure the start/end of each data set is the same\n            expect(dataWeeks[0]).toEqual(dataDays[0])\n            expect(dataWeeks[dataWeeks.length - 1]).toEqual(dataDays[dataDays.length - 1])\n\n            // ensure the data is the same for shared dates\n            dataWeeks.slice(1, -2).forEach((dataWeek) => {\n                const dataDay = dataDays.find((d) => d.date === dataWeek.date)\n                console.debug('date', dataWeek.date)\n                expect(dataWeek).toEqual(dataDay)\n            })\n        })\n    })\n\n    describe('multi-account net worth', () => {\n        const defaultAccountDetails = {\n            type: 'OTHER_ASSET' as AccountType,\n            provider: 'user' as AccountProvider,\n            currencyCode: 'USD',\n            categoryProvider: 'cash' as AccountCategory,\n        }\n\n        const expectedNetWorths: [string, number][] = [\n            ['2021-12-30', 1841],\n            ['2021-12-31', 1841],\n            ['2022-01-01', 1841],\n            ['2022-01-02', 1861],\n            ['2022-01-03', 1861],\n            ['2022-01-04', 1891],\n            ['2022-01-05', 1921],\n        ]\n\n        beforeEach(async () => {\n            await prisma.user.update({\n                where: { id: user.id },\n                data: {\n                    accounts: {\n                        create: [\n                            // no balances, updated before range\n                            {\n                                ...defaultAccountDetails,\n                                name: '1',\n                                updatedAt: date('2022-01-01'),\n                                currentBalanceProvider: 100,\n                            },\n                            // no balances, updated within range\n                            {\n                                ...defaultAccountDetails,\n                                name: '11',\n                                updatedAt: date('2022-01-04'),\n                                currentBalanceProvider: 110,\n                            },\n                            // no balances, updated after range\n                            {\n                                ...defaultAccountDetails,\n                                name: '111',\n                                updatedAt: date('2022-01-06'),\n                                currentBalanceProvider: 111,\n                            },\n                            // balances before range\n                            {\n                                ...defaultAccountDetails,\n                                name: '2',\n                                updatedAt: date('2022-01-01'),\n                                currentBalanceProvider: 200,\n                                balances: {\n                                    create: [\n                                        { date: date('2022-01-01'), balance: 200 },\n                                        { date: date('2022-01-02'), balance: 220 },\n                                    ],\n                                },\n                            },\n                            // balances within range\n                            {\n                                ...defaultAccountDetails,\n                                name: '3',\n                                updatedAt: date('2022-01-01'),\n                                currentBalanceProvider: 300,\n                                balances: {\n                                    create: [\n                                        { date: date('2022-01-02'), balance: 330 },\n                                        { date: date('2022-01-04'), balance: 360 },\n                                        { date: date('2022-01-05'), balance: 390 },\n                                    ],\n                                },\n                            },\n                            // balances after range\n                            {\n                                ...defaultAccountDetails,\n                                name: '4',\n                                updatedAt: date('2022-01-01'),\n                                currentBalanceProvider: 400,\n                                balances: {\n                                    create: [\n                                        { date: date('2022-01-06'), balance: 440 },\n                                        { date: date('2022-01-07'), balance: 480 },\n                                    ],\n                                },\n                            },\n                            // balances before + after range\n                            {\n                                ...defaultAccountDetails,\n                                name: '5',\n                                updatedAt: date('2022-01-01'),\n                                currentBalanceProvider: 500,\n                                balances: {\n                                    create: [\n                                        { date: date('2022-01-02'), balance: 550 },\n                                        { date: date('2022-01-06'), balance: 599 },\n                                    ],\n                                },\n                            },\n                        ],\n                    },\n                },\n            })\n        })\n\n        it('properly calculates net worth series', async () => {\n            const {\n                series: { data },\n            } = await userService.getNetWorthSeries(user.id, '2021-12-30', '2022-01-05')\n\n            expect(data).toHaveLength(7)\n\n            expectedNetWorths.forEach(([date, netWorth], idx) => {\n                expect(data[idx]).toMatchObject<Partial<typeof data[0]>>({\n                    date,\n                    netWorth: new Prisma.Decimal(netWorth),\n                })\n            })\n        })\n\n        it('properly calculates net worth for a single date', async () => {\n            for (const [date, netWorth] of expectedNetWorths) {\n                const data = await userService.getNetWorth(user.id, date)\n\n                expect(data).toMatchObject({\n                    date,\n                    netWorth: new Prisma.Decimal(netWorth),\n                })\n            }\n        })\n    })\n})\n"
  },
  {
    "path": "apps/server/src/app/__tests__/prisma.integration.spec.ts",
    "content": "import type { Account, User } from '@prisma/client'\nimport { PrismaClient, Prisma } from '@prisma/client'\nimport { DateTime } from 'luxon'\nimport { resetUser } from './utils/user'\n\nconst prisma = new PrismaClient()\n\ndescribe('prisma', () => {\n    let user: User\n    let account: Account\n\n    beforeEach(async () => {\n        // Clears old user and data, creates new user\n        user = await resetUser()\n        account = await prisma.account.create({\n            data: {\n                userId: user.id,\n                type: 'OTHER_ASSET',\n                provider: 'user',\n                name: 'TEST_DATA_TYPES',\n                startDate: DateTime.fromISO('2022-09-01').toJSDate(),\n                currentBalanceProvider: 123,\n            },\n        })\n    })\n\n    describe('$executeRaw', () => {\n        it('can serialize list of partially nullable values', async () => {\n            const count = await prisma.$executeRaw`\n              UPDATE account AS a\n              SET\n                available_balance_provider = u.available_balance_provider\n              FROM (\n                VALUES\n                  ${Prisma.join(\n                      [\n                          [account.id, null],\n                          [account.id, new Prisma.Decimal(12.34)],\n                          [account.id, null],\n                      ].map(([accountId, availableBalanceProvider]) => {\n                          return Prisma.sql`(\n                            ${accountId},\n                            ${availableBalanceProvider}\n                          )`\n                      })\n                  )}\n              ) AS u(id, available_balance_provider)\n              WHERE\n                a.id = u.id;\n            `\n\n            expect(count).toBe(1)\n        })\n\n        it(`can serialize list of all nullable values w/ casts`, async () => {\n            const count = await prisma.$executeRaw`\n              UPDATE account AS a\n              SET\n              available_balance_provider = u.available_balance_provider\n              FROM (\n                VALUES\n                  ${Prisma.join(\n                      [[account.id, null]].map(([accountId, availableBalanceProvider]) => {\n                          return Prisma.sql`(\n                            ${accountId},\n                            ${availableBalanceProvider}::numeric\n                          )`\n                      })\n                  )}\n              ) AS u(id, available_balance_provider)\n              WHERE\n                a.id = u.id;\n            `\n\n            expect(count).toBe(1)\n        })\n\n        it(`cannot serialize list of all nullable values w/o casts`, async () => {\n            expect(prisma.$executeRaw`\n              UPDATE account AS a\n              SET\n                available_balance_provider = u.available_balance_provider\n              FROM (\n                VALUES\n                  ${Prisma.join(\n                      [[account.id, null]].map(([accountId, availableBalanceProvider]) => {\n                          return Prisma.sql`(\n                            ${accountId},\n                            ${availableBalanceProvider}\n                          )`\n                      })\n                  )}\n              ) AS u(id, available_balance_provider)\n              WHERE\n                a.id = u.id;\n            `).rejects.toThrow()\n        })\n    })\n\n    describe('$queryRaw', () => {\n        it('can serialize function parameters w/ casts', async () => {\n            const rows = await prisma.$queryRaw<any[]>`\n              SELECT\n                a.*,\n                abg.*\n              FROM\n                account a,\n                account_balances_gapfilled(\n                  ${'2022-09-01'}::date,\n                  ${'2022-10-01'}::date,\n                  ${'1 day'}::interval,\n                  ${[account.id]}::int[]\n                ) abg\n              WHERE\n                a.id = ${account.id}\n            `\n\n            expect(rows.length).toBeGreaterThan(0)\n        })\n\n        it('cannot serialize function parameters w/o casts', async () => {\n            expect(prisma.$queryRaw<any[]>`\n              SELECT\n                a.*,\n                abg.*\n              FROM\n                account a,\n                account_balances_gapfilled(\n                  ${'2022-09-01'},\n                  ${'2022-10-01'},\n                  ${'1 day'}::interval,\n                  ${[account.id]}\n                ) abg\n              WHERE\n                a.id = ${account.id}\n            `).rejects.toThrow()\n        })\n\n        it('can deserialize data types', async () => {\n            const [data] = await prisma.$queryRaw<Record<string, any>[]>`\n              SELECT\n                created_at, -- datetime\n                current_balance, -- decimal\n                COALESCE(available_balance, 0) AS \"available_balance\", -- decimal\n                start_date, -- date\n                (SELECT COUNT(*) FROM account) AS \"count\" -- int4\n              FROM\n                account\n              WHERE\n                id = ${account.id}\n            `\n\n            expect(data.created_at).toBeInstanceOf(Date)\n            expect(data.current_balance).toBeInstanceOf(Prisma.Decimal)\n            expect(data.available_balance).toBeInstanceOf(Prisma.Decimal)\n            expect(data.start_date).toBeInstanceOf(Date)\n            expect(typeof data.count).toBe('bigint')\n        })\n    })\n})\n"
  },
  {
    "path": "apps/server/src/app/__tests__/stripe.integration.spec.ts",
    "content": "import type { User } from '@prisma/client'\nimport { PrismaClient } from '@prisma/client'\nimport { createLogger, transports } from 'winston'\nimport { AccountQueryService, UserService } from '@maybe-finance/server/features'\nimport { resetUser } from './utils/user'\nimport stripe from '../lib/stripe'\nimport { PgService } from '@maybe-finance/server/shared'\nimport { DateTime } from 'luxon'\n\nconst prisma = new PrismaClient()\nconst logger = createLogger({ transports: new transports.Console() })\n\nconst userService = new UserService(\n    logger,\n    prisma,\n    new AccountQueryService(logger, new PgService(logger)),\n    {} as any,\n    {} as any,\n    {} as any,\n    stripe\n)\n\nlet user: User\n\nbeforeEach(async () => {\n    user = await resetUser()\n})\n\nafterAll(async () => {\n    await prisma.$disconnect()\n})\n\ndescribe('stripe subscriptions', () => {\n    it(\"derives a new user's subscription status\", async () => {\n        const subscription = await userService.getSubscription(user.id)\n        const { trialEnd, ...rest } = subscription\n\n        // Default 14-day trial\n        expect(Math.round(trialEnd?.diffNow('days').days ?? 0)).toEqual(14)\n\n        expect(rest).toEqual({\n            subscribed: true,\n            trialing: true,\n            canceled: false,\n\n            currentPeriodEnd: null,\n            cancelAt: null,\n        })\n    })\n\n    it(\"derives a lapsed trial user's subscription status\", async () => {\n        const trialEnd = DateTime.now().minus({ days: 1 })\n\n        await prisma.user.update({\n            where: { id: user.id },\n            data: {\n                trialEnd: trialEnd.toJSDate(),\n            },\n        })\n\n        expect(await userService.getSubscription(user.id)).toEqual({\n            subscribed: false,\n            trialing: false,\n            canceled: false,\n\n            currentPeriodEnd: null,\n            trialEnd: trialEnd,\n            cancelAt: null,\n        })\n    })\n\n    it(\"derives a paying user's subscription status\", async () => {\n        const currentPeriodEnd = DateTime.now().plus({ days: 30 })\n\n        await prisma.user.update({\n            where: { id: user.id },\n            data: {\n                stripeCancelAt: null,\n                stripeCurrentPeriodEnd: currentPeriodEnd.toJSDate(),\n                stripePriceId: 'price_test',\n                stripeSubscriptionId: 'sub_test',\n                trialEnd: null,\n            },\n        })\n\n        expect(await userService.getSubscription(user.id)).toEqual({\n            subscribed: true,\n            trialing: false,\n            canceled: false,\n\n            currentPeriodEnd: currentPeriodEnd,\n            trialEnd: null,\n            cancelAt: null,\n        })\n    })\n})\n"
  },
  {
    "path": "apps/server/src/app/__tests__/test-data/portfolio-1/holdings.csv",
    "content": "ticker,qty,costBasis,value\nXYZ,31,178.05,5952\nDEF,60,38.83,2220"
  },
  {
    "path": "apps/server/src/app/__tests__/test-data/portfolio-1/securities.csv",
    "content": "date,ticker,price\n2021-12-30,XYZ,198\n2021-12-31,XYZ,198\n2022-01-01,XYZ,198\n2022-01-02,XYZ,200\n2022-01-03,XYZ,205\n2022-01-04,XYZ,210\n2022-01-05,XYZ,210\n2022-01-06,XYZ,210\n2022-01-07,XYZ,210\n2022-01-08,XYZ,210\n2022-01-09,XYZ,210\n2022-01-10,XYZ,210\n2022-01-11,XYZ,210\n2022-01-12,XYZ,210\n2022-01-13,XYZ,210\n2022-01-14,XYZ,210\n2022-01-15,XYZ,210\n2022-01-16,XYZ,210\n2022-01-17,XYZ,210\n2022-01-18,XYZ,210\n2022-01-19,XYZ,210\n2022-01-20,XYZ,210\n2022-01-21,XYZ,210\n2022-01-22,XYZ,210\n2022-01-23,XYZ,210\n2022-01-24,XYZ,212\n2022-01-25,XYZ,212\n2022-01-26,XYZ,212\n2022-01-27,XYZ,212\n2022-01-28,XYZ,212\n2022-01-29,XYZ,212\n2022-01-30,XYZ,212\n2022-01-31,XYZ,212\n2022-02-01,XYZ,204\n2022-02-02,XYZ,202\n2022-02-03,XYZ,198\n2022-02-04,XYZ,190\n2022-02-05,XYZ,180\n2022-02-06,XYZ,175\n2022-02-07,XYZ,178\n2022-02-08,XYZ,177\n2022-02-09,XYZ,177\n2022-02-10,XYZ,177\n2022-02-11,XYZ,177\n2022-02-12,XYZ,177\n2022-02-13,XYZ,177\n2022-02-14,XYZ,177\n2022-02-15,XYZ,177\n2022-02-16,XYZ,177\n2022-02-17,XYZ,177\n2022-02-18,XYZ,177\n2022-02-19,XYZ,177\n2022-02-20,XYZ,177\n2022-02-21,XYZ,177\n2022-02-22,XYZ,177\n2022-02-23,XYZ,177\n2022-02-24,XYZ,179\n2022-02-25,XYZ,179\n2022-02-26,XYZ,179\n2022-02-27,XYZ,179\n2022-02-28,XYZ,179\n2022-03-01,XYZ,179\n2022-03-02,XYZ,179\n2022-03-03,XYZ,179\n2022-03-04,XYZ,179\n2022-03-05,XYZ,179\n2022-03-06,XYZ,179\n2022-03-07,XYZ,179\n2022-03-08,XYZ,179\n2022-03-09,XYZ,180\n2022-03-10,XYZ,185\n2022-03-11,XYZ,190\n2022-03-12,XYZ,192\n2022-03-13,XYZ,192\n2022-03-14,XYZ,192\n2022-03-15,XYZ,192\n2022-03-16,XYZ,192\n2022-03-17,XYZ,192\n2022-03-18,XYZ,192\n2022-03-19,XYZ,192\n2022-03-20,XYZ,192\n2022-03-21,XYZ,192\n2022-03-22,XYZ,192\n2022-03-23,XYZ,192\n2022-03-24,XYZ,192\n2022-03-25,XYZ,192\n2022-03-26,XYZ,192\n2022-03-27,XYZ,192\n2022-03-28,XYZ,192\n2022-03-29,XYZ,192\n2022-03-30,XYZ,192\n2022-03-31,XYZ,192\n2022-04-01,XYZ,192\n2021-12-30,DEF,30\n2021-12-31,DEF,30\n2022-01-01,DEF,30\n2022-01-02,DEF,31\n2022-01-03,DEF,32\n2022-01-04,DEF,33\n2022-01-05,DEF,33\n2022-01-06,DEF,33\n2022-01-07,DEF,33\n2022-01-08,DEF,33\n2022-01-09,DEF,33\n2022-01-10,DEF,33\n2022-01-11,DEF,33\n2022-01-12,DEF,34\n2022-01-13,DEF,36\n2022-01-14,DEF,36\n2022-01-15,DEF,36\n2022-01-16,DEF,36\n2022-01-17,DEF,36\n2022-01-18,DEF,36\n2022-01-19,DEF,36\n2022-01-20,DEF,36\n2022-01-21,DEF,36\n2022-01-22,DEF,36\n2022-01-23,DEF,36\n2022-01-24,DEF,36\n2022-01-25,DEF,36\n2022-01-26,DEF,34\n2022-01-27,DEF,34\n2022-01-28,DEF,34\n2022-01-29,DEF,34\n2022-01-30,DEF,34\n2022-01-31,DEF,34\n2022-02-01,DEF,34\n2022-02-02,DEF,34\n2022-02-03,DEF,34\n2022-02-04,DEF,34\n2022-02-05,DEF,34\n2022-02-06,DEF,34\n2022-02-07,DEF,34\n2022-02-08,DEF,34\n2022-02-09,DEF,34\n2022-02-10,DEF,34\n2022-02-11,DEF,34\n2022-02-12,DEF,34\n2022-02-13,DEF,34\n2022-02-14,DEF,34\n2022-02-15,DEF,34\n2022-02-16,DEF,34\n2022-02-17,DEF,37\n2022-02-18,DEF,37\n2022-02-19,DEF,37\n2022-02-20,DEF,37\n2022-02-21,DEF,37\n2022-02-22,DEF,37\n2022-02-23,DEF,37\n2022-02-24,DEF,37\n2022-02-25,DEF,37\n2022-02-26,DEF,37\n2022-02-27,DEF,37\n2022-02-28,DEF,38\n2022-03-01,DEF,38\n2022-03-02,DEF,39\n2022-03-03,DEF,39\n2022-03-04,DEF,39\n2022-03-05,DEF,39\n2022-03-06,DEF,39\n2022-03-07,DEF,39\n2022-03-08,DEF,39\n2022-03-09,DEF,40\n2022-03-10,DEF,41\n2022-03-11,DEF,41\n2022-03-12,DEF,40\n2022-03-13,DEF,40\n2022-03-14,DEF,40\n2022-03-15,DEF,36\n2022-03-16,DEF,36\n2022-03-17,DEF,36\n2022-03-18,DEF,36\n2022-03-19,DEF,36\n2022-03-20,DEF,36\n2022-03-21,DEF,36\n2022-03-22,DEF,36\n2022-03-23,DEF,36\n2022-03-24,DEF,34\n2022-03-25,DEF,34\n2022-03-26,DEF,34\n2022-03-27,DEF,34\n2022-03-28,DEF,34\n2022-03-29,DEF,37\n2022-03-30,DEF,37\n2022-03-31,DEF,37\n2022-04-01,DEF,37\n2021-12-30,ABC,79.5\n2021-12-31,ABC,79.5\n2022-01-01,ABC,80\n2022-01-02,ABC,82\n2022-01-03,ABC,82.5\n2022-01-04,ABC,83\n2022-01-05,ABC,83.5\n2022-01-06,ABC,84\n2022-01-07,ABC,84.5\n2022-01-08,ABC,85\n2022-01-09,ABC,85.5\n2022-01-10,ABC,86\n2022-01-11,ABC,86.5\n2022-01-12,ABC,87\n2022-01-13,ABC,86.8\n2022-01-14,ABC,86.6\n2022-01-15,ABC,86.4\n2022-01-16,ABC,86.2\n2022-01-17,ABC,86\n2022-01-18,ABC,85.8\n2022-01-19,ABC,85.6\n2022-01-20,ABC,85.4\n2022-01-21,ABC,85.2\n2022-01-22,ABC,85\n2022-01-23,ABC,84.8\n2022-01-24,ABC,84.6\n2022-01-25,ABC,84.4\n2022-01-26,ABC,84.2\n2022-01-27,ABC,84\n2022-01-28,ABC,83.8\n2022-01-29,ABC,83.6\n2022-01-30,ABC,83.4\n2022-01-31,ABC,83.2\n2022-02-01,ABC,83\n2022-02-02,ABC,82.8\n2022-02-03,ABC,82.6\n2022-02-04,ABC,82.4\n2022-02-05,ABC,82.2\n2022-02-06,ABC,82\n2022-02-07,ABC,81.8\n2022-02-08,ABC,81.6\n2022-02-09,ABC,81.4\n2022-02-10,ABC,81.2\n2022-02-11,ABC,81\n2022-02-12,ABC,80.8\n2022-02-13,ABC,80.6\n2022-02-14,ABC,80.4\n2022-02-15,ABC,80.2\n2022-02-16,ABC,80\n2022-02-17,ABC,79.8\n2022-02-18,ABC,79.6\n2022-02-19,ABC,79.4\n2022-02-20,ABC,79.2\n2022-02-21,ABC,79\n2022-02-22,ABC,78.8\n2022-02-23,ABC,78.6\n2022-02-24,ABC,78.8\n2022-02-25,ABC,79\n2022-02-26,ABC,79.2\n2022-02-27,ABC,79.4\n2022-02-28,ABC,79.6\n2022-03-01,ABC,79.8\n2022-03-02,ABC,80\n2022-03-03,ABC,80.2\n2022-03-04,ABC,80.4\n2022-03-05,ABC,80.6\n2022-03-06,ABC,80.8\n2022-03-07,ABC,81\n2022-03-08,ABC,81.2\n2022-03-09,ABC,81.4\n2022-03-10,ABC,81.6\n2022-03-11,ABC,81.8\n2022-03-12,ABC,82\n2022-03-13,ABC,82.2\n2022-03-14,ABC,82.4\n2022-03-15,ABC,82.6\n2022-03-16,ABC,82.8\n2022-03-17,ABC,83\n2022-03-18,ABC,83.2\n2022-03-19,ABC,83.4\n2022-03-20,ABC,83.6\n2022-03-21,ABC,83.8\n2022-03-22,ABC,84\n2022-03-23,ABC,84.2\n2022-03-24,ABC,84.4\n2022-03-25,ABC,84.6\n2022-03-26,ABC,84.8\n2022-03-27,ABC,85\n2022-03-28,ABC,85.2\n2022-03-29,ABC,85.4\n2022-03-30,ABC,85.6\n2022-03-31,ABC,85.8\n2022-04-01,ABC,85.8\n"
  },
  {
    "path": "apps/server/src/app/__tests__/test-data/portfolio-1/transactions.csv",
    "content": "date,type,ticker,qty\n2021-12-31,DEPOSIT,USD,-5000\n2022-01-02,BUY,ABC,10\n2022-01-04,SELL,ABC,-10\n2022-01-10,BUY,DEF,10\n2022-02-05,WITHDRAW,USD,1000\n2022-02-06,BUY,XYZ,16\n2022-02-07,DIVIDEND,XYZ,-0.05618\n2022-03-08,DEPOSIT,USD,-4000\n2022-03-09,BUY,XYZ,25\n2022-03-11,SELL,XYZ,-10\n2022-03-12,BUY,DEF,50"
  },
  {
    "path": "apps/server/src/app/__tests__/test-data/portfolio-2/holdings.csv",
    "content": "ticker,qty,costBasis,value\nABC,10,50,900"
  },
  {
    "path": "apps/server/src/app/__tests__/test-data/portfolio-2/securities.csv",
    "content": "date,ticker,price\n2022-08-01,ABC,79.8\n2022-08-02,ABC,80\n2022-08-03,ABC,80.2\n2022-08-04,ABC,80.4\n2022-08-05,ABC,80.6\n2022-08-06,ABC,80.8\n2022-08-07,ABC,81\n2022-08-08,ABC,81.2\n2022-08-09,ABC,81.4\n2022-08-10,ABC,81.6\n2022-08-11,ABC,81.8\n2022-08-12,ABC,82\n2022-08-13,ABC,82.2\n2022-08-14,ABC,82.4\n2022-08-15,ABC,82.6\n2022-08-16,ABC,82.8\n2022-08-17,ABC,83\n2022-08-18,ABC,83.2\n2022-08-19,ABC,83.4\n2022-08-20,ABC,83.6\n2022-08-21,ABC,83.8\n2022-08-22,ABC,84\n2022-08-23,ABC,88.2\n2022-08-24,ABC,90"
  },
  {
    "path": "apps/server/src/app/__tests__/test-data/portfolio-2/transactions.csv",
    "content": "date,type,ticker,qty\n2022-08-10,DEPOSIT,USD,-2000\n2022-08-12,DEPOSIT,USD,-5000\n2022-08-15,DEPOSIT,USD,-2000\n2022-08-18,WITHDRAW,USD,2000\n2022-08-22,DEPOSIT,USD,-1000\n"
  },
  {
    "path": "apps/server/src/app/__tests__/utils/account.ts",
    "content": "import type { PrismaClient, User } from '@prisma/client'\nimport { InvestmentTransactionCategory, Prisma } from '@prisma/client'\nimport _ from 'lodash'\nimport { DateTime } from 'luxon'\nimport { parseCsv } from './csv'\nimport { join } from 'path'\n\nconst date = (s: string) => DateTime.fromISO(s, { zone: 'utc' }).toJSDate()\n\nconst portfolios: Record<string, Partial<Prisma.AccountUncheckedCreateInput>> = {\n    'portfolio-1': {\n        startDate: date('2021-12-31'),\n        currentBalanceProvider: 8462,\n        availableBalanceProvider: 290,\n    },\n    'portfolio-2': {\n        startDate: date('2022-07-01'),\n        currentBalanceProvider: 26000,\n        availableBalanceProvider: 8000,\n    },\n}\n\nconst investmentTransactionCategoryByType: Record<string, InvestmentTransactionCategory> = {\n    BUY: InvestmentTransactionCategory.buy,\n    SELL: InvestmentTransactionCategory.sell,\n    DIVIDEND: InvestmentTransactionCategory.dividend,\n    DEPOSIT: InvestmentTransactionCategory.transfer,\n    WITHDRAW: InvestmentTransactionCategory.transfer,\n}\n\nexport async function createTestInvestmentAccount(\n    prisma: PrismaClient,\n    user: User,\n    portfolio: 'portfolio-1' | 'portfolio-2'\n) {\n    const transactionsData = await parseCsv<'date' | 'type' | 'ticker' | 'qty'>(\n        join(__dirname, `../test-data/${portfolio}/transactions.csv`)\n    )\n    const securitiesData = await parseCsv<'date' | 'ticker' | 'price'>(\n        join(__dirname, `../test-data/${portfolio}/securities.csv`)\n    )\n    const holdingsData = await parseCsv<'ticker' | 'qty' | 'costBasis' | 'value'>(\n        join(__dirname, `../test-data/${portfolio}/holdings.csv`)\n    )\n\n    const [, ...securities] = await prisma.$transaction([\n        prisma.security.deleteMany({\n            where: {\n                symbol: {\n                    in: _(securitiesData)\n                        .map((s) => s.ticker)\n                        .uniq()\n                        .value(),\n                },\n                name: {\n                    startsWith: 'TEST[',\n                },\n            },\n        }),\n        ..._(securitiesData)\n            .groupBy((s) => s.ticker)\n            .map((rows, ticker) =>\n                prisma.security.create({\n                    data: {\n                        symbol: ticker,\n                        name: `TEST[${ticker}]`,\n                        pricing: {\n                            createMany: {\n                                data: rows\n                                    .filter((s) => s.ticker === ticker)\n                                    .map((s) => ({\n                                        date: date(s.date),\n                                        priceClose: s.price,\n                                    })),\n                            },\n                        },\n                    },\n                })\n            )\n            .value(),\n    ])\n\n    return prisma.account.create({\n        data: {\n            ...portfolios[portfolio],\n            userId: user.id,\n            name: portfolio,\n            type: 'INVESTMENT',\n            provider: 'user',\n            holdings: {\n                create: holdingsData.map((h) => {\n                    const security = securities.find((s) => s.symbol === h.ticker)\n                    return {\n                        security: security\n                            ? { connect: { id: security.id } }\n                            : { create: { symbol: h.ticker } },\n                        quantity: h.qty,\n                        costBasisProvider: new Prisma.Decimal(h.costBasis).times(h.qty),\n                        value: h.value,\n                    }\n                }),\n            },\n            investmentTransactions: {\n                createMany: {\n                    data: transactionsData.map((it) => {\n                        const price = securitiesData.find(\n                            (s) => s.date === it.date && s.ticker === it.ticker\n                        )?.price\n\n                        return {\n                            securityId: securities.find((s) => it.ticker === s.symbol)?.id,\n                            date: date(it.date),\n                            name: `${it.type} ${it.ticker}`,\n                            amount: price ? new Prisma.Decimal(price).times(it.qty) : it.qty,\n                            quantity: price ? it.qty : 0,\n                            price: price ?? 0,\n                            category:\n                                investmentTransactionCategoryByType[it.type] ??\n                                InvestmentTransactionCategory.other,\n                        }\n                    }),\n                },\n            },\n        },\n    })\n}\n"
  },
  {
    "path": "apps/server/src/app/__tests__/utils/axios.ts",
    "content": "import type { AxiosResponse } from 'axios'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { superjson } from '@maybe-finance/shared'\nimport Axios from 'axios'\nimport { encode } from 'next-auth/jwt'\n\nexport async function getAxiosClient() {\n    const baseUrl = 'http://127.0.0.1:53333/v1'\n    const jwt = await encode({\n        maxAge: 1 * 24 * 60 * 60,\n        secret: process.env.NEXTAUTH_SECRET || 'CHANGE_ME',\n        token: {\n            sub: '__TEST_USER_ID__',\n            user: '__TEST_USER_ID__',\n            'https://maybe.co/email': 'REPLACE_THIS',\n            firstName: 'REPLACE_THIS',\n            lastName: 'REPLACE_THIS',\n            name: 'REPLACE_THIS',\n        },\n    })\n\n    const defaultHeaders = {\n        'Content-Type': 'application/json',\n        'Access-Control-Allow-Credentials': true,\n        Authorization: `Bearer ${jwt}`,\n    }\n    const axiosOptions = {\n        baseURL: baseUrl,\n        headers: defaultHeaders,\n    }\n\n    const axios = Axios.create({\n        ...axiosOptions,\n        validateStatus: () => true, // Tests should determine whether status is correct, not Axios\n    })\n\n    axios.interceptors.response.use((response: AxiosResponse<SharedType.BaseResponse>) => {\n        if (response.data) {\n            const payload = response.data\n\n            if ('data' in payload) {\n                return { ...response, data: superjson.deserialize(payload.data) }\n            } else {\n                // Don't deserialize an error response\n                return response\n            }\n        } else {\n            // Don't deserialize a No Content response (i.e. 204)\n            return response\n        }\n    })\n\n    axios.interceptors.request.use(async (axiosRequestConfig) => {\n        // By default, serialize all requests to the format: { json, meta }\n        const serializedReqObj = superjson.serialize(axiosRequestConfig.data)\n\n        return { ...axiosRequestConfig, data: serializedReqObj }\n    })\n\n    return axios\n}\n"
  },
  {
    "path": "apps/server/src/app/__tests__/utils/csv.ts",
    "content": "import * as csv from '@fast-csv/parse'\n\nexport async function parseCsv<Keys extends string>(path: string): Promise<Record<Keys, string>[]> {\n    return new Promise((resolve) => {\n        const stream = csv.parseFile(path, { headers: true })\n        const data: any = []\n        stream.on('data', (row) => data.push(row))\n        stream.on('end', () => resolve(data))\n    })\n}\n"
  },
  {
    "path": "apps/server/src/app/__tests__/utils/server.ts",
    "content": "import type { Server } from 'http'\nimport app from '../../app'\n\nlet server: Server\n\nexport const startServer = () => {\n    return new Promise((resolve, _reject) => {\n        server = app.listen(53333, () => {\n            resolve(true)\n        })\n    })\n}\n\nexport const stopServer = () => {\n    return new Promise((resolve, _reject) => {\n        server.close(() => {\n            resolve(true)\n        })\n    })\n}\n"
  },
  {
    "path": "apps/server/src/app/__tests__/utils/user.ts",
    "content": "import type { User } from '@prisma/client'\nimport prisma from '../../lib/prisma'\n\nconst EMAIL = 'test@example.com'\n\nexport async function resetUser(authId = '__TEST_USER_ID__'): Promise<User> {\n    const [_, [user]] = await prisma.$transaction([\n        prisma.$executeRaw`DELETE FROM \"user\" WHERE auth_id=${authId}`,\n        prisma.$queryRaw<\n            [User]\n        >`INSERT INTO \"user\" (auth_id, email) VALUES (${authId}, ${EMAIL}) ON CONFLICT DO NOTHING RETURNING *`,\n    ])\n\n    return user\n}\n"
  },
  {
    "path": "apps/server/src/app/admin/views/pages/dashboard.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <%- include('../partials/head'); %>\n    <body>\n        <div class=\"container\">\n            <img src=\"maybe.svg\" alt=\"Maybe Logo\" width=\"50px\" height=\"50px\" />\n            <h3>Maybe Finance Admin Dashboard</h3>\n            <p style=\"margin-bottom: 30px\">Welcome, <%= user %> (<%= role %>)</p>\n            <div class=\"links\">\n                <a class=\"btn\" href=\"/admin/bullmq\">BullMQ Dashboard</a>\n                <a class=\"btn\" href=\"/admin/logout\">Logout</a>\n            </div>\n        </div>\n    </body>\n</html>\n"
  },
  {
    "path": "apps/server/src/app/admin/views/pages/index.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <%- include('../partials/head'); %>\n    <body>\n        <div class=\"container\">\n            <% if (error) { %>\n            <div class=\"unauthorized\">\n                <p>Unauthorized</p>\n                <p>If you are a Maybe Employee, please request admin access</p>\n            </div>\n            <% } %>\n            <img src=\"maybe.svg\" alt=\"Maybe Logo\" width=\"50px\" height=\"50px\" />\n            <h3>Maybe Finance</h3>\n            <button class=\"btn\" onclick=\"login()\">Admin Login</button>\n            <% if(error){ %>\n            <button class=\"btn\" onclick=\"logout()\" style=\"margin-top: 20px\">Logout</button>\n            <% } %>\n        </div>\n    </body>\n</html>\n"
  },
  {
    "path": "apps/server/src/app/admin/views/partials/head.ejs",
    "content": "<head>\n    <meta charset=\"UTF-8\" />\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <link rel=\"shortcut icon\" href=\"maybe.svg\" type=\"image/x-icon\" />\n    <link rel=\"stylesheet\" href=\"styles.css\" />\n    <script src=\"script.js\"></script>\n    <title>Maybe Finance</title>\n</head>\n"
  },
  {
    "path": "apps/server/src/app/app.ts",
    "content": "import type { RequestHandler } from 'express'\nimport express from 'express'\nimport cors from 'cors'\nimport morgan from 'morgan'\nimport * as Sentry from '@sentry/node'\nimport * as SentryTracing from '@sentry/tracing'\nimport * as trpcExpress from '@trpc/server/adapters/express'\nimport { appRouter, createTRPCContext } from './trpc'\n/**\n * In Express 4.x, asynchronous errors are NOT automatically passed to next().  This middleware is a small\n * wrapper around Express that enables automatic async error handling\n *\n * Benefit: Eliminates the need for try / catch blocks in routes (i.e. `next(err)` will automatically be called on failed Promise)\n *\n * When stable Express 5.x is released, this won't be necessary - https://github.com/expressjs/express/issues/4543#issuecomment-789256044\n */\nimport 'express-async-errors'\nimport logger from './lib/logger'\nimport prisma from './lib/prisma'\nimport {\n    defaultErrorHandler,\n    validateAuthJwt,\n    superjson,\n    authErrorHandler,\n    maintenance,\n    identifySentryUser,\n    devOnly,\n} from './middleware'\nimport {\n    usersRouter,\n    accountsRouter,\n    connectionsRouter,\n    webhooksRouter,\n    accountRollupRouter,\n    valuationsRouter,\n    institutionsRouter,\n    tellerRouter,\n    transactionsRouter,\n    holdingsRouter,\n    securitiesRouter,\n    plansRouter,\n    toolsRouter,\n    publicRouter,\n    e2eRouter,\n    adminRouter,\n} from './routes'\nimport env from '../env'\n\nconst app = express()\n\n// put health check before maintenance and other middleware\napp.get('/health', (_req, res) => {\n    res.status(200).json({ status: 'OK' })\n})\n\nif (process.env.NODE_ENV !== 'test') {\n    maintenance(app)\n}\n\n// Mostly defaults recommended by quickstart\n// - https://docs.sentry.io/platforms/node/guides/express/\n// - https://docs.sentry.io/platforms/node/guides/express/performance/\nSentry.init({\n    dsn: env.NX_SENTRY_DSN,\n    environment: env.NX_SENTRY_ENV,\n    maxValueLength: 8196,\n    integrations: [\n        new Sentry.Integrations.Http({ tracing: true }),\n        new SentryTracing.Integrations.Express({ app }),\n        new SentryTracing.Integrations.Postgres(),\n        new SentryTracing.Integrations.Prisma({ client: prisma }),\n    ],\n    tracesSampler: (ctx) => {\n        return ctx.request?.method === 'OPTIONS' ? false : ctx.parentSampled ?? true\n    },\n})\n\napp.use(Sentry.Handlers.requestHandler())\napp.use(Sentry.Handlers.tracingHandler())\n\napp.get('/', (req, res) => {\n    res.render('pages/index', { error: req.query.error })\n})\n\n// TODO: Replace \"admin\" concept from Auth0 with next-auth\n// Only Auth0 users with a role of \"admin\" can view these pages (i.e. Maybe Employees)\napp.use(express.static(__dirname + '/assets'))\n\nconst origin = [env.NX_CLIENT_URL, ...env.NX_CORS_ORIGINS]\nlogger.info(`CORS origins: ${origin}`)\napp.use(cors({ origin, credentials: true }))\napp.options('*', cors() as RequestHandler)\n\napp.set('view engine', 'ejs').set('views', __dirname + '/app/admin/views')\napp.use('/admin', adminRouter)\n\napp.use(\n    morgan(env.NX_MORGAN_LOG_LEVEL, {\n        stream: {\n            write: function (message: string) {\n                logger.http(message.trim()) // Trim because Morgan and Logger both add \\n, so avoid duplicates here\n            },\n        },\n    })\n)\n\n// Stripe webhooks require a raw request body\napp.use('/v1/stripe', express.raw({ type: 'application/json' }))\n\napp.use(express.urlencoded({ extended: true }))\napp.use(express.json())\n\n// =========================================\n//                 API ⬇️\n// =========================================\n\napp.use(\n    '/trpc',\n    validateAuthJwt,\n    trpcExpress.createExpressMiddleware({\n        router: appRouter,\n        createContext: createTRPCContext,\n    })\n)\n\n/**\n * This intercepts the express.json() middleware and modifies both the outgoing and incoming requests\n *\n * It is necessary because our models use Date, BigInt, and other non serializable JSON types\n *\n * Outgoing requests are serialized, and will be in the format { data: { json, meta }, ...rest }\n * Incoming requests are deserialized (client sends a serialized object { json, meta }), and attached to req.body\n */\napp.use(superjson)\n\n// Public routes\n// Keep this route public for Render health checks - https://render.com/docs/deploys#health-checks\napp.get('/v1', (_req, res) => {\n    res.status(200).json({ msg: 'API Running' })\n})\n\napp.get('/debug-sentry', function mainHandler(_req, _res) {\n    throw new Error('Server sentry is working correctly')\n})\n\napp.use('/tools', devOnly, toolsRouter)\n\napp.use('/v1', webhooksRouter)\n\napp.use('/v1', publicRouter)\n\n// All routes AFTER this line are protected via OAuth\napp.use('/v1', validateAuthJwt)\n\n// Private routes\napp.use('/v1/users', usersRouter)\napp.use('/v1/e2e', e2eRouter)\napp.use('/v1/teller', tellerRouter)\napp.use('/v1/accounts', accountsRouter)\napp.use('/v1/account-rollup', accountRollupRouter)\napp.use('/v1/connections', connectionsRouter)\napp.use('/v1/valuations', valuationsRouter)\napp.use('/v1/institutions', institutionsRouter)\napp.use('/v1/transactions', transactionsRouter)\napp.use('/v1/holdings', holdingsRouter)\napp.use('/v1/securities', securitiesRouter)\napp.use('/v1/plans', plansRouter)\n\n// Sentry must be the *first* handler\napp.use(identifySentryUser)\napp.use(Sentry.Handlers.errorHandler())\n\n// Errors will pass through in order listed, these MUST be at the bottom of this server file\napp.use(authErrorHandler) // Handles auth/authz specific errors\napp.use(defaultErrorHandler) // Fallback handler\n\nexport default app\n"
  },
  {
    "path": "apps/server/src/app/lib/ability.ts",
    "content": "import type { Subjects } from '@casl/prisma'\nimport { PrismaAbility, accessibleBy } from '@casl/prisma'\nimport type { AbilityClass } from '@casl/ability'\nimport { AbilityBuilder, ForbiddenError } from '@casl/ability'\nimport type {\n    User,\n    Account,\n    AccountBalance,\n    AccountConnection,\n    Transaction,\n    Valuation,\n    Holding,\n    InvestmentTransaction,\n    Security,\n    SecurityPricing,\n    Institution,\n    ProviderInstitution,\n    Plan,\n} from '@prisma/client'\nimport { AuthUserRole } from '@prisma/client'\n\ntype CRUDActions = 'create' | 'read' | 'update' | 'delete'\ntype AppActions = CRUDActions | 'manage'\n\ntype PrismaSubjects = Subjects<{\n    User: User\n    Account: Account\n    AccountBalance: AccountBalance\n    AccountConnection: AccountConnection\n    Transaction: Transaction\n    Valuation: Valuation\n    Security: Security\n    SecurityPricing: SecurityPricing\n    Holding: Holding\n    InvestmentTransaction: InvestmentTransaction\n    Institution: Institution\n    ProviderInstitution: ProviderInstitution\n    Plan: Omit<Plan, 'events'>\n}>\ntype AppSubjects = PrismaSubjects | 'all'\n\ntype AppAbility = PrismaAbility<[AppActions, AppSubjects]>\n\nexport default function defineAbilityFor(user: (Pick<User, 'id'> & { role: AuthUserRole }) | null) {\n    const { can, build } = new AbilityBuilder(PrismaAbility as AbilityClass<AppAbility>)\n\n    if (user) {\n        if (user.role === AuthUserRole.admin) {\n            can('manage', 'Account')\n            can('manage', 'AccountConnection')\n            can('manage', 'Valuation')\n            can('manage', 'User')\n            can('manage', 'Institution')\n            can('manage', 'Plan')\n            can('manage', 'Holding')\n            can('manage', 'Security')\n        }\n\n        // Account\n        can('create', 'Account')\n        can('read', 'Account', { userId: user.id })\n        can('read', 'Account', { accountConnection: { is: { userId: user.id } } })\n        can('update', 'Account', { userId: user.id })\n        can('update', 'Account', { accountConnection: { is: { userId: user.id } } })\n        can('delete', 'Account', { userId: user.id })\n        can('delete', 'Account', { accountConnection: { is: { userId: user.id } } })\n\n        // Valuation\n        can('create', 'Valuation')\n        can('read', 'Valuation', { account: { is: { userId: user.id } } })\n        can('update', 'Valuation', { account: { is: { userId: user.id } } })\n        can('delete', 'Valuation', { account: { is: { userId: user.id } } })\n\n        // AccountConnection\n        can('create', 'AccountConnection')\n        can('read', 'AccountConnection', { userId: user.id })\n        can('update', 'AccountConnection', { userId: user.id })\n        can('delete', 'AccountConnection', { userId: user.id })\n\n        // User\n        can('read', 'User', { id: user.id })\n        can('update', 'User', { id: user.id })\n        can('delete', 'User', { id: user.id })\n\n        // Institution\n        can('read', 'Institution')\n\n        // Security\n        can('read', 'Security')\n\n        // Transaction\n        can('read', 'Transaction', { account: { is: { userId: user.id } } })\n        can('read', 'Transaction', {\n            account: { is: { accountConnection: { is: { userId: user.id } } } },\n        })\n        can('update', 'Transaction', { account: { is: { userId: user.id } } })\n        can('update', 'Transaction', {\n            account: { is: { accountConnection: { is: { userId: user.id } } } },\n        })\n\n        // Holding\n        can('read', 'Holding', { account: { is: { userId: user.id } } })\n        can('read', 'Holding', {\n            account: { is: { accountConnection: { is: { userId: user.id } } } },\n        })\n        can('update', 'Holding', { account: { is: { userId: user.id } } })\n        can('update', 'Holding', {\n            account: { is: { accountConnection: { is: { userId: user.id } } } },\n        })\n\n        // Plan\n        can('create', 'Plan')\n        can('read', 'Plan', { userId: user.id })\n        can('update', 'Plan', { userId: user.id })\n        can('delete', 'Plan', { userId: user.id })\n    }\n\n    const ability = build()\n\n    return {\n        can: ability.can,\n        throwUnlessCan: (...args: Parameters<AppAbility['can']>) => {\n            ForbiddenError.from(ability).throwUnlessCan(...args)\n        },\n        where: accessibleBy(ability),\n    }\n}\n"
  },
  {
    "path": "apps/server/src/app/lib/email.ts",
    "content": "import { ServerClient as PostmarkServerClient } from 'postmark'\nimport nodemailer from 'nodemailer'\nimport type SMTPTransport from 'nodemailer/lib/smtp-transport'\nimport env from '../../env'\n\nexport function initializeEmailClient() {\n    switch (env.NX_EMAIL_PROVIDER) {\n        case 'postmark':\n            if (env.NX_EMAIL_PROVIDER_API_TOKEN) {\n                return new PostmarkServerClient(env.NX_EMAIL_PROVIDER_API_TOKEN)\n            } else {\n                return undefined\n            }\n\n        case 'smtp':\n            if (\n                !process.env.NX_EMAIL_SMTP_HOST ||\n                !process.env.NX_EMAIL_SMTP_PORT ||\n                !process.env.NX_EMAIL_SMTP_USERNAME ||\n                !process.env.NX_EMAIL_SMTP_PASSWORD\n            ) {\n                return undefined\n            } else {\n                const transportOptions: SMTPTransport.Options = {\n                    host: process.env.NX_EMAIL_SMTP_HOST,\n                    port: Number(process.env.NX_EMAIL_SMTP_PORT),\n                    secure: process.env.NX_EMAIL_SMTP_SECURE === 'true',\n                    auth: {\n                        user: process.env.NX_EMAIL_SMTP_USERNAME,\n                        pass: process.env.NX_EMAIL_SMTP_PASSWORD,\n                    },\n                }\n                return nodemailer.createTransport(transportOptions)\n            }\n        default:\n            return undefined\n    }\n}\n"
  },
  {
    "path": "apps/server/src/app/lib/endpoint.ts",
    "content": "import type { IMarketDataService } from '@maybe-finance/server/shared'\nimport type {\n    IAccountQueryService,\n    IInstitutionService,\n    IInsightService,\n    ISecurityPricingService,\n    IPlanService,\n} from '@maybe-finance/server/features'\nimport {\n    CryptoService,\n    EndpointFactory,\n    QueueService,\n    PgService,\n    PolygonMarketDataService,\n    CacheService,\n    ServerUtil,\n    RedisCacheBackend,\n    BullQueueFactory,\n    InMemoryQueueFactory,\n} from '@maybe-finance/server/shared'\nimport type { Request } from 'express'\nimport Redis from 'ioredis'\nimport {\n    AccountService,\n    AccountConnectionService,\n    AuthUserService,\n    UserService,\n    EmailService,\n    AccountQueryService,\n    ValuationService,\n    InstitutionService,\n    AccountConnectionProviderFactory,\n    BalanceSyncStrategyFactory,\n    ValuationBalanceSyncStrategy,\n    TransactionBalanceSyncStrategy,\n    InvestmentTransactionBalanceSyncStrategy,\n    InstitutionProviderFactory,\n    TellerService,\n    TellerETL,\n    TellerWebhookHandler,\n    InsightService,\n    SecurityPricingService,\n    TransactionService,\n    HoldingService,\n    LoanBalanceSyncStrategy,\n    PlanService,\n    ProjectionCalculator,\n    StripeWebhookHandler,\n} from '@maybe-finance/server/features'\nimport prisma from './prisma'\nimport teller, { getTellerWebhookUrl } from './teller'\nimport stripe from './stripe'\nimport defineAbilityFor from './ability'\nimport env from '../../env'\nimport logger from '../lib/logger'\nimport { initializeEmailClient } from './email'\n\n// shared services\n\nconst redis = new Redis(env.NX_REDIS_URL, {\n    retryStrategy: ServerUtil.redisRetryStrategy({ maxAttempts: 5 }),\n})\n\nexport const queueService = new QueueService(\n    logger.child({ service: 'QueueService' }),\n    process.env.NODE_ENV === 'test'\n        ? new InMemoryQueueFactory()\n        : new BullQueueFactory(logger.child({ service: 'BullQueueFactory' }), env.NX_REDIS_URL)\n)\n\nexport const emailService: EmailService = new EmailService(\n    logger.child({ service: 'EmailService' }),\n    initializeEmailClient(),\n    {\n        from: env.NX_EMAIL_FROM_ADDRESS,\n        replyTo: env.NX_EMAIL_REPLY_TO_ADDRESS,\n    }\n)\n\nconst cryptoService = new CryptoService(env.NX_DATABASE_SECRET)\n\nconst pgService = new PgService(logger.child({ service: 'PgService' }), env.NX_DATABASE_URL)\n\nconst cacheService = new CacheService(\n    logger.child({ service: 'CacheService' }),\n    new RedisCacheBackend(redis)\n)\n\nconst marketDataService: IMarketDataService = new PolygonMarketDataService(\n    logger.child({ service: 'PolygonMarketDataService' }),\n    env.NX_POLYGON_API_KEY,\n    cacheService\n)\n\nconst securityPricingService: ISecurityPricingService = new SecurityPricingService(\n    logger.child({ service: 'SecurityPricingService' }),\n    prisma,\n    marketDataService\n)\n\nconst insightService: IInsightService = new InsightService(\n    logger.child({ service: 'InsightService' }),\n    prisma\n)\n\nconst planService: IPlanService = new PlanService(\n    prisma,\n    new ProjectionCalculator(),\n    insightService\n)\n\n// providers\n\nconst tellerService = new TellerService(\n    logger.child({ service: 'TellerService' }),\n    prisma,\n    teller,\n    new TellerETL(logger.child({ service: 'TellerETL' }), prisma, teller, cryptoService),\n    cryptoService,\n    getTellerWebhookUrl(),\n    env.NX_TELLER_ENV === 'sandbox'\n)\n\n// account-connection\n\nconst accountConnectionProviderFactory = new AccountConnectionProviderFactory({\n    teller: tellerService,\n})\n\nconst transactionStrategy = new TransactionBalanceSyncStrategy(\n    logger.child({ service: 'TransactionBalanceSyncStrategy' }),\n    prisma\n)\n\nconst investmentTransactionStrategy = new InvestmentTransactionBalanceSyncStrategy(\n    logger.child({ service: 'InvestmentTransactionBalanceSyncStrategy' }),\n    prisma\n)\n\nconst valuationStrategy = new ValuationBalanceSyncStrategy(\n    logger.child({ service: 'ValuationBalanceSyncStrategy' }),\n    prisma\n)\n\nconst loanStrategy = new LoanBalanceSyncStrategy(\n    logger.child({ service: 'LoanBalanceSyncStrategy' }),\n    prisma\n)\n\nconst balanceSyncStrategyFactory = new BalanceSyncStrategyFactory({\n    INVESTMENT: investmentTransactionStrategy,\n    DEPOSITORY: transactionStrategy,\n    CREDIT: transactionStrategy,\n    LOAN: loanStrategy,\n    PROPERTY: valuationStrategy,\n    VEHICLE: valuationStrategy,\n    OTHER_ASSET: valuationStrategy,\n    OTHER_LIABILITY: valuationStrategy,\n})\n\nconst accountConnectionService = new AccountConnectionService(\n    logger.child({ service: 'AccountConnectionService' }),\n    prisma,\n    accountConnectionProviderFactory,\n    balanceSyncStrategyFactory,\n    securityPricingService,\n    queueService.getQueue('sync-account-connection')\n)\n\n// account\n\nconst accountQueryService: IAccountQueryService = new AccountQueryService(\n    logger.child({ service: 'AccountQueryService' }),\n    pgService\n)\n\nconst accountService = new AccountService(\n    logger.child({ service: 'AccountService' }),\n    prisma,\n    accountQueryService,\n    queueService.getQueue('sync-account'),\n    queueService.getQueue('sync-account-connection'),\n    balanceSyncStrategyFactory\n)\n\n// auth-user\n\nconst authUserService = new AuthUserService(logger.child({ service: 'AuthUserService' }), prisma)\n\n// user\n\nconst userService = new UserService(\n    logger.child({ service: 'UserService' }),\n    prisma,\n    accountQueryService,\n    balanceSyncStrategyFactory,\n    queueService.getQueue('sync-user'),\n    queueService.getQueue('purge-user'),\n    stripe\n)\n\n// institution\n\nconst institutionProviderFactory = new InstitutionProviderFactory({\n    TELLER: tellerService,\n})\n\nconst institutionService: IInstitutionService = new InstitutionService(\n    logger.child({ service: 'InstitutionService' }),\n    prisma,\n    pgService,\n    institutionProviderFactory\n)\n\n// valuation\n\nconst valuationService = new ValuationService(\n    logger.child({ service: 'ValuationService' }),\n    prisma,\n    accountQueryService\n)\n\n// transaction\n\nconst transactionService = new TransactionService(\n    logger.child({ service: 'TransactionService' }),\n    prisma\n)\n\n// holding\n\nconst holdingService = new HoldingService(logger.child({ service: 'HoldingService' }), prisma)\n\n// webhooks\n\nconst stripeWebhooks = new StripeWebhookHandler(\n    logger.child({ service: 'StripeWebhookHandler' }),\n    prisma,\n    stripe\n)\n\nconst tellerWebhooks = new TellerWebhookHandler(\n    logger.child({ service: 'TellerWebhookHandler' }),\n    prisma,\n    teller,\n    accountConnectionService\n)\n\n// helper function for parsing JWT and loading User record\n// TODO: update this with roles, identity, and metadata\nasync function getCurrentUser(jwt: NonNullable<Request['user']>) {\n    if (!jwt.sub) throw new Error(`jwt missing sub`)\n    if (!jwt['https://maybe.co/email']) throw new Error(`jwt missing email`)\n\n    const user =\n        (await prisma.user.findUnique({\n            where: { authId: jwt.sub },\n        })) ??\n        (await prisma.user.upsert({\n            where: { authId: jwt.sub },\n            create: {\n                authId: jwt.sub,\n                email: jwt['https://maybe.co/email'],\n                picture: jwt['picture'],\n                firstName: jwt['firstName'],\n                lastName: jwt['lastName'],\n            },\n            update: {},\n        }))\n\n    return {\n        ...user,\n        role: jwt.role,\n        // TODO: Replace Auth0 concepts with next-auth\n        primaryIdentity: {},\n        userMetadata: {},\n        appMetadata: {},\n    }\n}\n\nexport async function createContext(req: Request) {\n    const user = req.user ? await getCurrentUser(req.user) : null\n\n    return {\n        prisma,\n        stripe,\n        logger,\n        user,\n        ability: defineAbilityFor(user),\n        accountService,\n        transactionService,\n        holdingService,\n        accountConnectionService,\n        authUserService,\n        userService,\n        valuationService,\n        institutionService,\n        cryptoService,\n        queueService,\n        stripeWebhooks,\n        tellerService,\n        tellerWebhooks,\n        insightService,\n        marketDataService,\n        planService,\n        emailService,\n    }\n}\n\nexport default new EndpointFactory({\n    createContext,\n    onSuccess: (req, res, data) => res.status(200).superjson(data),\n})\n"
  },
  {
    "path": "apps/server/src/app/lib/logger.ts",
    "content": "import { createLogger } from '@maybe-finance/server/shared'\n\nconst logger = createLogger({\n    level: process.env.LOG_LEVEL ?? 'info',\n})\n\nexport default logger\n"
  },
  {
    "path": "apps/server/src/app/lib/prisma.ts",
    "content": "import { PrismaClient } from '@prisma/client'\nimport { DbUtil } from '@maybe-finance/server/shared'\nimport globalLogger from './logger'\n\nconst logger = globalLogger.child({ service: 'PrismaClient' })\n\n// https://stackoverflow.com/a/68328402\ndeclare global {\n    var prisma: PrismaClient // eslint-disable-line\n}\n\nfunction createPrismaClient() {\n    const prisma = new PrismaClient({\n        log: [\n            { emit: 'event', level: 'query' },\n            { emit: 'event', level: 'info' },\n            { emit: 'event', level: 'warn' },\n            { emit: 'event', level: 'error' },\n        ],\n    })\n\n    prisma.$on('query', ({ query, params, duration, ...data }) => {\n        logger.silly(`Query: ${query}, Params: ${params}, Duration: ${duration}`, { ...data })\n    })\n\n    prisma.$on('info', ({ message, ...data }) => {\n        logger.info(message, { ...data })\n    })\n\n    prisma.$on('warn', ({ message, ...data }) => {\n        logger.warn(message, { ...data })\n    })\n\n    prisma.$on('error', ({ message, ...data }) => {\n        logger.error(message, { ...data })\n    })\n\n    prisma.$use(DbUtil.slowQueryMiddleware(logger))\n\n    return prisma\n}\n\n// Prevent multiple instances of Prisma Client in development\n// https://www.prisma.io/docs/guides/performance-and-optimization/connection-management#prevent-hot-reloading-from-creating-new-instances-of-prismaclient\n// https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/instantiate-prisma-client#the-number-of-prismaclient-instances-matters\nconst prisma = global.prisma || createPrismaClient()\n\nif (process.env.NODE_ENV === 'development') global.prisma = prisma\n\nexport default prisma\n"
  },
  {
    "path": "apps/server/src/app/lib/stripe.ts",
    "content": "import Stripe from 'stripe'\nimport env from '../../env'\n\nconst stripe = new Stripe(env.NX_STRIPE_SECRET_KEY, { apiVersion: '2022-08-01' })\n\nexport default stripe\n"
  },
  {
    "path": "apps/server/src/app/lib/teller.ts",
    "content": "import { TellerApi } from '@maybe-finance/teller-api'\nimport { getWebhookUrl } from './webhook'\n\nconst teller = new TellerApi()\n\nexport default teller\n\nexport async function getTellerWebhookUrl() {\n    const webhookUrl = await getWebhookUrl()\n    return `${webhookUrl}/v1/teller/webhook`\n}\n"
  },
  {
    "path": "apps/server/src/app/lib/types.ts",
    "content": "import type { Response, Request, NextFunction } from 'express'\n\ninterface TypedRequest<T> extends Omit<Request, 'body'> {\n    body: T\n}\n\ninterface TypedResponse<T> extends Response {\n    superjson(data: T): TypedResponse<T>\n}\n\nexport type DefaultHandler<Req, Res> = (\n    req: TypedRequest<Req>,\n    res: TypedResponse<Res>,\n    next: NextFunction\n) => void\n\n// GET requests have optional request type, mandatory response type\nexport type GetHandler<Res> = (\n    req: TypedRequest<any>, // eslint-disable-line\n    res: TypedResponse<Res>,\n    next: NextFunction\n) => void\n\n// Aliases for semantics and convenience\nexport type PostHandler<Req, Res> = DefaultHandler<Req, Res>\nexport type PutHandler<Req, Res> = DefaultHandler<Req, Res>\nexport type DeleteHandler<Req, Res> = DefaultHandler<Req, Res>\n"
  },
  {
    "path": "apps/server/src/app/lib/webhook.ts",
    "content": "import axios from 'axios'\nimport isCI from 'is-ci'\nimport env from '../../env'\nimport logger from './logger'\n\nexport async function getWebhookUrl(): Promise<string> {\n    if (process.env.NODE_ENV === 'development' && !isCI && !env.NX_WEBHOOK_URL) {\n        const ngrokUrl = await axios.get(`${env.NX_NGROK_URL}/api/tunnels`).then((res) => {\n            const httpsTunnel = res.data.tunnels.find((t) => t.proto === 'https')\n            return httpsTunnel.public_url\n        })\n\n        logger.info(`Generated dynamic ngrok webhook URL: ${ngrokUrl}`)\n\n        return ngrokUrl\n    }\n\n    logger.info(`Using ${env.NX_WEBHOOK_URL ? env.NX_WEBHOOK_URL : env.NX_API_URL} for webhook URL`)\n\n    return env.NX_WEBHOOK_URL || env.NX_API_URL\n}\n"
  },
  {
    "path": "apps/server/src/app/middleware/auth-error-handler.ts",
    "content": "import type { ErrorRequestHandler } from 'express'\nimport { ForbiddenError } from '@casl/ability'\n\nexport const authErrorHandler: ErrorRequestHandler = (err, req, res, next) => {\n    if (err instanceof ForbiddenError) {\n        return res.status(403).json({\n            errors: [\n                {\n                    status: '403',\n                    title: 'Unauthorized',\n                    detail: err.message,\n                },\n            ],\n        })\n    }\n\n    next(err)\n}\n"
  },
  {
    "path": "apps/server/src/app/middleware/dev-only.ts",
    "content": "import createError from 'http-errors'\n\nexport const devOnly = (_req, _res, next) => {\n    if (process.env.NODE_ENV !== 'development') {\n        return next(createError(401, 'Route only available in dev mode'))\n    }\n\n    next()\n}\n"
  },
  {
    "path": "apps/server/src/app/middleware/error-handler.ts",
    "content": "import type { ErrorRequestHandler } from 'express'\nimport type { SharedType } from '@maybe-finance/shared'\nimport logger from '../lib/logger'\nimport { ErrorUtil } from '@maybe-finance/server/shared'\n\nexport const defaultErrorHandler: ErrorRequestHandler = async (err, req, res, _next) => {\n    const parsedError = ErrorUtil.parseError(err)\n\n    // A custom redirect if user tries to access admin dashboard without Admin role (see /apps/server/src/app/admin/admin-router.ts)\n    if (parsedError.message === 'ADMIN_UNAUTHORIZED') {\n        return res.redirect('/?error=invalid-credentials')\n    }\n\n    const errors: SharedType.ErrorResponse = {\n        errors: [\n            {\n                status: parsedError.statusCode || '500',\n                title: parsedError.message,\n            },\n        ],\n    }\n\n    logger.error(`[default-express-handler] ${parsedError.message}`, {\n        metadata: parsedError.metadata,\n        stackTrace: parsedError.stackTrace,\n        user: req.user?.sub,\n        request: {\n            method: req.method,\n            url: req.url,\n        },\n    })\n\n    logger.debug(parsedError.stackTrace)\n\n    res.status(+(parsedError.statusCode || 500)).json(errors)\n}\n"
  },
  {
    "path": "apps/server/src/app/middleware/identify-user.ts",
    "content": "import type { ErrorRequestHandler } from 'express'\nimport * as Sentry from '@sentry/node'\n\nexport const identifySentryUser: ErrorRequestHandler = (err, req, _res, next) => {\n    Sentry.setUser({\n        authId: req.user?.sub,\n    })\n\n    next(err)\n}\n"
  },
  {
    "path": "apps/server/src/app/middleware/index.ts",
    "content": "export * from './dev-only'\nexport * from './error-handler'\nexport * from './auth-error-handler'\nexport * from './superjson'\nexport * from './validate-auth-jwt'\nexport * from './validate-teller-signature'\nexport { default as maintenance } from './maintenance'\nexport * from './identify-user'\n"
  },
  {
    "path": "apps/server/src/app/middleware/maintenance.ts",
    "content": "import type { Express } from 'express'\n\ntype MaintenanceOptions = {\n    statusCode?: number\n    path?: string\n    featureKey?: string\n}\n\nexport default function maintenance(\n    app: Express,\n    { statusCode = 503, path = '/maintenance' }: MaintenanceOptions = {}\n) {\n    const enabled = false\n\n    app.get(path, async (req, res) => {\n        res.status(200).json({ enabled })\n    })\n\n    app.use(async (req, res, next) => {\n        if (enabled) {\n            return res.status(statusCode).json({ message: 'Maintenance in progress' })\n        }\n\n        next()\n    })\n}\n"
  },
  {
    "path": "apps/server/src/app/middleware/superjson.ts",
    "content": "import type { RequestHandler } from 'express'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { superjson as sj } from '@maybe-finance/shared'\n\nexport const superjson: RequestHandler = (req, res, next) => {\n    // Client *should* make requests with valid superjson format, { json: any, meta?: any }\n    if ('json' in req.body) {\n        req.body = sj.deserialize(req.body)\n    }\n\n    const _json = res.json.bind(res)\n    res.superjson = (data) => {\n        const serialized = sj.serialize(data)\n        const responsePayload: SharedType.SuccessResponse = { data: serialized }\n        return _json(responsePayload)\n    }\n\n    next()\n}\n"
  },
  {
    "path": "apps/server/src/app/middleware/validate-auth-jwt.ts",
    "content": "import cookieParser from 'cookie-parser'\nimport { decode } from 'next-auth/jwt'\nimport type { Request } from 'express'\n\nconst SECRET = process.env.NEXTAUTH_SECRET ?? 'REPLACE_THIS'\n\nconst getNextAuthCookie = (req: Request) => {\n    if (req.cookies) {\n        if ('__Secure-next-auth.session-token' in req.cookies) {\n            return req.cookies['__Secure-next-auth.session-token']\n        } else if ('next-auth.session-token' in req.cookies) {\n            return req.cookies['next-auth.session-token']\n        }\n    }\n    return undefined\n}\n\nexport const validateAuthJwt = async (req, res, next) => {\n    cookieParser(SECRET)(req, res, async (err) => {\n        if (err) {\n            return res.status(500).json({ message: 'Internal Server Error' })\n        }\n\n        if (req.cookies && getNextAuthCookie(req)) {\n            try {\n                const token = await decode({\n                    token: getNextAuthCookie(req),\n                    secret: SECRET,\n                })\n\n                if (token) {\n                    req.user = token\n                    return next()\n                } else {\n                    return res.status(401).json({ message: 'Unauthorized' })\n                }\n            } catch (error) {\n                console.error('Error in token validation', error)\n                return res.status(500).json({ message: 'Internal Server Error' })\n            }\n        } else if (req.headers.authorization) {\n            const token = req.headers.authorization.split(' ')[1]\n            const decoded = await decode({\n                token,\n                secret: SECRET,\n            })\n            if (decoded) {\n                req.user = decoded\n                return next()\n            } else {\n                return res.status(401).json({ message: 'Unauthorized' })\n            }\n        } else {\n            return res.status(401).json({ message: 'Unauthorized' })\n        }\n    })\n}\n"
  },
  {
    "path": "apps/server/src/app/middleware/validate-teller-signature.ts",
    "content": "import crypto from 'crypto'\nimport type { RequestHandler } from 'express'\nimport type { TellerTypes } from '@maybe-finance/teller-api'\nimport env from '../../env'\n\n// https://teller.io/docs/api/webhooks#verifying-messages\nexport const validateTellerSignature: RequestHandler = (req, res, next) => {\n    const signatureHeader = req.headers['teller-signature'] as string | undefined\n\n    if (!signatureHeader) {\n        return res.status(401).send('No Teller-Signature header found')\n    }\n\n    const { timestamp, signatures } = parseTellerSignatureHeader(signatureHeader)\n    const threeMinutesAgo = Math.floor(Date.now() / 1000) - 3 * 60\n\n    if (parseInt(timestamp) < threeMinutesAgo) {\n        return res.status(408).send('Signature timestamp is too old')\n    }\n\n    const signedMessage = `${timestamp}.${JSON.stringify(req.body as TellerTypes.WebhookData)}`\n    const expectedSignature = createHmacSha256(signedMessage, env.NX_TELLER_SIGNING_SECRET)\n\n    if (!signatures.includes(expectedSignature)) {\n        return res.status(401).send('Invalid webhook signature')\n    }\n\n    next()\n}\n\nconst parseTellerSignatureHeader = (\n    header: string\n): { timestamp: string; signatures: string[] } => {\n    const parts = header.split(',')\n    const timestampPart = parts.find((p) => p.startsWith('t='))\n    const signatureParts = parts.filter((p) => p.startsWith('v1='))\n\n    if (!timestampPart) {\n        throw new Error('No timestamp in Teller-Signature header')\n    }\n\n    const timestamp = timestampPart.split('=')[1]\n    const signatures = signatureParts.map((p) => p.split('=')[1])\n\n    return { timestamp, signatures }\n}\n\nconst createHmacSha256 = (message: string, secret: string): string => {\n    return crypto.createHmac('sha256', secret).update(message).digest('hex')\n}\n"
  },
  {
    "path": "apps/server/src/app/routes/account-rollup.router.ts",
    "content": "import { Router } from 'express'\nimport { z } from 'zod'\nimport { DateUtil } from '@maybe-finance/shared'\nimport endpoint from '../lib/endpoint'\n\nconst router = Router()\n\nrouter.get(\n    '/',\n    endpoint.create({\n        input: z\n            .object({\n                start: z.string().transform(DateUtil.dateTransform),\n                end: z.string().transform(DateUtil.dateTransform),\n            })\n            .partial(),\n        resolve: ({ ctx, input: { start, end } }) => {\n            return ctx.accountService.getAccountRollup(ctx.user!.id, start, end)\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/accounts.router.ts",
    "content": "import type { Account } from '@prisma/client'\nimport { AssetClass } from '@prisma/client'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { Router } from 'express'\nimport { subject } from '@casl/ability'\nimport { z } from 'zod'\nimport { DateUtil } from '@maybe-finance/shared'\nimport {\n    AccountCreateSchema,\n    AccountUpdateSchema,\n    InvestmentTransactionCategorySchema,\n} from '@maybe-finance/server/features'\nimport endpoint from '../lib/endpoint'\nimport keyBy from 'lodash/keyBy'\nimport merge from 'lodash/merge'\n\nconst router = Router()\n\nrouter.get(\n    '/',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            return ctx.accountService.getAll(ctx.user!.id)\n        },\n    })\n)\n\nrouter.post(\n    '/',\n    endpoint.create({\n        input: AccountCreateSchema,\n        resolve: async ({ input, ctx }) => {\n            ctx.ability.throwUnlessCan('create', 'Account')\n\n            let account: Account\n\n            switch (input.type) {\n                case 'LOAN': {\n                    const { currentBalance, ...rest } = input\n\n                    account = await ctx.accountService.create({\n                        ...rest,\n                        userId: ctx.user!.id,\n                        currentBalanceProvider: currentBalance,\n                        currentBalanceStrategy: 'current',\n                    })\n\n                    break\n                }\n                case 'INVESTMENT': {\n                    const { name, ...rest } = input\n\n                    account = await ctx.accountService.create({\n                        ...rest,\n                        userId: ctx.user!.id,\n                        currentBalanceProvider: 0,\n                        currentBalanceStrategy: 'current',\n                        provider: 'user',\n                        name: name,\n                    })\n\n                    break\n                }\n                default: {\n                    const {\n                        valuations: { originalBalance, currentBalance, currentDate },\n                        startDate,\n                        ...rest\n                    } = input\n\n                    const initialValuations = [\n                        {\n                            source: 'manual',\n                            date: startDate!,\n                            amount: originalBalance,\n                        },\n                    ]\n\n                    if (\n                        startDate &&\n                        currentBalance &&\n                        !DateUtil.isSameDate(DateUtil.datetimeTransform(startDate), currentDate)\n                    ) {\n                        initialValuations.push({\n                            source: 'manual',\n                            date: currentDate.toJSDate(),\n                            amount: currentBalance,\n                        })\n                    }\n\n                    account = await ctx.accountService.create(\n                        {\n                            ...rest,\n                            userId: ctx.user!.id,\n                            currentBalanceProvider: currentBalance,\n                            currentBalanceStrategy: 'current',\n                        },\n                        {\n                            create: initialValuations,\n                        }\n                    )\n\n                    break\n                }\n            }\n\n            await ctx.accountService.syncBalances(account.id)\n\n            return account\n        },\n    })\n)\n\nrouter.get(\n    '/:id',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const account = await ctx.accountService.getAccountDetails(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Account', account))\n            return account\n        },\n    })\n)\n\nrouter.put(\n    '/:id',\n    endpoint.create({\n        input: AccountUpdateSchema,\n        resolve: async ({ input, ctx, req }) => {\n            const account = await ctx.accountService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('Account', account))\n            const updatedAccount = await ctx.accountService.update(account.id, {\n                ...input.data,\n                ...('currentBalance' in input.data\n                    ? {\n                          currentBalance: undefined,\n                          currentBalanceProvider: input.data.currentBalance,\n                          currentBalanceStrategy: 'current',\n                      }\n                    : {}),\n            })\n            await ctx.accountService.syncBalances(updatedAccount.id)\n            return updatedAccount\n        },\n    })\n)\n\nrouter.delete(\n    '/:id',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const account = await ctx.accountService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('delete', subject('Account', account))\n            return ctx.accountService.delete(account.id)\n        },\n    })\n)\n\nrouter.get(\n    '/:id/balances',\n    endpoint.create({\n        input: z\n            .object({\n                start: z.string().transform(DateUtil.dateTransform),\n                end: z.string().transform(DateUtil.dateTransform),\n            })\n            .partial(),\n        resolve: async ({ ctx, input, req }) => {\n            const account = await ctx.accountService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Account', account))\n            return ctx.accountService.getBalances(account.id, input.start, input.end)\n        },\n    })\n)\n\nrouter.get(\n    '/:id/returns',\n    endpoint.create({\n        input: z.object({\n            start: z.string().transform((v) => DateUtil.datetimeTransform(v)),\n            end: z.string().transform((v) => DateUtil.datetimeTransform(v)),\n            compare: z\n                .string()\n                .optional()\n                .transform((v) => v?.split(',')), // in format of /accounts/:id/returns?compare=VOO,AAPL,TSLA\n        }),\n        resolve: async ({ ctx, input, req }): Promise<SharedType.AccountReturnResponse> => {\n            const account = await ctx.accountService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Account', account))\n\n            const returnSeries: SharedType.AccountReturnTimeSeriesData[] =\n                await ctx.accountService.getReturns(\n                    account.id,\n                    input.start.toISODate(),\n                    input.end.toISODate()\n                )\n\n            const baseSeries = {\n                interval: 'days' as SharedType.TimeSeriesInterval,\n                start: input.start.toISODate(),\n                end: input.end.toISODate(),\n            }\n\n            if (!input.compare || input.compare.length < 1)\n                return {\n                    ...baseSeries,\n                    data: returnSeries,\n                }\n\n            const comparisonData = await Promise.allSettled(\n                input.compare.map(async (ticker) => {\n                    return {\n                        ticker,\n                        pricing: await ctx.marketDataService.getDailyPricing(\n                            { assetClass: AssetClass.other, currencyCode: 'USD', symbol: ticker },\n                            input.start,\n                            input.end\n                        ),\n                    }\n                })\n            )\n\n            const comparisonPrices = comparisonData\n                .filter(\n                    (\n                        data\n                    ): data is PromiseFulfilledResult<{\n                        ticker: string\n                        pricing: SharedType.DailyPricing[]\n                    }> => {\n                        if (data.status === 'rejected') {\n                            ctx.logger.warn('Unable to generate comparison data', {\n                                reason: data.reason,\n                            })\n                        }\n\n                        return data.status === 'fulfilled'\n                    }\n                )\n                .map((data) => {\n                    return data.value.pricing.map((price) => ({\n                        date: price.date.toISODate(),\n                        [data.value.ticker]: price.priceClose\n                            .dividedBy(data.value.pricing[0].priceClose)\n                            .minus(1),\n                    }))\n                })\n\n            // Performs a \"left join\" of prices by ticker\n            const merged: Record<\n                string,\n                SharedType.AccountReturnResponse['data'][number]['comparisons']\n            > = merge(\n                keyBy(\n                    returnSeries.map((v) => ({ date: v.date })), // ensures we have a key for every single day\n                    'date'\n                ),\n                ...comparisonPrices.map((prices) => keyBy(prices, 'date'))\n            )\n\n            return {\n                ...baseSeries,\n                data: returnSeries.map((rs) => {\n                    return {\n                        ...rs,\n                        comparisons: merged[rs.date],\n                    }\n                }),\n            }\n        },\n    })\n)\n\nrouter.get(\n    '/:id/transactions',\n    endpoint.create({\n        input: z\n            .object({\n                start: z.string().transform(DateUtil.datetimeTransform),\n                end: z.string().transform(DateUtil.datetimeTransform),\n                page: z.string().transform((val) => parseInt(val)),\n            })\n            .partial(),\n        resolve: async ({ ctx, input, req }) => {\n            const account = await ctx.accountService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Account', account))\n            return ctx.accountService.getTransactions(\n                account.id,\n                input.page,\n                input.start,\n                input.end\n            )\n        },\n    })\n)\n\nrouter.get(\n    '/:id/valuations',\n    endpoint.create({\n        input: z\n            .object({\n                start: z.string().transform(DateUtil.datetimeTransform),\n                end: z.string().transform(DateUtil.datetimeTransform),\n            })\n            .partial(),\n        resolve: async ({ ctx, input, req }) => {\n            const account = await ctx.accountService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Account', account))\n            return ctx.valuationService.getValuations(account.id, input.start, input.end)\n        },\n    })\n)\n\nrouter.post(\n    '/:id/valuations',\n    endpoint.create({\n        input: z.object({\n            date: z.string().transform(DateUtil.datetimeTransform),\n            amount: z.number(),\n        }),\n        resolve: async ({ ctx, input: { date, amount }, req }) => {\n            const account = await ctx.accountService.get(+req.params.id)\n\n            ctx.ability.throwUnlessCan('update', subject('Account', account))\n\n            if (!date) throw new Error('Invalid valuation date')\n\n            const valuation = await ctx.valuationService.createValuation({\n                amount,\n                date: date.toJSDate(),\n                accountId: +req.params.id,\n                source: 'manual',\n            })\n\n            await ctx.accountService.syncBalances(+req.params.id)\n\n            return valuation\n        },\n    })\n)\n\nrouter.get(\n    '/:id/holdings',\n    endpoint.create({\n        input: z\n            .object({\n                page: z.string().transform((val) => parseInt(val)),\n            })\n            .partial(),\n        resolve: async ({ ctx, input: { page }, req }) => {\n            const account = await ctx.accountService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Account', account))\n            return ctx.accountService.getHoldings(account.id, page)\n        },\n    })\n)\n\nrouter.get(\n    '/:id/investment-transactions',\n    endpoint.create({\n        input: z\n            .object({\n                page: z.string().transform((val) => parseInt(val)),\n                start: z.string().transform(DateUtil.datetimeTransform).optional(),\n                end: z.string().transform(DateUtil.datetimeTransform).optional(),\n                category: InvestmentTransactionCategorySchema.optional(),\n            })\n            .partial(),\n        resolve: async ({ ctx, input, req }) => {\n            const account = await ctx.accountService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Account', account))\n            return ctx.accountService.getInvestmentTransactions(\n                account.id,\n                input.page,\n                input.start,\n                input.end,\n                input.category\n            )\n        },\n    })\n)\n\nrouter.get(\n    '/:id/insights',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const account = await ctx.accountService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Account', account))\n            return ctx.insightService.getAccountInsights({ accountId: account.id })\n        },\n    })\n)\n\nrouter.post(\n    '/:id/sync',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const account = await ctx.accountService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('Account', account))\n            return ctx.accountService.sync(+req.params.id)\n        },\n    })\n)\n\n// Syncs account balances without triggering a background worker (syncs balances much faster, ideal for smaller updates such as editing an account valuation)\nrouter.post(\n    '/:id/sync/balances',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const account = await ctx.accountService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('Account', account))\n            return ctx.accountService.syncBalances(+req.params.id)\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/admin.router.ts",
    "content": "import { Router } from 'express'\nimport { createBullBoard } from '@bull-board/api'\nimport { BullAdapter } from '@bull-board/api/bullAdapter'\nimport { ExpressAdapter } from '@bull-board/express'\nimport { BullQueue } from '@maybe-finance/server/shared'\nimport { queueService } from '../lib/endpoint'\nimport { validateAuthJwt } from '../middleware'\n\nconst router = Router()\n\nconst serverAdapter = new ExpressAdapter().setBasePath('/admin/bullmq')\n\ncreateBullBoard({\n    queues: queueService.allQueues\n        .filter((q): q is BullQueue => q instanceof BullQueue)\n        .map((q) => new BullAdapter(q.queue)),\n    serverAdapter,\n})\n\nrouter.get('/', validateAuthJwt, (req, res) => {\n    res.render('pages/dashboard', {\n        user: req.user?.name,\n        role: 'Admin',\n    })\n})\n\n// Visit /admin/bullmq to see BullMQ Dashboard\nrouter.use('/bullmq', validateAuthJwt, serverAdapter.getRouter())\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/connections.router.ts",
    "content": "import { Router } from 'express'\nimport { subject } from '@casl/ability'\nimport { z } from 'zod'\nimport endpoint from '../lib/endpoint'\nimport { devOnly } from '../middleware'\n\nconst router = Router()\n\nrouter.get(\n    '/:id',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const connection = await ctx.accountConnectionService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('AccountConnection', connection))\n            return connection\n        },\n    })\n)\n\nrouter.put(\n    '/:id',\n    endpoint.create({\n        input: z.object({ syncStatus: z.enum(['IDLE', 'PENDING', 'SYNCING']) }),\n        resolve: async ({ input, ctx, req }) => {\n            const connection = await ctx.accountConnectionService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('AccountConnection', connection))\n            const updatedConnection = await ctx.accountConnectionService.update(\n                connection.id,\n                input\n            )\n            return updatedConnection\n        },\n    })\n)\n\nrouter.post(\n    '/:id/disconnect',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const connection = await ctx.accountConnectionService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('AccountConnection', connection))\n            return ctx.accountConnectionService.disconnect(connection.id)\n        },\n    })\n)\n\nrouter.post(\n    '/:id/reconnect',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const connection = await ctx.accountConnectionService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('AccountConnection', connection))\n            return ctx.accountConnectionService.reconnect(connection.id)\n        },\n    })\n)\n\nrouter.post(\n    '/:id/sync',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const connection = await ctx.accountConnectionService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('AccountConnection', connection))\n            return ctx.accountConnectionService.sync(connection.id)\n        },\n    })\n)\n\nrouter.post(\n    '/:id/sync/:sync',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const connection = await ctx.accountConnectionService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('AccountConnection', connection))\n\n            switch (req.params.sync) {\n                case 'balances': {\n                    await ctx.accountConnectionService.syncBalances(connection.id)\n                    break\n                }\n                case 'securities': {\n                    await ctx.accountConnectionService.syncSecurities(connection.id)\n                    break\n                }\n                default:\n                    throw new Error(`unknown sync command: ${req.params.sync}`)\n            }\n\n            return connection\n        },\n    })\n)\n\nrouter.delete(\n    '/:id',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const connection = await ctx.accountConnectionService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('delete', subject('AccountConnection', connection))\n            return ctx.accountConnectionService.delete(connection.id)\n        },\n    })\n)\n\nrouter.delete(\n    '/',\n    devOnly,\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            await ctx.prisma.accountConnection.deleteMany({ where: { userId: ctx.user!.id } })\n            return { success: true }\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/e2e.router.ts",
    "content": "import type { OnboardingState } from '@maybe-finance/server/features'\nimport { AuthUserRole } from '@prisma/client'\nimport { Router } from 'express'\nimport { DateTime } from 'luxon'\nimport { z } from 'zod'\nimport endpoint from '../lib/endpoint'\n\nconst router = Router()\n\nrouter.use((req, res, next) => {\n    const role = req.user?.role\n\n    if (role === AuthUserRole.admin || role === AuthUserRole.ci) {\n        next()\n    } else {\n        res.status(401).send('Route only available to CIUser and Admin roles')\n    }\n})\n\n// Validation endpoint for Cypress\nrouter.get(\n    '/',\n    endpoint.create({\n        resolve: async () => {\n            return { success: true }\n        },\n    })\n)\n\nrouter.post(\n    '/reset',\n    endpoint.create({\n        input: z.object({\n            mainOnboardingDisabled: z.boolean().default(true),\n            sidebarOnboardingDisabled: z.boolean().default(true),\n            trialLapsed: z.boolean().default(false),\n        }),\n        resolve: async ({ ctx, input }) => {\n            const user = ctx.user!\n            await ctx.prisma.$transaction([\n                ctx.prisma.$executeRaw`DELETE FROM \"user\" WHERE auth_id=${user.authId};`,\n                ctx.prisma.user.create({\n                    data: {\n                        authId: user.authId,\n                        email: user.email,\n                        firstName: user.firstName,\n                        lastName: user.lastName,\n                        dob: new Date('1990-01-01'),\n                        linkAccountDismissedAt: new Date(), // ensures our auto-account link doesn't trigger\n\n                        // Override onboarding flows to be complete for e2e testing\n                        onboarding: {\n                            main: { markedComplete: input.mainOnboardingDisabled, steps: [] },\n                            sidebar: { markedComplete: input.sidebarOnboardingDisabled, steps: [] },\n                        } as OnboardingState,\n\n                        trialEnd: input.trialLapsed\n                            ? DateTime.now().minus({ days: 1 }).toJSDate()\n                            : undefined,\n                    },\n                }),\n            ])\n\n            return { success: true }\n        },\n    })\n)\n\nrouter.post(\n    '/clean',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            const user = ctx.user!\n            await ctx.prisma.$transaction([\n                ctx.prisma.$executeRaw`DELETE FROM \"user\" WHERE auth_id=${user.authId};`,\n                ctx.prisma.$executeRaw`DELETE FROM \"auth_user\" WHERE id=${user.authId};`,\n            ])\n\n            return { success: true }\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/holdings.router.ts",
    "content": "import { Router } from 'express'\nimport { subject } from '@casl/ability'\nimport { HoldingUpdateInputSchema } from '@maybe-finance/server/features'\nimport endpoint from '../lib/endpoint'\n\nconst router = Router()\n\nrouter.get(\n    '/:id',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const holding = await ctx.holdingService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Holding', holding))\n\n            return ctx.holdingService.getHoldingDetails(holding.id)\n        },\n    })\n)\n\nrouter.get(\n    '/:id/insights',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const holding = await ctx.holdingService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Holding', holding))\n\n            return ctx.insightService.getHoldingInsights({ holding })\n        },\n    })\n)\n\nrouter.put(\n    '/:id',\n    endpoint.create({\n        input: HoldingUpdateInputSchema,\n        resolve: async ({ input, ctx, req }) => {\n            const holding = await ctx.holdingService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('Holding', holding))\n\n            const updatedHolding = await ctx.holdingService.update(+req.params.id, input)\n\n            await ctx.accountService.syncBalances(holding.accountId)\n\n            return updatedHolding\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/index.ts",
    "content": "export { default as accountsRouter } from './accounts.router'\nexport { default as accountRollupRouter } from './account-rollup.router'\nexport { default as connectionsRouter } from './connections.router'\nexport { default as usersRouter } from './users.router'\nexport { default as webhooksRouter } from './webhooks.router'\nexport { default as tellerRouter } from './teller.router'\nexport { default as valuationsRouter } from './valuations.router'\nexport { default as institutionsRouter } from './institutions.router'\nexport { default as transactionsRouter } from './transactions.router'\nexport { default as holdingsRouter } from './holdings.router'\nexport { default as securitiesRouter } from './securities.router'\nexport { default as plansRouter } from './plans.router'\nexport { default as toolsRouter } from './tools.router'\nexport { default as publicRouter } from './public.router'\nexport { default as e2eRouter } from './e2e.router'\nexport { default as adminRouter } from './admin.router'\n"
  },
  {
    "path": "apps/server/src/app/routes/institutions.router.ts",
    "content": "import { Router } from 'express'\nimport { z } from 'zod'\nimport endpoint from '../lib/endpoint'\n\nconst router = Router()\n\nrouter.get(\n    '/',\n    endpoint.create({\n        input: z\n            .object({\n                q: z.string(),\n                page: z.string().transform((val) => parseInt(val)),\n            })\n            .partial(),\n        resolve: async ({ ctx, input }) => {\n            ctx.ability.throwUnlessCan('read', 'Institution')\n\n            return ctx.institutionService.getAll({ query: input.q, page: input.page })\n        },\n    })\n)\n\nrouter.post(\n    '/sync',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            ctx.ability.throwUnlessCan('update', 'Institution')\n\n            // Sync all Teller institutions\n            await ctx.queueService\n                .getQueue('sync-institution')\n                .addBulk([{ name: 'sync-teller-institutions', data: {} }])\n\n            return { success: true }\n        },\n    })\n)\n\nrouter.post(\n    '/deduplicate',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            ctx.ability.throwUnlessCan('manage', 'Institution')\n\n            await ctx.institutionService.deduplicateInstitutions()\n\n            return { success: true }\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/plans.router.ts",
    "content": "import { Router } from 'express'\nimport { subject } from '@casl/ability'\nimport {\n    PlanCreateSchema,\n    PlanTemplateSchema,\n    PlanUpdateSchema,\n} from '@maybe-finance/server/features'\nimport endpoint from '../lib/endpoint'\nimport { DateUtil, PlanUtil } from '@maybe-finance/shared'\n\nconst router = Router()\n\nrouter.get(\n    '/',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            const { plans } = await ctx.planService.getAll(ctx.user!.id)\n\n            if (plans.length > 0) return { plans }\n\n            /**\n             * Generate a default plan so the user can start using the plan feature\n             * without requiring any data inputs up-front.\n             *\n             * Defaults to \"Retirement\" template\n             */\n            const plan = await ctx.planService.createWithTemplate(ctx.user!, {\n                type: 'retirement',\n                data: {\n                    // Defaults to user age of 30 and retirement age of 65\n                    retirementYear: DateUtil.ageToYear(\n                        PlanUtil.RETIREMENT_MILESTONE_AGE,\n                        DateUtil.dobToAge(ctx.user?.dob) ?? PlanUtil.DEFAULT_AGE\n                    ),\n                },\n            })\n\n            return {\n                plans: [plan],\n            }\n        },\n    })\n)\n\nrouter.post(\n    '/',\n    endpoint.create({\n        input: PlanCreateSchema,\n        resolve: async ({ input: { events, milestones, ...data }, ctx }) => {\n            ctx.ability.throwUnlessCan('create', 'Plan')\n\n            return await ctx.planService.create({\n                ...data,\n                userId: ctx.user!.id,\n                events: { create: events },\n                milestones: { create: milestones },\n            })\n        },\n    })\n)\n\n/** Create a new plan using a pre-defined template */\nrouter.post(\n    '/template',\n    endpoint.create({\n        input: PlanTemplateSchema,\n        resolve: async ({ input, ctx }) => {\n            ctx.ability.throwUnlessCan('create', 'Plan')\n\n            return await ctx.planService.createWithTemplate(ctx.user!, input)\n        },\n    })\n)\n\n/**\n * Update an existing plan using a pre-defined template\n *\n * Can be used to reset a template to defaults or\n * add milestone-templates to an existing plan\n */\nrouter.put(\n    '/:id/template',\n    endpoint.create({\n        input: PlanTemplateSchema,\n        resolve: async ({ ctx, req, input }) => {\n            const plan = await ctx.planService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('Plan', plan))\n\n            const shouldReset = req.query.reset === 'true'\n\n            return await ctx.planService.updateWithTemplate(plan.id, input, shouldReset)\n        },\n    })\n)\n\nrouter.get(\n    '/:id',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const plan = await ctx.planService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Plan', plan))\n            return plan\n        },\n    })\n)\n\nrouter.put(\n    '/:id',\n    endpoint.create({\n        input: PlanUpdateSchema,\n        resolve: async ({ input: { events, milestones, ...data }, ctx, req }) => {\n            const plan = await ctx.planService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('Plan', plan))\n            const updatedPlan = await ctx.planService.update(plan.id, {\n                ...data,\n                events: events\n                    ? {\n                          create: events.create,\n                          update: events.update\n                              ? events.update.map(({ id, data }) => ({ where: { id }, data }))\n                              : undefined,\n                          deleteMany: events.delete ? { id: { in: events.delete } } : undefined,\n                      }\n                    : undefined,\n                milestones: milestones\n                    ? {\n                          create: milestones.create,\n                          update: milestones.update\n                              ? milestones.update.map(({ id, data }) => ({ where: { id }, data }))\n                              : undefined,\n                          deleteMany: milestones.delete\n                              ? { id: { in: milestones.delete } }\n                              : undefined,\n                      }\n                    : undefined,\n            })\n            return updatedPlan\n        },\n    })\n)\n\nrouter.delete(\n    '/:id',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const plan = await ctx.planService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('delete', subject('Plan', plan))\n            return ctx.planService.delete(plan.id)\n        },\n    })\n)\n\nrouter.get(\n    '/:id/projections',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const plan = await ctx.planService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Plan', plan))\n            return ctx.planService.projections(plan.id)\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/public.router.ts",
    "content": "import { Router } from 'express'\nimport env from '../../env'\nimport endpoint from '../lib/endpoint'\n\nconst router = Router()\n\nrouter.get(\n    '/users/card/:memberId',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const memberId = req.params.memberId\n            if (!memberId) throw new Error('No memberId provided for member details.')\n\n            const clientUrl = env.NX_CLIENT_URL_CUSTOM || env.NX_CLIENT_URL\n\n            return ctx.userService.getMemberCard(memberId, clientUrl)\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/securities.router.ts",
    "content": "import { Router } from 'express'\nimport { DateTime } from 'luxon'\nimport env from '../../env'\nimport endpoint from '../lib/endpoint'\n\nconst router = Router()\n\nrouter.get(\n    '/',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            ctx.ability.throwUnlessCan('read', 'Security')\n\n            return await prisma.security.findMany({\n                select: {\n                    symbol: true,\n                    exchangeName: true,\n                },\n            })\n        },\n    })\n)\n\nrouter.get(\n    '/:id',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            ctx.ability.throwUnlessCan('read', 'Security')\n\n            return await ctx.prisma.security.findUniqueOrThrow({\n                where: { id: +req.params.id },\n                include: {\n                    pricing: {\n                        where: {\n                            date: {\n                                gte: DateTime.now().minus({ weeks: 52 }).toJSDate(),\n                                lte: DateTime.now().toJSDate(),\n                            },\n                        },\n                        orderBy: {\n                            date: 'asc',\n                        },\n                    },\n                },\n            })\n        },\n    })\n)\n\nrouter.get(\n    '/:id/details',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            ctx.ability.throwUnlessCan('read', 'Security')\n\n            const security = await ctx.prisma.security.findUniqueOrThrow({\n                where: { id: +req.params.id },\n            })\n\n            return await ctx.marketDataService.getSecurityDetails(security)\n        },\n    })\n)\n\nrouter.post(\n    '/sync/us-stock-tickers',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            ctx.ability.throwUnlessCan('manage', 'Security')\n\n            if (env.NX_POLYGON_API_KEY) {\n                try {\n                    await ctx.queueService\n                        .getQueue('sync-security')\n                        .add('sync-us-stock-tickers', {})\n                    return { success: true }\n                } catch (err) {\n                    throw new Error('Failed to sync stock tickers')\n                }\n            } else {\n                throw new Error('No Polygon API key found')\n            }\n        },\n    })\n)\n\nrouter.post(\n    '/sync/stock-pricing',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            ctx.ability.throwUnlessCan('manage', 'Security')\n\n            if (env.NX_POLYGON_API_KEY) {\n                try {\n                    await ctx.queueService.getQueue('sync-security').add('sync-all-securities', {})\n                    return { success: true }\n                } catch (err) {\n                    throw new Error('Failed to sync securities pricing')\n                }\n            } else {\n                throw new Error('No Polygon API key found')\n            }\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/teller.router.ts",
    "content": "import { Router } from 'express'\nimport { z } from 'zod'\nimport endpoint from '../lib/endpoint'\n\nconst router = Router()\n\nrouter.post(\n    '/handle-enrollment',\n    endpoint.create({\n        input: z.object({\n            institution: z.object({\n                name: z.string(),\n                id: z.string(),\n            }),\n            enrollment: z.object({\n                accessToken: z.string(),\n                user: z.object({\n                    id: z.string(),\n                }),\n                enrollment: z.object({\n                    id: z.string(),\n                    institution: z.object({\n                        name: z.string(),\n                    }),\n                }),\n                signatures: z.array(z.string()).optional(),\n            }),\n        }),\n        resolve: ({ input: { institution, enrollment }, ctx }) => {\n            return ctx.tellerService.handleEnrollment(ctx.user!.id, institution, enrollment)\n        },\n    })\n)\n\nrouter.post(\n    '/institutions/sync',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            ctx.ability.throwUnlessCan('manage', 'Institution')\n            await ctx.queueService.getQueue('sync-institution').add('sync-teller-institutions', {})\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/tools.router.ts",
    "content": "import { Router } from 'express'\nimport _ from 'lodash'\nimport type Decimal from 'decimal.js'\nimport type { ProjectionInput } from '@maybe-finance/server/features'\nimport { AssetValue, monteCarlo, ProjectionCalculator } from '@maybe-finance/server/features'\nimport { StatsUtil } from '@maybe-finance/shared'\n\nconst router = Router()\n\nconst params: Record<string, [Decimal.Value, Decimal.Value]> = {\n    stocks: ['0.05', '0.186'],\n    bonds: ['0.02', '0.052'],\n    cash: ['-0.02', '0.05'],\n    crypto: ['1.0', '1.0'],\n    property: ['0.1', '0.2'],\n    other: ['-0.02', '0'],\n}\n\nfunction getInput(scenario: string, randomized = false): ProjectionInput {\n    const Value = (value, mean, stddev) => new AssetValue(value, mean, randomized ? stddev : 0)\n\n    const scenarios: Record<string, ProjectionInput> = {\n        portfolio_vizualizer: {\n            years: 30,\n            assets: [\n                {\n                    id: 'stock',\n                    value: Value(800_000, ...params.stocks),\n                },\n                {\n                    id: 'bonds',\n                    value: Value(150_000, ...params.bonds),\n                },\n                {\n                    id: 'cash',\n                    value: Value(50_000, ...params.cash),\n                },\n            ],\n            liabilities: [],\n            events: [\n                { id: 'income', value: new AssetValue(100_000), end: 2032 },\n                { id: 'expenses', value: new AssetValue(-60_000) },\n            ],\n            milestones: [\n                { id: 'retirement', type: 'net-worth', expenseMultiple: 25, expenseYears: 1 },\n            ],\n        },\n        debug: {\n            years: 56,\n            assets: [\n                {\n                    id: 'cash',\n                    value: Value('283221', ...params.cash),\n                },\n                {\n                    id: 'other',\n                    value: Value('221332', ...params.other),\n                },\n                {\n                    id: 'property',\n                    value: Value('1300000', ...params.property),\n                },\n                {\n                    id: 'stocks',\n                    value: Value('1421113', ...params.stocks),\n                },\n            ],\n            liabilities: [],\n            events: [\n                {\n                    id: '3',\n                    value: new AssetValue('-10000', '0.01'),\n                    start: 2022,\n                    end: 2072,\n                },\n                {\n                    id: '4',\n                    value: new AssetValue('12000'),\n                    start: 2050,\n                    end: 2072,\n                },\n                {\n                    id: '5',\n                    value: new AssetValue('17148'),\n                    start: 2050,\n                    end: 2072,\n                },\n                {\n                    id: '6',\n                    value: new AssetValue('-120000', '0.01'),\n                    start: 2022,\n                    end: 2076,\n                },\n            ],\n            milestones: [\n                {\n                    id: '2',\n                    type: 'year',\n                    year: 2057,\n                },\n            ],\n        },\n    }\n\n    return scenarios[scenario]\n}\n\nrouter.post('/projections', (req, res) => {\n    const calculator = new ProjectionCalculator()\n\n    const scenario = 'debug'\n    const N = 500\n    const tiles = ['0.1', '0.25', '0.5', '0.75', '0.9']\n\n    const inputTheo = getInput(scenario, false)\n    const inputRandomized = getInput(scenario, true)\n\n    const theo = calculator.calculate(inputTheo)\n    const simulations = monteCarlo(() => calculator.calculate(inputRandomized), { n: N })\n\n    const simulationsWithStats = _.zipWith(...simulations, (...series) => {\n        const year = series[0].year\n        const netWorths = series.map((d) => d.netWorth)\n\n        return {\n            year,\n            percentiles: StatsUtil.quantiles(netWorths, tiles),\n            successRate: StatsUtil.rateOf(netWorths, (nw) => nw.gt(0)),\n            ci95: StatsUtil.confidenceInterval(netWorths),\n            avg: StatsUtil.mean(netWorths),\n            netWorths: _.sortBy(netWorths, (nw) => +nw),\n            stddev: StatsUtil.stddev(netWorths),\n        }\n    })\n\n    const simulationsByPercentile = tiles.map((percentile, idx) => ({\n        percentile,\n        simulation: simulationsWithStats.map(({ year, percentiles }) => ({\n            year,\n            netWorth: percentiles[idx],\n        })),\n    }))\n\n    const result = {\n        theo,\n        simulations,\n        simulationsWithStats,\n        simulationsByPercentile,\n    }\n\n    // res.set('cache-control', 'public, max-age=60')\n    res.status(200).json(result)\n})\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/transactions.router.ts",
    "content": "import { Router } from 'express'\nimport { subject } from '@casl/ability'\nimport endpoint from '../lib/endpoint'\nimport {\n    TransactionPaginateParams,\n    TransactionUpdateInputSchema,\n} from '@maybe-finance/server/features'\n\nconst router = Router()\n\nrouter.get(\n    '/',\n    endpoint.create({\n        input: TransactionPaginateParams,\n        resolve: async ({ ctx, input }) => {\n            return ctx.transactionService.getAll(ctx.user!.id, input.pageIndex, input.pageSize)\n        },\n    })\n)\n\nrouter.get(\n    '/:id',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const transaction = await ctx.transactionService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Transaction', transaction))\n            return transaction\n        },\n    })\n)\n\nrouter.put(\n    '/:id',\n    endpoint.create({\n        input: TransactionUpdateInputSchema,\n        resolve: async ({ input, ctx, req }) => {\n            const transaction = await ctx.transactionService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('Transaction', transaction))\n\n            const updatedTransaction = await ctx.transactionService.update(+req.params.id, input)\n\n            await ctx.accountService.syncBalances(transaction.accountId)\n\n            return updatedTransaction\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/users.router.ts",
    "content": "import { Router } from 'express'\nimport { subject } from '@casl/ability'\nimport { z } from 'zod'\nimport { DateUtil, type SharedType } from '@maybe-finance/shared'\nimport endpoint from '../lib/endpoint'\nimport env from '../../env'\nimport {\n    type OnboardingState,\n    type RegisteredStep,\n    UpdateOnboardingSchema,\n} from '@maybe-finance/server/features'\n\nconst router = Router()\n\nrouter.get(\n    '/',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            return ctx.userService.get(ctx.user!.id)\n        },\n    })\n)\n\nrouter.get(\n    '/onboarding/:flow',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            let onboarding: SharedType.OnboardingResponse\n\n            switch (req.params.flow) {\n                case 'main':\n                    onboarding = await ctx.userService.buildMainOnboarding(ctx.user!.id)\n                    break\n                case 'sidebar':\n                    onboarding = await ctx.userService.buildSidebarOnboarding(ctx.user!.id)\n                    break\n                default:\n                    throw new Error(`${req.params.flow} is not a valid onboarding flow key`)\n            }\n\n            const { steps, currentStep, progress, isComplete, isMarkedComplete } = onboarding\n\n            return {\n                steps,\n                currentStep,\n                progress,\n                isComplete,\n                isMarkedComplete,\n            }\n        },\n    })\n)\n\nrouter.put(\n    '/onboarding',\n    endpoint.create({\n        input: UpdateOnboardingSchema,\n        resolve: async ({ ctx, input }) => {\n            const user = await ctx.prisma.user.findFirstOrThrow({\n                where: { id: ctx.user!.id },\n                select: { id: true, onboarding: true },\n            })\n\n            const onboardingState = user.onboarding as OnboardingState | null\n\n            // Initialize onboarding state\n            const onboarding = onboardingState\n                ? onboardingState\n                : ({\n                      main: { markedComplete: false, steps: [] },\n                      sidebar: { markedComplete: false, steps: [] },\n                  } as OnboardingState)\n\n            input.updates.forEach((update: RegisteredStep) => {\n                const oldStepIdx = onboarding[input.flow].steps.findIndex(\n                    (step) => step.key === update.key\n                )\n\n                // Create or update\n                if (oldStepIdx < 0) {\n                    onboarding[input.flow].steps.push(update)\n                } else {\n                    onboarding[input.flow].steps[oldStepIdx] = update\n                }\n            })\n\n            if (input.flow === 'sidebar' && input.markedComplete != null) {\n                onboarding['sidebar'].markedComplete = input.markedComplete\n            }\n\n            ctx.logger.info(\n                `User onboarding updated. flow=${input.flow} updated=${input.updates.length} user=${\n                    ctx.user!.id\n                }`,\n                input\n            )\n\n            return ctx.prisma.user.update({\n                where: { id: ctx.user!.id },\n                data: { onboarding },\n            })\n        },\n    })\n)\n\nrouter.get(\n    '/auth-profile',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            return ctx.userService.getAuthProfile(ctx.user!.id)\n        },\n    })\n)\n\nrouter.get(\n    '/subscription',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            if (!ctx.user || !ctx.user.id) {\n                throw new Error('User not found')\n            }\n\n            return ctx.userService.getSubscription(ctx.user.id)\n        },\n    })\n)\n\nrouter.put(\n    '/',\n    endpoint.create({\n        input: z\n            .object({\n                monthlyDebtUser: z.number().nullable(),\n                monthlyIncomeUser: z.number().nullable(),\n                monthlyExpensesUser: z.number().nullable(),\n                goals: z.string().array(),\n                riskAnswers: z\n                    .object({ questionKey: z.string(), choiceKey: z.string() })\n                    .array()\n                    .min(1),\n                household: z.enum([\n                    'single',\n                    'singleWithDependents',\n                    'dual',\n                    'dualWithDependents',\n                    'retired',\n                ]),\n                country: z.string().nullable(),\n                state: z.string().nullable(),\n                maybeGoals: z.enum(['aggregate', 'advice', 'plan']).array(),\n                maybeGoalsDescription: z.string().nullable(),\n                maybe: z.string().nullable(),\n                title: z.string().nullable(),\n                firstName: z.string(),\n                lastName: z.string(),\n                dob: z.string().transform((d) => DateUtil.datetimeTransform(d).toJSDate()),\n                linkAccountDismissedAt: z.date(),\n            })\n            .partial(),\n        resolve: ({ input, ctx }) => {\n            if (!ctx.user || !ctx.user.id) {\n                throw new Error('Could not update user.  User not found')\n            }\n\n            return ctx.userService.update(ctx.user.id, input)\n        },\n    })\n)\n\nrouter.get(\n    '/net-worth',\n    endpoint.create({\n        input: z\n            .object({\n                start: z.string().transform(DateUtil.dateTransform),\n                end: z.string().transform(DateUtil.dateTransform),\n            })\n            .partial(),\n        resolve: ({ ctx, input: { start, end } }) => {\n            return ctx.userService.getNetWorthSeries(ctx.user!.id, start, end)\n        },\n    })\n)\n\nrouter.get(\n    '/:id/net-worth',\n    endpoint.create({\n        input: z\n            .object({\n                start: z.string().transform(DateUtil.dateTransform),\n                end: z.string().transform(DateUtil.dateTransform),\n            })\n            .partial(),\n        resolve: async ({ ctx, req, input: { start, end } }) => {\n            const user = await ctx.userService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('User', user))\n            return ctx.userService.getNetWorthSeries(user.id, start, end)\n        },\n    })\n)\n\nrouter.get(\n    '/net-worth/:date',\n    endpoint.create({\n        resolve: ({ ctx, req }) => {\n            return ctx.userService.getNetWorth(\n                ctx.user!.id,\n                DateUtil.dateTransform(req.params.date)\n            )\n        },\n    })\n)\n\nrouter.get(\n    '/:id/net-worth/:date',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const user = await ctx.userService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('User', user))\n            return ctx.userService.getNetWorth(user.id, DateUtil.dateTransform(req.params.date))\n        },\n    })\n)\n\nrouter.get(\n    '/:id/account-rollup',\n    endpoint.create({\n        input: z\n            .object({\n                start: z.string().transform(DateUtil.dateTransform),\n                end: z.string().transform(DateUtil.dateTransform),\n            })\n            .partial(),\n        resolve: async ({ ctx, input: { start, end }, req }) => {\n            const user = await ctx.userService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('User', user))\n            return ctx.accountService.getAccountRollup(user.id, start, end)\n        },\n    })\n)\n\nrouter.get(\n    '/insights',\n    endpoint.create({\n        resolve: ({ ctx }) => {\n            return ctx.insightService.getUserInsights({ userId: ctx.user!.id })\n        },\n    })\n)\n\nrouter.get(\n    '/:id/insights',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const user = await ctx.userService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('User', user))\n            return ctx.insightService.getUserInsights({ userId: user.id })\n        },\n    })\n)\n\n// TODO: Implement verification email using internal email service instead of Auth0\nrouter.post(\n    '/resend-verification-email',\n    endpoint.create({\n        input: z.object({\n            authId: z.string().optional(),\n        }),\n        resolve: async ({ input, ctx }) => {\n            const authId = input.authId ?? ctx.user?.authId\n            if (!authId) throw new Error('User not found')\n\n            //await ctx.managementClient.sendEmailVerification({ user_id: authId })\n\n            ctx.logger.info(`Sent verification email to ${authId}`)\n\n            return { success: true }\n        },\n    })\n)\n\nrouter.put(\n    '/change-password',\n    endpoint.create({\n        input: z.object({\n            newPassword: z.string(),\n            currentPassword: z.string(),\n        }),\n        resolve: async ({ input, ctx, req }) => {\n            if (!req.user || !req.user.sub) {\n                throw new Error('Unable to update password.  No user found.')\n            }\n\n            const { newPassword, currentPassword } = input\n\n            try {\n                await ctx.authUserService.updatePassword(req.user.sub, currentPassword, newPassword)\n            } catch (err) {\n                const errMessage = 'Could not reset password'\n                // Do not log the full error here, the user's password could be in it!\n                ctx.logger.error('Could not reset password')\n\n                return { success: false, error: errMessage }\n            }\n\n            return { success: true }\n        },\n    })\n)\n\nrouter.post(\n    '/checkout-session',\n    endpoint.create({\n        input: z.object({\n            plan: z.string(),\n        }),\n        resolve: async ({ ctx, req, input }) => {\n            if (!req.user?.sub || !ctx.user) {\n                throw new Error('Unable to create checkout session. No user found.')\n            }\n\n            const session = await ctx.stripe.checkout.sessions.create({\n                line_items: [\n                    {\n                        price:\n                            input.plan === 'yearly'\n                                ? env.NX_STRIPE_PREMIUM_YEARLY_PRICE_ID\n                                : env.NX_STRIPE_PREMIUM_MONTHLY_PRICE_ID,\n                        quantity: 1,\n                    },\n                ],\n                mode: 'subscription',\n                success_url: `${req.headers.origin}/settings?tab=billing&status=success`,\n                cancel_url: `${req.headers.origin}/settings?tab=billing&status=cancelled`,\n                allow_promotion_codes: true,\n\n                client_reference_id: req.user.sub,\n\n                // Provide customer ID or user email, not both\n                ...(ctx.user.stripeCustomerId\n                    ? {\n                          customer: ctx.user.stripeCustomerId,\n                      }\n                    : {\n                          customer_email:\n                              (await ctx.authUserService.get(req.user.sub)).email ?? undefined,\n                      }),\n            })\n\n            if (!session.url) throw new Error('Failed to create checkout session with URL.')\n\n            return { url: session.url }\n        },\n    })\n)\n\nrouter.post(\n    '/customer-portal-session',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            if (!req.user?.sub || !ctx.user || !ctx.user.stripeCustomerId) {\n                throw new Error('Unable to create customer portal session. No user/customer found.')\n            }\n\n            const session = await ctx.stripe.billingPortal.sessions.create({\n                customer: ctx.user.stripeCustomerId,\n                return_url: `${req.headers.origin}/settings?tab=billing`,\n            })\n\n            if (!session.url) throw new Error('Failed to create customer portal session with URL.')\n\n            return { url: session.url }\n        },\n    })\n)\n\nrouter.delete(\n    '/',\n    endpoint.create({\n        input: z.object({\n            confirm: z.literal(true),\n        }),\n        resolve: async ({ ctx }) => {\n            const { id } = ctx.user!\n            ctx.ability.throwUnlessCan('delete', subject('User', ctx.user!))\n            await ctx.userService.delete(id)\n        },\n    })\n)\n\nrouter.delete(\n    '/:id',\n    endpoint.create({\n        input: z.object({\n            confirm: z.literal(true),\n        }),\n        resolve: async ({ ctx, req }) => {\n            const user = await ctx.userService.get(+req.params.id)\n            ctx.ability.throwUnlessCan('delete', subject('User', user))\n            await ctx.userService.delete(user.id)\n        },\n    })\n)\n\nrouter.get(\n    '/card',\n    endpoint.create({\n        resolve: async ({ ctx }) => {\n            return ctx.userService.getMemberCard(\n                ctx.user!.memberId,\n                env.NX_CLIENT_URL_CUSTOM || env.NX_CLIENT_URL\n            )\n        },\n    })\n)\n\nrouter.post(\n    '/send-test-email',\n    endpoint.create({\n        input: z.object({\n            recipient: z.string(),\n            subject: z.string(),\n            body: z.string(),\n        }),\n        resolve: async ({ input, ctx, req }) => {\n            if (!req.user || req.user.role !== 'admin') throw new Error('Unauthorized')\n\n            try {\n                await ctx.emailService.send({\n                    to: input.recipient,\n                    subject: input.subject,\n                    textBody: input.body,\n                    htmlBody: input.body,\n                })\n            } catch (err) {\n                console.log('Error sending email', err)\n            }\n\n            ctx.logger.info(`Sent test email to ${input.recipient}`)\n\n            return { success: true }\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/valuations.router.ts",
    "content": "import { Router } from 'express'\nimport { z } from 'zod'\nimport { subject } from '@casl/ability'\nimport endpoint from '../lib/endpoint'\nimport { DateUtil } from '@maybe-finance/shared'\n\nconst router = Router()\n\nrouter.get(\n    '/:id',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const valuation = await ctx.valuationService.getValuation(+req.params.id)\n            ctx.ability.throwUnlessCan('read', subject('Valuation', valuation))\n\n            return valuation\n        },\n    })\n)\n\nrouter.put(\n    '/:id',\n    endpoint.create({\n        input: z\n            .object({\n                date: z.string().transform((d) => DateUtil.datetimeTransform(d).toJSDate()),\n                amount: z.number(),\n            })\n            .optional(),\n        resolve: async ({ ctx, input, req }) => {\n            const valuation = await ctx.valuationService.getValuation(+req.params.id)\n            ctx.ability.throwUnlessCan('update', subject('Valuation', valuation))\n\n            if (!input) return valuation\n\n            const updatedValuation = await ctx.valuationService.updateValuation(+req.params.id, {\n                date: input.date,\n                ...(input.amount && { amount: input.amount }),\n            })\n\n            await ctx.accountService.syncBalances(updatedValuation.accountId)\n\n            return updatedValuation\n        },\n    })\n)\n\nrouter.delete(\n    '/:id',\n    endpoint.create({\n        resolve: async ({ ctx, req }) => {\n            const valuation = await ctx.valuationService.getValuation(+req.params.id)\n            ctx.ability.throwUnlessCan('delete', subject('Valuation', valuation))\n\n            const deletedValuation = await ctx.valuationService.deleteValuation(+req.params.id)\n\n            await ctx.accountService.syncBalances(deletedValuation.accountId)\n\n            return deletedValuation\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/routes/webhooks.router.ts",
    "content": "import { Router } from 'express'\nimport { z } from 'zod'\nimport { validateTellerSignature } from '../middleware'\nimport endpoint from '../lib/endpoint'\nimport stripe from '../lib/stripe'\nimport env from '../../env'\nimport type { TellerTypes } from '@maybe-finance/teller-api'\n\nconst router = Router()\n\nrouter.post(\n    '/stripe/webhook',\n    endpoint.create({\n        async resolve({ req, ctx }) {\n            if (!req.headers['stripe-signature'])\n                throw new Error('Missing `stripe-signature` header')\n\n            let event\n            try {\n                event = stripe.webhooks.constructEvent(\n                    req.body,\n                    req.headers['stripe-signature'],\n                    env.NX_STRIPE_WEBHOOK_SECRET\n                )\n            } catch (err) {\n                ctx.logger.error(`Failed to construct Stripe event`, err)\n                throw new Error('Failed to construct Stripe event')\n            }\n\n            ctx.logger.info(`rx[stripe_webhook] type=${event.type} id=${event.id}`, event.data)\n\n            await ctx.stripeWebhooks.handleWebhook(event)\n\n            return { status: 'ok' }\n        },\n        onSuccess: (_, res, data) => res.status(200).json(data),\n    })\n)\n\nrouter.post(\n    '/teller/webhook',\n    process.env.NODE_ENV !== 'development' ? validateTellerSignature : (_req, _res, next) => next(),\n    endpoint.create({\n        input: z\n            .object({\n                id: z.string(),\n                payload: z.object({\n                    enrollment_id: z.string(),\n                    reason: z.string(),\n                }),\n                timestamp: z.string(),\n                type: z.string(),\n            })\n            .passthrough(),\n        async resolve({ input, ctx }) {\n            const { type, id, payload } = input\n\n            ctx.logger.info(\n                `rx[teller_webhook] event eventType=${type} eventId=${id} enrollmentId=${payload.enrollment_id}`\n            )\n\n            // May contain sensitive info, only print at the debug level\n            ctx.logger.debug(`rx[teller_webhook] event payload`, input)\n\n            try {\n                console.log('handling webhook')\n                await ctx.tellerWebhooks.handleWebhook(input as TellerTypes.WebhookData)\n            } catch (err) {\n                // record error but don't throw\n                ctx.logger.error(`[teller_webhook] error handling webhook`, err)\n            }\n\n            return { status: 'ok' }\n        },\n    })\n)\n\nexport default router\n"
  },
  {
    "path": "apps/server/src/app/trpc.ts",
    "content": "import * as trpc from '@trpc/server'\nimport type * as trpcExpress from '@trpc/server/adapters/express'\nimport { superjson } from '@maybe-finance/shared'\nimport { createContext } from './lib/endpoint'\n\nexport async function createTRPCContext({ req }: trpcExpress.CreateExpressContextOptions) {\n    return createContext(req)\n}\n\ntype Context = trpc.inferAsyncReturnType<typeof createTRPCContext>\n\nconst t = trpc.initTRPC.context<Context>().create({\n    transformer: superjson,\n})\n\n/**\n * Middleware\n */\nconst isUser = t.middleware(({ ctx, next }) => {\n    if (!ctx.user) {\n        throw new trpc.TRPCError({ code: 'UNAUTHORIZED', message: 'You must be a user' })\n    }\n\n    return next({\n        ctx: {\n            ...ctx,\n            user: ctx.user,\n        },\n    })\n})\n\n/**\n * Routers\n */\nexport const appRouter = t.router({\n    users: t.router({\n        me: t.procedure.use(isUser).query(({ ctx }) => ctx.user),\n    }),\n})\n\nexport type AppRouter = typeof appRouter\n"
  },
  {
    "path": "apps/server/src/assets/script.js",
    "content": "// eslint-disable-next-line\nfunction login() {\n    window.location.href = '/admin/login'\n}\n\n// eslint-disable-next-line\nfunction logout() {\n    window.location.href = '/admin/logout'\n}\n"
  },
  {
    "path": "apps/server/src/assets/styles.css",
    "content": "a {\n    text-decoration: none;\n}\n\n.links {\n    display: flex;\n    justify-content: center;\n    min-width: 350px;\n}\n\n.links .btn {\n    margin-left: 10px;\n}\n\nbody {\n    background-color: #242629;\n    height: 100vh;\n    color: white;\n}\n\n.unauthorized {\n    background-color: #f85c41;\n    padding: 10px 20px;\n    color: #f8f9fa;\n    border-radius: 5px;\n    margin-bottom: 40px;\n    text-align: center;\n    max-width: 250px;\n}\n\n.container {\n    height: 100%;\n    display: flex;\n    flex-direction: column;\n    justify-content: center;\n    align-items: center;\n    transform: translateY(-60px);\n}\n\n.btn {\n    background-color: #3bc9db;\n    padding: 10px;\n    color: #242629;\n    border: none;\n    border-radius: 5px;\n}\n\n.btn:hover {\n    background-color: rgba(102, 217, 232, 0.9);\n    cursor: pointer;\n}\n"
  },
  {
    "path": "apps/server/src/env.ts",
    "content": "import { z } from 'zod'\n\nconst toOriginArray = (s?: string) => {\n    if (!s) return []\n\n    const originList = (s || '').split(',').map((s) => s.trim())\n\n    return originList.map((origin) => {\n        const originParts = origin.split('.')\n\n        // Search for the specific pattern: domain.tld (e.g. maybe.co) and enable wildcard access on domain\n        if (originParts.length === 2) {\n            return new RegExp(`${originParts[0]}\\\\.${originParts[1]}`)\n        } else {\n            return origin\n        }\n    })\n}\n\nconst envSchema = z.object({\n    NX_API_URL: z.string().url().default('http://localhost:3333'),\n    NX_CDN_URL: z.string().url().default('https://staging-cdn.maybe.co'),\n    NX_WEBHOOK_URL: z.string().url().optional(),\n\n    NX_CLIENT_URL: z.string().url().default('http://localhost:4200'),\n    NX_CLIENT_URL_CUSTOM: z.string().url().default('http://localhost:4200'),\n\n    NX_REDIS_URL: z.string().default('redis://localhost:6379'),\n\n    NX_DATABASE_URL: z.string(),\n    NX_DATABASE_SECRET: z.string(),\n\n    NX_NGROK_URL: z.string().default('http://localhost:4551'),\n\n    NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),\n    NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),\n    NX_TELLER_ENV: z.string().default('sandbox'),\n\n    NX_SENTRY_DSN: z.string().optional(),\n    NX_SENTRY_ENV: z.string().optional(),\n\n    NX_POLYGON_API_KEY: z.string().default(''),\n    NX_POLYGON_TIER: z.string().default('basic'),\n\n    NX_PORT: z.string().default('3333'),\n    NX_CORS_ORIGINS: z.string().default('https://localhost.maybe.co').transform(toOriginArray),\n\n    NX_MORGAN_LOG_LEVEL: z\n        .string()\n        .default(process.env.NODE_ENV === 'development' ? 'dev' : 'combined'),\n\n    NX_STRIPE_SECRET_KEY: z.string().default('REPLACE_THIS'),\n    NX_STRIPE_WEBHOOK_SECRET: z.string().default('whsec_REPLACE_THIS'),\n    NX_STRIPE_PREMIUM_MONTHLY_PRICE_ID: z.string().default('price_REPLACE_THIS'),\n    NX_STRIPE_PREMIUM_YEARLY_PRICE_ID: z.string().default('price_REPLACE_THIS'),\n\n    NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'),\n    NX_CDN_PUBLIC_BUCKET: z.string().default('REPLACE_THIS'),\n\n    // Key to secrets manager value\n    NX_CDN_SIGNER_SECRET_ID: z.string().default('/apps/maybe-app/CLOUDFRONT_SIGNER1_PRIV'),\n\n    // Key to Cloudfront pub key\n    NX_CDN_SIGNER_PUBKEY_ID: z.string().default('REPLACE_THIS'),\n\n    NX_EMAIL_FROM_ADDRESS: z.string().default('account@maybe.co'),\n    NX_EMAIL_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'),\n    NX_EMAIL_PROVIDER: z.string().optional(),\n    NX_EMAIL_PROVIDER_API_TOKEN: z.string().optional(),\n})\n\nconst env = envSchema.parse(process.env)\n\nexport default env\n"
  },
  {
    "path": "apps/server/src/environments/environment.prod.ts",
    "content": "export const environment = {\n    production: true,\n}\n"
  },
  {
    "path": "apps/server/src/environments/environment.ts",
    "content": "export const environment = {\n    production: false,\n}\n"
  },
  {
    "path": "apps/server/src/main.ts",
    "content": "import type { AddressInfo } from 'net'\nimport env from './env'\nimport app from './app/app'\nimport logger from './app/lib/logger'\nimport * as Sentry from '@sentry/node'\n\nprocess.on('uncaughtException', (error) => {\n    Sentry.captureException(error)\n    logger.error('server: uncaught exception', error)\n})\n\nprocess.on('unhandledRejection', (reason, promise) => {\n    Sentry.captureException(reason)\n    logger.error(`server: unhandled promise rejection: ${promise}: ${reason}`)\n})\n\nconst server = app.listen(env.NX_PORT, () => {\n    logger.info(`🚀 API listening at http://localhost:${(server.address() as AddressInfo).port}`)\n})\n\n// Handle SIGTERM coming from ECS Fargate\nprocess.on('SIGTERM', () => server.close())\n\nserver.on('error', (err) => logger.error('Server failed to start from main.ts', err))\n"
  },
  {
    "path": "apps/server/tsconfig.app.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../dist/out-tsc\",\n        \"module\": \"commonjs\",\n        \"types\": [\"node\", \"express\"]\n    },\n    \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"jest.config.ts\"],\n    \"include\": [\"**/*.ts\", \"../../custom-express.d.ts\"]\n}\n"
  },
  {
    "path": "apps/server/tsconfig.json",
    "content": "{\n    \"extends\": \"../../tsconfig.base.json\",\n    \"compilerOptions\": {\n        \"esModuleInterop\": true,\n        \"noImplicitAny\": false,\n        \"strict\": true,\n        \"strictNullChecks\": true\n    },\n    \"files\": [],\n    \"include\": [],\n    \"references\": [\n        {\n            \"path\": \"./tsconfig.app.json\"\n        },\n        {\n            \"path\": \"./tsconfig.spec.json\"\n        }\n    ]\n}\n"
  },
  {
    "path": "apps/server/tsconfig.spec.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../dist/out-tsc\",\n        \"module\": \"commonjs\",\n        \"types\": [\"jest\", \"node\"]\n    },\n    \"include\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"../../custom-express.d.ts\", \"jest.config.ts\"]\n}\n"
  },
  {
    "path": "apps/workers/.eslintrc.json",
    "content": "{\n    \"extends\": [\"../../.eslintrc.json\"],\n    \"ignorePatterns\": [\"!**/*\", \"Dockerfile\"],\n    \"overrides\": [\n        {\n            \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.ts\", \"*.tsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.ts\"],\n            \"rules\": {\n                \"@typescript-eslint/no-unused-vars\": [\"warn\", { \"argsIgnorePattern\": \"^_\" }]\n            }\n        }\n    ]\n}\n"
  },
  {
    "path": "apps/workers/Dockerfile",
    "content": "# ------------------------------------------\n#                BUILD STAGE              \n# ------------------------------------------ \nFROM node:18-alpine3.18 as builder\n\nWORKDIR /app\nCOPY ./dist/apps/workers ./prisma ./\nRUN npm install -g pnpm\n# nrwl/nx#20079, generated lockfile is completely broken\nRUN rm -f pnpm-lock.yaml\nRUN pnpm install --prod --no-frozen-lockfile\n\n# ------------------------------------------\n#                PROD STAGE               \n# ------------------------------------------ \nFROM node:18-alpine3.18 as prod\n\n# Used for container health checks\nRUN apk add --no-cache curl\nWORKDIR /app\nUSER node \nCOPY --from=builder /app  .\n\nCMD [\"node\", \"--es-module-specifier-resolution=node\", \"./main.js\"]"
  },
  {
    "path": "apps/workers/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n    displayName: 'workers',\n    preset: '../../jest.preset.js',\n    globals: {\n        'ts-jest': {\n            tsconfig: '<rootDir>/tsconfig.spec.json',\n        },\n    },\n    testEnvironment: 'node',\n    transform: {\n        '^.+\\\\.[tj]s$': 'ts-jest',\n    },\n    moduleFileExtensions: ['ts', 'js', 'html'],\n    coverageDirectory: '../../coverage/apps/workers',\n}\n"
  },
  {
    "path": "apps/workers/src/app/__tests__/helpers/user.test-helper.ts",
    "content": "import type { PrismaClient, User } from '@prisma/client'\nimport { faker } from '@faker-js/faker'\n\nexport async function resetUser(prisma: PrismaClient, authId = '__TEST_USER_ID__'): Promise<User> {\n    try {\n        // eslint-disable-next-line\n        const [_, __, ___, user] = await prisma.$transaction([\n            prisma.$executeRaw`DELETE FROM \"user\" WHERE auth_id=${authId};`,\n\n            // Deleting a user does not cascade to securities, so delete all security records\n            prisma.$executeRaw`DELETE from security;`,\n            prisma.$executeRaw`DELETE from security_pricing;`,\n\n            prisma.user.create({\n                data: {\n                    authId,\n                    email: faker.internet.email(),\n                    tellerUserId: faker.string.uuid(),\n                },\n            }),\n        ])\n        return user\n    } catch (e) {\n        console.error('error in reset user transaction', e)\n        throw e\n    }\n}\n"
  },
  {
    "path": "apps/workers/src/app/__tests__/queue.integration.spec.ts",
    "content": "// =====================================================\n// Keep these imports above the rest to avoid errors\n// =====================================================\nimport { TellerGenerator } from 'tools/generators'\nimport type { User, AccountConnection } from '@prisma/client'\nimport { AccountConnectionType } from '@prisma/client'\nimport prisma from '../lib/prisma'\nimport { default as _teller } from '../lib/teller'\nimport { resetUser } from './helpers/user.test-helper'\nimport { Interval } from 'luxon'\n\n// Import the workers process\nimport '../../main'\nimport { queueService } from '../lib/di'\n\n// For TypeScript support\njest.mock('../lib/teller')\nconst teller = jest.mocked(_teller)\n\nlet user: User | null\nlet connection: AccountConnection\n\n// When debugging, we don't want the tests to time out\nif (process.env.IS_VSCODE_DEBUG === 'true') {\n    jest.setTimeout(100000)\n}\n\nbeforeEach(async () => {\n    jest.clearAllMocks()\n\n    user = await resetUser(prisma)\n\n    connection = await prisma.accountConnection.create({\n        data: {\n            name: 'Chase Test',\n            type: AccountConnectionType.teller,\n            tellerEnrollmentId: 'test-teller-item-workers',\n            tellerInstitutionId: 'chase_test',\n            tellerAccessToken:\n                'U2FsdGVkX1+WMq9lfTS9Zkbgrn41+XT1hvSK5ain/udRPujzjVCAx/lyPG7EumVZA+nVKXPauGwI+d7GZgtqTA9R3iCZNusU6LFPnmFOCE4=', // need correct encoding here\n            userId: user.id,\n            syncStatus: 'PENDING',\n        },\n    })\n})\n\nafterAll(async () => {\n    await prisma.$disconnect()\n})\n\ndescribe('Message queue tests', () => {\n    it('Creates the correct number of queues', () => {\n        expect(queueService.allQueues.map((q) => q.name)).toEqual([\n            'sync-user',\n            'sync-account',\n            'sync-account-connection',\n            'sync-security',\n            'purge-user',\n            'sync-institution',\n            'send-email',\n        ])\n    })\n\n    it('Should handle sync errors', async () => {\n        const syncQueue = queueService.getQueue('sync-account-connection')\n\n        teller.getAccounts.mockRejectedValueOnce(new Error('forced error for Jest tests'))\n\n        await syncQueue.add('sync-connection', { accountConnectionId: connection.id })\n\n        const updatedConnection = await prisma.accountConnection.findUnique({\n            where: { id: connection.id },\n        })\n\n        expect(teller.getAccounts).toHaveBeenCalledTimes(1)\n        expect(updatedConnection?.status).toEqual('ERROR')\n    })\n\n    it('Should run all sync-account-connection queue jobs', async () => {\n        const syncQueue = queueService.getQueue('sync-account-connection')\n\n        let cnx = await prisma.accountConnection.findUnique({ where: { id: connection.id } })\n\n        expect(cnx?.status).toEqual('OK')\n        expect(cnx?.syncStatus).toEqual('PENDING')\n\n        await syncQueue.add('sync-connection', { accountConnectionId: connection.id })\n\n        cnx = await prisma.accountConnection.findUnique({\n            where: { id: connection.id },\n        })\n\n        expect(cnx?.syncStatus).toEqual('IDLE')\n    })\n\n    xit('Should sync connected transaction account', async () => {\n        const syncQueue = queueService.getQueue('sync-account-connection')\n\n        // Mock will return a basic banking checking account\n        const mockAccounts = TellerGenerator.generateAccountsWithBalances({\n            count: 1,\n            institutionId: 'chase_test',\n            enrollmentId: 'test-teller-item-workers',\n            institutionName: 'Chase Test',\n            accountType: 'depository',\n            accountSubType: 'checking',\n        })\n        teller.getAccounts.mockResolvedValueOnce(mockAccounts)\n\n        const mockTransactions = TellerGenerator.generateTransactions(10, mockAccounts[0].id)\n        teller.getTransactions.mockResolvedValueOnce(mockTransactions)\n\n        await syncQueue.add('sync-connection', { accountConnectionId: connection.id })\n\n        expect(teller.getAccounts).toHaveBeenCalledTimes(1)\n        expect(teller.getTransactions).toHaveBeenCalledTimes(1)\n\n        const item = await prisma.accountConnection.findUniqueOrThrow({\n            where: { id: connection.id },\n            include: {\n                accounts: {\n                    include: {\n                        balances: {\n                            where: TellerGenerator.testDates.prismaWhereFilter,\n                            orderBy: { date: 'asc' },\n                        },\n                        transactions: true,\n                        holdings: true,\n                        valuations: true,\n                        investmentTransactions: true,\n                    },\n                },\n            },\n        })\n\n        expect(item.accounts).toHaveLength(1)\n\n        const [account] = item.accounts\n\n        const intervalDates = Interval.fromDateTimes(\n            TellerGenerator.lowerBound,\n            TellerGenerator.now\n        )\n            .splitBy({ day: 1 })\n            .map((date: Interval) => date.start.toISODate())\n\n        const startingBalance = Number(mockAccounts[0].balance.available)\n\n        const balances = TellerGenerator.calculateDailyBalances(\n            startingBalance,\n            mockTransactions,\n            intervalDates\n        )\n\n        expect(account.transactions).toHaveLength(10)\n        expect(account.balances.map((b) => b.balance)).toEqual(balances)\n        expect(account.holdings).toHaveLength(0)\n        expect(account.valuations).toHaveLength(0)\n        expect(account.investmentTransactions).toHaveLength(0)\n    })\n})\n"
  },
  {
    "path": "apps/workers/src/app/__tests__/security-sync.integration.spec.ts",
    "content": "import { PrismaClient, SecurityProvider } from '@prisma/client'\nimport winston from 'winston'\nimport Redis from 'ioredis'\nimport nock from 'nock'\nimport type { ISecurityPricingService } from '@maybe-finance/server/features'\nimport { SecurityPricingService } from '@maybe-finance/server/features'\nimport type { IMarketDataService } from '@maybe-finance/server/shared'\nimport {\n    RedisCacheBackend,\n    CacheService,\n    ServerUtil,\n    PolygonMarketDataService,\n} from '@maybe-finance/server/shared'\nimport { PolygonTestData } from '../../../../../tools/test-data'\n\nconst prisma = new PrismaClient()\n\nconst redis = new Redis(process.env.NX_REDIS_URL as string, {\n    retryStrategy: ServerUtil.redisRetryStrategy({ maxAttempts: 1 }),\n})\n\nbeforeAll(() => {\n    process.env.CI = 'true'\n    nock.disableNetConnect()\n\n    nock('https://api.polygon.io')\n        .get((uri) => uri.includes('/v2/snapshot/locale/us/markets/stocks/tickers'))\n        .reply(200, PolygonTestData.snapshotAllTickers)\n        .persist()\n\n    nock('https://api.polygon.io')\n        .get((uri) => uri.includes('/v2/aggs/grouped/locale/us/market/stocks'))\n        .reply(200, PolygonTestData.dailyPricing)\n        .persist()\n\n    nock('https://api.polygon.io')\n        .get((uri) => uri.includes('/v3/reference/exchanges'))\n        .reply(200, PolygonTestData.getExchanges)\n        .persist()\n\n    nock('https://api.polygon.io')\n        .get(\n            (uri) =>\n                uri.includes('/v3/reference/tickers') &&\n                uri.includes('market=stocks') &&\n                uri.includes('exchange=XNAS')\n        )\n        .reply(200, PolygonTestData.getNASDAQTickers)\n        .persist()\n\n    nock('https://api.polygon.io')\n        .get(\n            (uri) =>\n                uri.includes('/v3/reference/tickers') &&\n                uri.includes('market=stocks') &&\n                uri.includes('exchange=XNYS')\n        )\n        .reply(200, PolygonTestData.getNYSETickers)\n        .persist()\n})\n\nafterAll(async () => {\n    process.env.CI = ''\n    await Promise.allSettled([prisma.$disconnect(), redis.disconnect()])\n})\n\ndescribe('security pricing sync for non basic tier', () => {\n    let securityPricingService: ISecurityPricingService\n\n    beforeEach(async () => {\n        const logger = winston.createLogger({\n            level: 'debug',\n            transports: new winston.transports.Console({ format: winston.format.simple() }),\n        })\n\n        const cacheService = new CacheService(\n            logger.child({ service: 'CacheService' }),\n            new RedisCacheBackend(redis)\n        )\n\n        const marketDataService: IMarketDataService = new PolygonMarketDataService(\n            logger.child({ service: 'PolygonMarketDataService' }),\n            'TEST',\n            cacheService\n        )\n\n        securityPricingService = new SecurityPricingService(\n            logger.child({ service: 'SecurityPricingService' }),\n            prisma,\n            marketDataService\n        )\n\n        // reset db records\n        await prisma.security.deleteMany({\n            where: {\n                providerName: SecurityProvider.other,\n            },\n        })\n        await prisma.security.createMany({\n            data: [{ symbol: 'AAPL' }, { symbol: 'VOO' }],\n        })\n    })\n\n    it('syncs', async () => {\n        // sync 2x to catch any possible caching I/O issues\n        await securityPricingService.syncSecuritiesPricing()\n        await securityPricingService.syncSecuritiesPricing()\n    })\n})\n\ndescribe('security pricing sync for basic tier', () => {\n    let securityPricingService: ISecurityPricingService\n    let initialPolygonTier: string | undefined\n\n    beforeEach(async () => {\n        initialPolygonTier = process.env.NX_POLYGON_TIER\n        process.env.NX_POLYGON_TIER = 'basic' // Force basic tier to test code path\n        const logger = winston.createLogger({\n            level: 'debug',\n            transports: new winston.transports.Console({ format: winston.format.simple() }),\n        })\n\n        const cacheService = new CacheService(\n            logger.child({ service: 'CacheService' }),\n            new RedisCacheBackend(redis)\n        )\n\n        const marketDataService: IMarketDataService = new PolygonMarketDataService(\n            logger.child({ service: 'PolygonMarketDataService' }),\n            'TEST',\n            cacheService\n        )\n\n        securityPricingService = new SecurityPricingService(\n            logger.child({ service: 'SecurityPricingService' }),\n            prisma,\n            marketDataService\n        )\n\n        // reset db records\n        await prisma.security.deleteMany({\n            where: {\n                providerName: SecurityProvider.other,\n            },\n        })\n        await prisma.security.createMany({\n            data: [{ symbol: 'AAPL' }, { symbol: 'VOO' }],\n        })\n    })\n\n    afterEach(() => {\n        process.env.NX_POLYGON_TIER = initialPolygonTier\n    })\n\n    it('syncs', async () => {\n        // sync 2x to catch any possible caching I/O issues\n        await securityPricingService.syncSecuritiesPricing()\n        await securityPricingService.syncSecuritiesPricing()\n    })\n})\n\ndescribe('us stock ticker sync', () => {\n    const apiKey = process.env.NX_POLYGON_API_KEY\n    let securityPricingService: ISecurityPricingService\n\n    beforeEach(async () => {\n        if (!apiKey) {\n            process.env.NX_POLYGON_API_KEY = 'TEST_KEY'\n        }\n        const logger = winston.createLogger({\n            level: 'debug',\n            transports: new winston.transports.Console({ format: winston.format.simple() }),\n        })\n\n        const cacheService = new CacheService(\n            logger.child({ service: 'CacheService' }),\n            new RedisCacheBackend(redis)\n        )\n\n        const marketDataService: IMarketDataService = new PolygonMarketDataService(\n            logger.child({ service: 'PolygonMarketDataService' }),\n            'TEST',\n            cacheService\n        )\n\n        securityPricingService = new SecurityPricingService(\n            logger.child({ service: 'SecurityPricingService' }),\n            prisma,\n            marketDataService\n        )\n\n        // reset db records\n        await prisma.security.deleteMany()\n    })\n\n    afterEach(() => {\n        process.env.NX_POLYGON_API_KEY = apiKey // Restore original key\n    })\n\n    it('syncs', async () => {\n        // sync 2x to catch any possible caching I/O issues\n        await securityPricingService.syncUSStockTickers()\n        await securityPricingService.syncUSStockTickers()\n        expect(await prisma.security.count()).toEqual(20)\n    })\n})\n"
  },
  {
    "path": "apps/workers/src/app/__tests__/teller.integration.spec.ts",
    "content": "import type { User } from '@prisma/client'\nimport { TellerGenerator } from '../../../../../tools/generators'\nimport { TellerApi } from '@maybe-finance/teller-api'\njest.mock('@maybe-finance/teller-api')\nimport {\n    TellerETL,\n    TellerService,\n    type IAccountConnectionProvider,\n} from '@maybe-finance/server/features'\nimport { createLogger } from '@maybe-finance/server/shared'\nimport prisma from '../lib/prisma'\nimport { resetUser } from './helpers/user.test-helper'\nimport { transports } from 'winston'\nimport { cryptoService } from '../lib/di'\n\nconst logger = createLogger({ level: 'debug', transports: [new transports.Console()] })\nconst teller = jest.mocked(new TellerApi())\nconst tellerETL = new TellerETL(logger, prisma, teller, cryptoService)\nconst service: IAccountConnectionProvider = new TellerService(\n    logger,\n    prisma,\n    teller,\n    tellerETL,\n    cryptoService,\n    'TELLER_WEBHOOK_URL',\n    true\n)\n\nafterAll(async () => {\n    await prisma.$disconnect()\n})\n\ndescribe('Teller', () => {\n    let user: User\n\n    beforeEach(async () => {\n        jest.clearAllMocks()\n\n        user = await resetUser(prisma)\n    })\n\n    it('syncs connection', async () => {\n        const tellerConnection = TellerGenerator.generateConnection()\n        const tellerAccounts = tellerConnection.accountsWithBalances\n        const tellerTransactions = tellerConnection.transactions\n\n        teller.getAccounts.mockResolvedValue(tellerAccounts)\n\n        teller.getTransactions.mockImplementation(async ({ accountId }) => {\n            return Promise.resolve(tellerTransactions.filter((t) => t.account_id === accountId))\n        })\n\n        const connection = await prisma.accountConnection.create({\n            data: {\n                userId: user.id,\n                name: 'TEST_TELLER',\n                type: 'teller',\n                tellerEnrollmentId: tellerConnection.enrollment.enrollment.id,\n                tellerInstitutionId: tellerConnection.enrollment.institutionId,\n                tellerAccessToken: cryptoService.encrypt(tellerConnection.enrollment.accessToken),\n            },\n        })\n\n        await service.sync(connection)\n\n        const { accounts } = await prisma.accountConnection.findUniqueOrThrow({\n            where: {\n                id: connection.id,\n            },\n            include: {\n                accounts: {\n                    include: {\n                        transactions: true,\n                        investmentTransactions: true,\n                        holdings: true,\n                        valuations: true,\n                    },\n                },\n            },\n        })\n\n        // all accounts\n        expect(accounts).toHaveLength(tellerConnection.accounts.length)\n        for (const account of accounts) {\n            expect(account.transactions).toHaveLength(\n                tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length\n            )\n        }\n\n        // credit accounts\n        const creditAccounts = tellerAccounts.filter((a) => a.type === 'credit')\n        expect(accounts.filter((a) => a.type === 'CREDIT')).toHaveLength(creditAccounts.length)\n        for (const creditAccount of creditAccounts) {\n            const account = accounts.find((a) => a.tellerAccountId === creditAccount.id)!\n            expect(account.transactions).toHaveLength(\n                tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length\n            )\n            expect(account.holdings).toHaveLength(0)\n            expect(account.valuations).toHaveLength(0)\n            expect(account.investmentTransactions).toHaveLength(0)\n        }\n\n        // depository accounts\n        const depositoryAccounts = tellerAccounts.filter((a) => a.type === 'depository')\n        expect(accounts.filter((a) => a.type === 'DEPOSITORY')).toHaveLength(\n            depositoryAccounts.length\n        )\n        for (const depositoryAccount of depositoryAccounts) {\n            const account = accounts.find((a) => a.tellerAccountId === depositoryAccount.id)!\n            expect(account.transactions).toHaveLength(\n                tellerTransactions.filter((t) => t.account_id === account.tellerAccountId).length\n            )\n            expect(account.holdings).toHaveLength(0)\n            expect(account.valuations).toHaveLength(0)\n            expect(account.investmentTransactions).toHaveLength(0)\n        }\n    })\n})\n"
  },
  {
    "path": "apps/workers/src/app/lib/di.ts",
    "content": "import type {\n    IAccountConnectionProcessor,\n    IAccountProcessor,\n    IAccountQueryService,\n    IAccountService,\n    ISecurityPricingProcessor,\n    IInstitutionService,\n    IUserProcessor,\n    ISecurityPricingService,\n    IUserService,\n    IEmailProcessor,\n} from '@maybe-finance/server/features'\nimport {\n    AccountConnectionProcessor,\n    AccountConnectionProviderFactory,\n    AccountConnectionService,\n    AccountProcessor,\n    AccountProviderFactory,\n    AccountQueryService,\n    AccountService,\n    BalanceSyncStrategyFactory,\n    InstitutionProviderFactory,\n    InstitutionService,\n    InvestmentTransactionBalanceSyncStrategy,\n    LoanBalanceSyncStrategy,\n    TellerETL,\n    TellerService,\n    SecurityPricingProcessor,\n    SecurityPricingService,\n    TransactionBalanceSyncStrategy,\n    UserProcessor,\n    UserService,\n    ValuationBalanceSyncStrategy,\n    EmailService,\n    EmailProcessor,\n    TransactionService,\n} from '@maybe-finance/server/features'\nimport type { IMarketDataService } from '@maybe-finance/server/shared'\nimport {\n    BullQueueFactory,\n    CacheService,\n    CryptoService,\n    InMemoryQueueFactory,\n    PgService,\n    PolygonMarketDataService,\n    QueueService,\n    RedisCacheBackend,\n    ServerUtil,\n} from '@maybe-finance/server/shared'\nimport Redis from 'ioredis'\nimport logger from './logger'\nimport prisma from './prisma'\nimport teller from './teller'\nimport { initializeEmailClient } from './email'\nimport stripe from './stripe'\nimport env from '../../env'\nimport { BullQueueEventHandler, WorkerErrorHandlerService } from '../services'\n\n// shared services\n\nconst redis = new Redis(env.NX_REDIS_URL, {\n    retryStrategy: ServerUtil.redisRetryStrategy({ maxAttempts: 5 }),\n})\n\nexport const cryptoService = new CryptoService(env.NX_DATABASE_SECRET)\nexport const pgService = new PgService(logger.child({ service: 'PgService' }), env.NX_DATABASE_URL)\n\nexport const queueService = new QueueService(\n    logger.child({ service: 'QueueService' }),\n    process.env.NODE_ENV === 'test'\n        ? new InMemoryQueueFactory()\n        : new BullQueueFactory(\n              logger.child({ service: 'BullQueueFactory' }),\n              env.NX_REDIS_URL,\n              new BullQueueEventHandler(logger.child({ service: 'BullQueueEventHandler' }), prisma)\n          )\n)\n\nconst cacheService = new CacheService(\n    logger.child({ service: 'CacheService' }),\n    new RedisCacheBackend(redis)\n)\n\nexport const marketDataService: IMarketDataService = new PolygonMarketDataService(\n    logger.child({ service: 'PolygonMarketDataService' }),\n    env.NX_POLYGON_API_KEY,\n    cacheService\n)\n\nexport const securityPricingService: ISecurityPricingService = new SecurityPricingService(\n    logger.child({ service: 'SecurityPricingService' }),\n    prisma,\n    marketDataService\n)\n\n// providers\n\nconst tellerService = new TellerService(\n    logger.child({ service: 'TellerService' }),\n    prisma,\n    teller,\n    new TellerETL(logger.child({ service: 'TellerETL' }), prisma, teller, cryptoService),\n    cryptoService,\n    '',\n    env.NX_TELLER_ENV === 'sandbox'\n)\n\n// account-connection\n\nconst accountConnectionProviderFactory = new AccountConnectionProviderFactory({\n    teller: tellerService,\n})\n\nconst transactionStrategy = new TransactionBalanceSyncStrategy(\n    logger.child({ service: 'TransactionBalanceSyncStrategy' }),\n    prisma\n)\n\nconst investmentTransactionStrategy = new InvestmentTransactionBalanceSyncStrategy(\n    logger.child({ service: 'InvestmentTransactionBalanceSyncStrategy' }),\n    prisma\n)\n\nconst valuationStrategy = new ValuationBalanceSyncStrategy(\n    logger.child({ service: 'ValuationBalanceSyncStrategy' }),\n    prisma\n)\n\nconst loanStrategy = new LoanBalanceSyncStrategy(\n    logger.child({ service: 'LoanBalanceSyncStrategy' }),\n    prisma\n)\n\nconst balanceSyncStrategyFactory = new BalanceSyncStrategyFactory({\n    INVESTMENT: investmentTransactionStrategy,\n    DEPOSITORY: transactionStrategy,\n    CREDIT: transactionStrategy,\n    LOAN: loanStrategy,\n    PROPERTY: valuationStrategy,\n    VEHICLE: valuationStrategy,\n    OTHER_ASSET: valuationStrategy,\n    OTHER_LIABILITY: valuationStrategy,\n})\n\nexport const accountConnectionService = new AccountConnectionService(\n    logger.child({ service: 'AccountConnectionService' }),\n    prisma,\n    accountConnectionProviderFactory,\n    balanceSyncStrategyFactory,\n    securityPricingService,\n    queueService.getQueue('sync-account-connection')\n)\n\nconst transactionService = new TransactionService(\n    logger.child({ service: 'TransactionService' }),\n    prisma\n)\n\nexport const accountConnectionProcessor: IAccountConnectionProcessor =\n    new AccountConnectionProcessor(\n        logger.child({ service: 'AccountConnectionProcessor' }),\n        accountConnectionService,\n        transactionService,\n        accountConnectionProviderFactory\n    )\n\n// account\n\nexport const accountQueryService: IAccountQueryService = new AccountQueryService(\n    logger.child({ service: 'AccountQueryService' }),\n    pgService\n)\n\nexport const accountService: IAccountService = new AccountService(\n    logger.child({ service: 'AccountService' }),\n    prisma,\n    accountQueryService,\n    queueService.getQueue('sync-account'),\n    queueService.getQueue('sync-account-connection'),\n    balanceSyncStrategyFactory\n)\n\nconst accountProviderFactory = new AccountProviderFactory({\n    // Since these are not in use yet, just commenting out for now\n    // property: propertyService,\n    // vehicle: vehicleService,\n})\n\nexport const accountProcessor: IAccountProcessor = new AccountProcessor(\n    logger.child({ service: 'AccountProcessor' }),\n    accountService,\n    accountProviderFactory\n)\n\n// user\n\nexport const userService: IUserService = new UserService(\n    logger.child({ service: 'UserService' }),\n    prisma,\n    accountQueryService,\n    balanceSyncStrategyFactory,\n    queueService.getQueue('sync-user'),\n    queueService.getQueue('purge-user'),\n    stripe\n)\n\nexport const userProcessor: IUserProcessor = new UserProcessor(\n    logger.child({ service: 'UserProcessor' }),\n    prisma,\n    userService,\n    accountService,\n    accountConnectionService,\n    accountConnectionProviderFactory\n)\n\n// security-pricing\n\nexport const securityPricingProcessor: ISecurityPricingProcessor = new SecurityPricingProcessor(\n    logger.child({ service: 'SecurityPricingProcessor' }),\n    securityPricingService\n)\n\n// institution\n\nconst institutionProviderFactory = new InstitutionProviderFactory({\n    TELLER: tellerService,\n})\n\nexport const institutionService: IInstitutionService = new InstitutionService(\n    logger.child({ service: 'InstitutionService' }),\n    prisma,\n    pgService,\n    institutionProviderFactory\n)\n\n// worker services\n\nexport const workerErrorHandlerService = new WorkerErrorHandlerService(\n    logger.child({ service: 'WorkerErrorHandlerService' })\n)\n\n// send-email\n\nexport const emailService: EmailService = new EmailService(\n    logger.child({ service: 'EmailService' }),\n    initializeEmailClient(),\n    {\n        from: env.NX_EMAIL_FROM_ADDRESS,\n        replyTo: env.NX_EMAIL_REPLY_TO_ADDRESS,\n    }\n)\n\nexport const emailProcessor: IEmailProcessor = new EmailProcessor(\n    logger.child({ service: 'EmailProcessor' }),\n    prisma,\n    emailService\n)\n"
  },
  {
    "path": "apps/workers/src/app/lib/email.ts",
    "content": "import { ServerClient as PostmarkServerClient } from 'postmark'\nimport nodemailer from 'nodemailer'\nimport type SMTPTransport from 'nodemailer/lib/smtp-transport'\nimport env from '../../env'\n\nexport function initializeEmailClient() {\n    switch (env.NX_EMAIL_PROVIDER) {\n        case 'postmark':\n            if (env.NX_EMAIL_PROVIDER_API_TOKEN) {\n                return new PostmarkServerClient(env.NX_EMAIL_PROVIDER_API_TOKEN)\n            } else {\n                return undefined\n            }\n        case 'smtp':\n            if (\n                !process.env.NX_EMAIL_SMTP_HOST ||\n                !process.env.NX_EMAIL_SMTP_PORT ||\n                !process.env.NX_EMAIL_SMTP_USERNAME ||\n                !process.env.NX_EMAIL_SMTP_PASSWORD\n            ) {\n                return undefined\n            } else {\n                const transportOptions: SMTPTransport.Options = {\n                    host: process.env.NX_EMAIL_SMTP_HOST,\n                    port: Number(process.env.NX_EMAIL_SMTP_PORT),\n                    secure: process.env.NX_EMAIL_SMTP_SECURE === 'true',\n                    auth: {\n                        user: process.env.NX_EMAIL_SMTP_USERNAME,\n                        pass: process.env.NX_EMAIL_SMTP_PASSWORD,\n                    },\n                }\n                return nodemailer.createTransport(transportOptions)\n            }\n        default:\n            return undefined\n    }\n}\n"
  },
  {
    "path": "apps/workers/src/app/lib/logger.ts",
    "content": "import { createLogger } from '@maybe-finance/server/shared'\n\nconst logger = createLogger({\n    level: 'info',\n})\n\nexport default logger\n"
  },
  {
    "path": "apps/workers/src/app/lib/prisma.ts",
    "content": "import { PrismaClient } from '@prisma/client'\nimport { DbUtil } from '@maybe-finance/server/shared'\nimport globalLogger from './logger'\n\nconst logger = globalLogger.child({ service: 'PrismaClient' })\n\n// https://stackoverflow.com/a/68328402\ndeclare global {\n    var prisma: PrismaClient | undefined // eslint-disable-line\n}\n\nfunction createPrismaClient() {\n    const prisma = new PrismaClient({\n        log: [\n            { emit: 'event', level: 'query' },\n            { emit: 'event', level: 'info' },\n            { emit: 'event', level: 'warn' },\n            { emit: 'event', level: 'error' },\n        ],\n    })\n\n    prisma.$on('query', ({ query, params, duration, ...data }) => {\n        logger.silly(`Query: ${query}, Params: ${params}, Duration: ${duration}`, { ...data })\n    })\n\n    prisma.$on('info', ({ message, ...data }) => {\n        logger.info(message, { ...data })\n    })\n\n    prisma.$on('warn', ({ message, ...data }) => {\n        logger.warn(message, { ...data })\n    })\n\n    prisma.$on('error', ({ message, ...data }) => {\n        logger.error(message, { ...data })\n    })\n\n    prisma.$use(DbUtil.slowQueryMiddleware(logger))\n\n    return prisma\n}\n\n// Prevent multiple instances of Prisma Client in development\n// https://www.prisma.io/docs/guides/performance-and-optimization/connection-management#prevent-hot-reloading-from-creating-new-instances-of-prismaclient\n// https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/instantiate-prisma-client#the-number-of-prismaclient-instances-matters\nconst prisma = global.prisma || createPrismaClient()\n\nif (process.env.NODE_ENV === 'development') global.prisma = prisma\n\nexport default prisma\n"
  },
  {
    "path": "apps/workers/src/app/lib/stripe.ts",
    "content": "import Stripe from 'stripe'\nimport env from '../../env'\n\nconst stripe = new Stripe(env.NX_STRIPE_SECRET_KEY, { apiVersion: '2022-08-01' })\n\nexport default stripe\n"
  },
  {
    "path": "apps/workers/src/app/lib/teller.ts",
    "content": "import { TellerApi } from '@maybe-finance/teller-api'\n\nconst teller = new TellerApi()\n\nexport default teller\n"
  },
  {
    "path": "apps/workers/src/app/services/bull-queue-event-handler.ts",
    "content": "import type { PrismaClient, User } from '@prisma/client'\nimport type { Job } from 'bull'\nimport type { Logger } from 'winston'\nimport type { BullQueue, IBullQueueEventHandler } from '@maybe-finance/server/shared'\nimport * as Sentry from '@sentry/node'\nimport { ErrorUtil } from '@maybe-finance/server/shared'\n\nconst printJob = (job: Job) =>\n    `Job{queue=${job.queue.name} id=${job.id} name=${job.name} ts=${\n        job.timestamp\n    } data=${JSON.stringify(job.data)}}`\n\nexport class BullQueueEventHandler implements IBullQueueEventHandler {\n    constructor(private readonly logger: Logger, private readonly prisma: PrismaClient) {}\n\n    onQueueCreated({ queue }: BullQueue) {\n        // https://github.com/OptimalBits/bull/blob/develop/REFERENCE.md#events\n        queue.on('active', (job, _jobPromise) => {\n            this.logger.info(`[job.active] ${printJob(job)}`)\n        })\n\n        queue.on('completed', (job, _result) => {\n            this.logger.info(`[job.completed] ${printJob(job)}`)\n        })\n\n        queue.on('stalled', async (job) => {\n            this.logger.warn(`[job.stalled] ${printJob(job)}`)\n        })\n\n        queue.on('lock-extension-failed', (job, err) => {\n            this.logger.warn(`[job.lock-extension-failed] ${printJob(job)}`, { err })\n        })\n\n        queue.on('progress', (job, progress) => {\n            this.logger.info(`[job.progress] ${printJob(job)}`, { progress })\n        })\n\n        queue.on('failed', async (job, error) => {\n            this.logger.error(`[job.failed] ${printJob(job)}`, { error })\n\n            const user = await this.getUserFromJob(job)\n\n            Sentry.withScope((scope) => {\n                scope.setUser(user ? {} : null)\n\n                scope.setTags({\n                    'queue.name': job.queue.name,\n                    'job.name': job.name,\n                })\n\n                scope.setContext('Job Info', {\n                    queue: job.queue.name,\n                    job: job.name,\n                    attempts: job.attemptsMade,\n                    data: job.data,\n                })\n\n                const err = ErrorUtil.parseError(error)\n\n                Sentry.captureException(error, {\n                    level: 'error',\n                    tags: err.sentryTags,\n                    contexts: err.sentryContexts,\n                })\n            })\n        })\n\n        queue.on('error', async (error) => {\n            this.logger.error(`[queue.error]`, { error })\n\n            const err = ErrorUtil.parseError(error)\n\n            Sentry.captureException(error, {\n                level: 'error',\n                tags: err.sentryTags,\n                contexts: {\n                    ...err.sentryContexts,\n                    queue: { name: queue.name },\n                },\n            })\n        })\n    }\n\n    private async getUserFromJob(job: Job) {\n        let user: Pick<User, 'id' | 'authId'> | undefined\n\n        try {\n            if (job.queue.name === 'sync-account' && 'accountId' in job.data) {\n                const account = await this.prisma.account.findUniqueOrThrow({\n                    where: { id: job.data.accountId },\n                    include: {\n                        accountConnection: { include: { user: true } },\n                        user: true,\n                    },\n                })\n\n                user = account.user ?? account.accountConnection?.user\n            }\n\n            if (job.queue.name === 'sync-account-connection' && 'accountConnectionId' in job.data) {\n                const accountConnection = await this.prisma.accountConnection.findUniqueOrThrow({\n                    where: { id: job.data.accountConnectionId },\n                    include: {\n                        user: true,\n                    },\n                })\n\n                user = accountConnection.user\n            }\n\n            return user\n        } catch (err) {\n            // Gracefully return if no user identified successfully\n            return null\n        }\n    }\n}\n"
  },
  {
    "path": "apps/workers/src/app/services/index.ts",
    "content": "export * from './worker-error.service'\nexport * from './bull-queue-event-handler'\n"
  },
  {
    "path": "apps/workers/src/app/services/worker-error.service.ts",
    "content": "import type { Logger } from 'winston'\nimport * as Sentry from '@sentry/node'\nimport { ErrorUtil } from '@maybe-finance/server/shared'\n\ntype WorkerErrorContext = { variant: 'unhandled'; error: unknown }\n\nexport class WorkerErrorHandlerService {\n    constructor(private readonly logger: Logger) {}\n\n    async handleWorkersError(ctx: WorkerErrorContext) {\n        const err = ErrorUtil.parseError(ctx.error)\n\n        switch (ctx.variant) {\n            case 'unhandled':\n                this.logger.error(`[workers-unhandled] ${err.message}`, { error: err.metadata })\n\n                Sentry.captureException(ctx.error, {\n                    level: 'error',\n                    tags: err.sentryTags,\n                    contexts: err.sentryContexts,\n                })\n\n                break\n            default:\n                return\n        }\n    }\n}\n"
  },
  {
    "path": "apps/workers/src/assets/.gitkeep",
    "content": ""
  },
  {
    "path": "apps/workers/src/env.ts",
    "content": "import { z } from 'zod'\n\nconst envSchema = z.object({\n    NX_PORT: z.string().default('3334'),\n\n    NX_DATABASE_URL: z.string(),\n    NX_DATABASE_SECRET: z.string(),\n\n    NX_TELLER_SIGNING_SECRET: z.string().default('REPLACE_THIS'),\n    NX_TELLER_APP_ID: z.string().default('REPLACE_THIS'),\n    NX_TELLER_ENV: z.string().default('sandbox'),\n\n    NX_SENTRY_DSN: z.string().optional(),\n    NX_SENTRY_ENV: z.string().optional(),\n\n    NX_REDIS_URL: z.string().default('redis://localhost:6379'),\n\n    NX_POLYGON_API_KEY: z.string().default(''),\n    NX_POLYGON_TIER: z.string().default('basic'),\n\n    NX_EMAIL_FROM_ADDRESS: z.string().default('account@maybe.co'),\n    NX_EMAIL_REPLY_TO_ADDRESS: z.string().default('support@maybe.co'),\n    NX_EMAIL_PROVIDER: z.string().optional(),\n    NX_EMAIL_PROVIDER_API_TOKEN: z.string().optional(),\n\n    NX_STRIPE_SECRET_KEY: z.string().default('sk_test_REPLACE_THIS'),\n\n    NX_CDN_PRIVATE_BUCKET: z.string().default('REPLACE_THIS'),\n    NX_CDN_PUBLIC_BUCKET: z.string().default('REPLACE_THIS'),\n\n    STRIPE_API_KEY: z.string().optional(),\n})\n\nconst env = envSchema.parse(process.env)\n\nexport default env\n"
  },
  {
    "path": "apps/workers/src/environments/environment.prod.ts",
    "content": "export const environment = {\n    production: true,\n}\n"
  },
  {
    "path": "apps/workers/src/environments/environment.ts",
    "content": "export const environment = {\n    production: false,\n}\n"
  },
  {
    "path": "apps/workers/src/main.ts",
    "content": "import express from 'express'\nimport cors from 'cors'\nimport * as Sentry from '@sentry/node'\nimport * as SentryTracing from '@sentry/tracing'\nimport { BullQueue } from '@maybe-finance/server/shared'\nimport logger from './app/lib/logger'\nimport prisma from './app/lib/prisma'\nimport {\n    accountConnectionProcessor,\n    accountProcessor,\n    institutionService,\n    queueService,\n    securityPricingProcessor,\n    userProcessor,\n    emailProcessor,\n    workerErrorHandlerService,\n} from './app/lib/di'\nimport env from './env'\nimport { cleanUpOutdatedJobs } from './utils'\n\n// Defaults from quickstart - https://docs.sentry.io/platforms/node/\nSentry.init({\n    dsn: env.NX_SENTRY_DSN,\n    environment: env.NX_SENTRY_ENV,\n    maxValueLength: 8196,\n    integrations: [\n        new Sentry.Integrations.Http({ tracing: true }),\n        new SentryTracing.Integrations.Postgres(),\n        new SentryTracing.Integrations.Prisma({ client: prisma }),\n    ],\n    tracesSampleRate: 1.0,\n})\n\nconst syncUserQueue = queueService.getQueue('sync-user')\nconst syncConnectionQueue = queueService.getQueue('sync-account-connection')\nconst syncAccountQueue = queueService.getQueue('sync-account')\nconst syncSecurityQueue = queueService.getQueue('sync-security')\nconst purgeUserQueue = queueService.getQueue('purge-user')\nconst syncInstitutionQueue = queueService.getQueue('sync-institution')\nconst sendEmailQueue = queueService.getQueue('send-email')\n\nsyncUserQueue.process(\n    'sync-user',\n    async (job) => {\n        await userProcessor.sync(job.data)\n    },\n    { concurrency: 4 }\n)\n\nsyncAccountQueue.process(\n    'sync-account',\n    async (job) => {\n        await accountProcessor.sync(job.data)\n    },\n    { concurrency: 4 }\n)\n\n/**\n * sync-account-connection queue\n */\nsyncConnectionQueue.process(\n    'sync-connection',\n    async (job) => {\n        await accountConnectionProcessor.sync(job.data, async (progress) => {\n            try {\n                await job.progress(progress)\n            } catch (e) {\n                logger.warn('Failed to update SYNC_CONNECTION job progress', job.data)\n            }\n        })\n    },\n    { concurrency: 4 }\n)\n\n/**\n * sync-security queue\n */\nsyncSecurityQueue.process(\n    'sync-all-securities',\n    async () => await securityPricingProcessor.syncAll()\n)\n\n/**\n * sync-us-stock-ticker queue\n */\nsyncSecurityQueue.process(\n    'sync-us-stock-tickers',\n    async () => await securityPricingProcessor.syncUSStockTickers()\n)\n\n/**\n * purge-user queue\n */\npurgeUserQueue.process(\n    'purge-user',\n    async (job) => {\n        await userProcessor.delete(job.data)\n    },\n    { concurrency: 4 }\n)\n\n/**\n * sync-all-securities queue\n */\n\n// If no securities exist, sync them immediately\n// Otherwise, schedule the job to run every 24 hours\n// Use same jobID to prevent duplicates and rate limiting\nsyncSecurityQueue.cancelJobs().then(() => {\n    if (!env.NX_POLYGON_API_KEY) {\n        logger.warn('No Polygon API key found, skipping adding jobs to queue')\n        return\n    }\n    prisma.security\n        .count({\n            where: {\n                providerName: 'polygon',\n            },\n        })\n        .then((count) => {\n            // If no securities exist, sync them immediately except in development\n            // In development, sync manually to avoid hot reloads causing rate issues\n            if (count === 0 && process.env.NODE_ENV !== 'development') {\n                syncSecurityQueue.add(\n                    'sync-us-stock-tickers',\n                    {},\n                    {\n                        delay: 15_000,\n                        removeOnFail: true,\n                    }\n                )\n            } else {\n                syncSecurityQueue.add(\n                    'sync-us-stock-tickers',\n                    {},\n                    {\n                        repeat: { cron: '0 */24 * * *' }, // Run every 24 hours\n                        jobId: Date.now().toString(),\n                    }\n                )\n            }\n            // Do not run if on the free tier (rate limits)\n            if (env.NX_POLYGON_TIER !== 'basic') {\n                syncSecurityQueue.add(\n                    'sync-all-securities',\n                    {},\n                    {\n                        repeat: { cron: '*/5 * * * *' }, // Run every 5 minutes\n                        jobId: Date.now().toString(),\n                    }\n                )\n            } else if (env.NX_POLYGON_TIER === 'basic') {\n                syncSecurityQueue.add(\n                    'sync-all-securities',\n                    {},\n                    {\n                        repeat: { cron: '* 3 * * *' }, // Run at 3am to avoid rate limits\n                        jobId: Date.now().toString(),\n                    }\n                )\n            }\n        })\n})\n\n/**\n * sync-institution queue\n */\nsyncInstitutionQueue.process(\n    'sync-teller-institutions',\n    async () => await institutionService.sync('TELLER')\n)\n\nsyncInstitutionQueue.add(\n    'sync-teller-institutions',\n    {},\n    {\n        repeat: { cron: '0 */24 * * *' }, // Run every 24 hours\n        jobId: Date.now().toString(),\n    }\n)\n\n/**\n * send-email queue\n */\nsendEmailQueue.process('send-email', async (job) => await emailProcessor.send(job.data))\n\nif (env.STRIPE_API_KEY) {\n    sendEmailQueue.add(\n        'send-email',\n        { type: 'trial-reminders' },\n        { repeat: { cron: '0 */12 * * *' } } // Run every 12 hours\n    )\n}\n\n// Fallback - usually triggered by errors not handled (or thrown) within the Bull event handlers (see above)\nprocess.on(\n    'uncaughtException',\n    async (error) =>\n        await workerErrorHandlerService.handleWorkersError({ variant: 'unhandled', error })\n)\n\n// Fallback - usually triggered by errors not handled (or thrown) within the Bull event handlers (see above)\nprocess.on(\n    'unhandledRejection',\n    async (error) =>\n        await workerErrorHandlerService.handleWorkersError({ variant: 'unhandled', error })\n)\n\n// Replace any jobs that have changed cron schedules and ensures only\n// one repeatable jobs for each type is running\nconst queues = [syncSecurityQueue, syncInstitutionQueue]\ncleanUpOutdatedJobs(queues)\n\nconst app = express()\n\napp.use(cors())\n\n// Make sure that at least 1 of the queues is ready and Redis is connected properly\napp.get('/health', (_req, res, _next) => {\n    syncConnectionQueue\n        .isHealthy()\n        .then((isHealthy) => {\n            if (isHealthy) {\n                res.status(200).json({ success: true, message: 'Queue is healthy' })\n            } else {\n                res.status(500).json({ success: false, message: 'Queue is not healthy' })\n            }\n        })\n        .catch((err) => {\n            console.log(err)\n            res.status(500).json({ success: false, message: 'Queue health check failed' })\n        })\n})\n\nconst server = app.listen(env.NX_PORT, () => {\n    logger.info(`Worker health server started on port ${env.NX_PORT}`)\n})\n\nasync function onShutdown() {\n    logger.info('[shutdown.start]')\n\n    await new Promise((resolve) => server.close(resolve))\n\n    // shutdown queues\n    try {\n        await Promise.allSettled(\n            queueService.allQueues\n                .filter((q): q is BullQueue => q instanceof BullQueue)\n                .map((q) => q.queue.close())\n        )\n    } catch (error) {\n        logger.error('[shutdown.error]', error)\n    } finally {\n        logger.info('[shutdown.complete]')\n        process.exitCode = 0\n    }\n}\n\nprocess.on('SIGINT', onShutdown)\nprocess.on('SIGTERM', onShutdown)\nprocess.on('exit', (code) => logger.info(`[exit] code=${code}`))\n\nlogger.info(`🚀 worker started`)\n"
  },
  {
    "path": "apps/workers/src/utils.ts",
    "content": "import type { IQueue } from '@maybe-finance/server/shared'\nimport type { JobInformation } from 'bull'\n\nexport async function cleanUpOutdatedJobs(queues: IQueue[]) {\n    for (const queue of queues) {\n        const repeatedJobs = await queue.getRepeatableJobs()\n\n        const outdatedJobs = filterOutdatedJobs(repeatedJobs)\n        for (const job of outdatedJobs) {\n            await queue.removeRepeatableByKey(job.key)\n        }\n    }\n}\n\nfunction filterOutdatedJobs(jobs: JobInformation[]) {\n    const jobGroups = new Map()\n\n    jobs.forEach((job) => {\n        if (!jobGroups.has(job.name)) {\n            jobGroups.set(job.name, [])\n        }\n        jobGroups.get(job.name).push(job)\n    })\n\n    const mostRecentJobs = new Map()\n    jobGroups.forEach((group, name) => {\n        const mostRecentJob = group.reduce((mostRecent, current) => {\n            if (current.id === null) return mostRecent\n            const currentIdTime = current.id\n            const mostRecentIdTime = mostRecent ? mostRecent.id : 0\n\n            return currentIdTime > mostRecentIdTime ? current : mostRecent\n        }, null)\n\n        if (mostRecentJob) {\n            mostRecentJobs.set(name, mostRecentJob.id)\n        }\n    })\n\n    return jobs.filter((job: JobInformation) => {\n        const mostRecentId = mostRecentJobs.get(job.name)\n        return job.id === null || job.id !== mostRecentId\n    })\n}\n"
  },
  {
    "path": "apps/workers/tsconfig.app.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../dist/out-tsc\",\n        \"module\": \"commonjs\",\n        \"types\": [\"node\"]\n    },\n    \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"**/*.test-helper.ts\", \"jest.config.ts\"],\n    \"include\": [\"**/*.ts\", \"../../custom-express.d.ts\"]\n}\n"
  },
  {
    "path": "apps/workers/tsconfig.json",
    "content": "{\n    \"extends\": \"../../tsconfig.base.json\",\n    \"compilerOptions\": {\n        \"esModuleInterop\": true,\n        \"noImplicitAny\": false,\n        \"strict\": true,\n        \"strictNullChecks\": true\n    },\n    \"files\": [],\n    \"include\": [],\n    \"references\": [\n        {\n            \"path\": \"./tsconfig.app.json\"\n        },\n        {\n            \"path\": \"./tsconfig.spec.json\"\n        }\n    ]\n}\n"
  },
  {
    "path": "apps/workers/tsconfig.spec.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../dist/out-tsc\",\n        \"module\": \"commonjs\",\n        \"types\": [\"jest\", \"node\"]\n    },\n    \"include\": [\n        \"**/*.spec.ts\",\n        \"**/*.test.ts\",\n        \"**/*.d.ts\",\n        \"**/*.test-helper.ts\",\n        \"jest.config.ts\"\n    ]\n}\n"
  },
  {
    "path": "babel.config.json",
    "content": "{\n    \"babelrcRoots\": [\"*\"]\n}\n"
  },
  {
    "path": "custom-express.d.ts",
    "content": "import express, { Send, Response, Request } from 'express'\nimport { SharedType } from '@maybe-finance/shared'\n\n// Because this is a module, need to escape from module scope and enter global scope so declaration merging works correctly\ndeclare global {\n    namespace Express {\n        interface Request {\n            // Add custom properties here (i.e. if props are defined with middleware)\n            user?: User & SharedType.MaybeCustomClaims\n        }\n\n        interface Response {\n            json(data: any): Send<Response, this>\n            superjson(data: any): Send<Response, this>\n        }\n    }\n}\n"
  },
  {
    "path": "docker-compose.test.yml",
    "content": "---\nversion: '3.9'\n\nservices:\n    server:\n        env_file: .env\n        container_name: maybe-server\n        profiles: [maybe]\n        image: ghcr.io/maybe/maybe:main\n        ports:\n            - 3333\n        environment:\n            NEXTAUTH_URL: &canonical https://maybe.example.com\n            NX_NEXTAUTH_URL: *canonical\n            NX_CLIENT_URL: *canonical\n            NX_CLIENT_URL_CUSTOM: *canonical\n            NEXT_PUBLIC_API_URL: &next_public_api_url https://maybe-server.example.com\n            NX_API_URL: &nx_api_url https://maybe-server.example.com\n            NX_REDIS_URL: &nx_redis_url redis://redis:6379\n            NODE_ENV: production\n    client:\n        env_file: .env\n        container_name: maybe-client\n        profiles: [maybe]\n        image: ghcr.io/maybe/maybe-client:main\n        ports:\n            - 4200\n        environment:\n            NEXTAUTH_URL: *canonical\n            NX_NEXTAUTH_URL: *canonical\n            NX_CLIENT_URL: *canonical\n            NX_CLIENT_URL_CUSTOM: *canonical\n            NEXT_PUBLIC_API_URL: *next_public_api_url\n            NX_API_URL: *nx_api_url\n            NX_REDIS_URL: *nx_redis_url\n            NODE_ENV: production\n    worker:\n        env_file: .env\n        container_name: maybe-worker\n        profiles: [maybe]\n        image: ghcr.io/maybe/maybe-worker:main\n        ports:\n            - 3334\n        environment:\n            NEXTAUTH_URL: *canonical\n            NX_NEXTAUTH_URL: *canonical\n            NX_CLIENT_URL: *canonical\n            NX_CLIENT_URL_CUSTOM: *canonical\n            NEXT_PUBLIC_API_URL: *next_public_api_url\n            NX_API_URL: *nx_api_url\n            NX_REDIS_URL: *nx_redis_url\n            NODE_ENV: production"
  },
  {
    "path": "docker-compose.yml",
    "content": "---\nversion: '3.9'\n\nservices:\n    postgres:\n        container_name: postgres\n        profiles: [services]\n        image: timescale/timescaledb:latest-pg14\n        ports:\n            - 5433:5432\n        environment:\n            POSTGRES_USER: maybe\n            POSTGRES_PASSWORD: maybe\n            POSTGRES_DB: maybe_local\n        volumes:\n            - postgres_data:/var/lib/postgresql/data\n\n    redis:\n        container_name: redis\n        profiles: [services]\n        image: redis:6.2-alpine\n        ports:\n            - 6379:6379\n        command: 'redis-server --bind 0.0.0.0'\n\n    ngrok:\n        env_file: .env\n        image: shkoliar/ngrok:latest\n        profiles: [ngrok]\n        container_name: ngrok\n        ports:\n            - 4551:4551\n        environment:\n            - DOMAIN=${NGROK_DOMAIN:-host.docker.internal}\n            - PORT=3333\n            - AUTH_TOKEN=${NGROK_AUTH_TOKEN}\n            - DEBUG=true\n\n    stripe:\n        container_name: stripe\n        image: stripe/stripe-cli:latest\n        profiles: [stripe]\n        command: listen --forward-to host.docker.internal:3333/v1/stripe/webhook --log-level warn\n        extra_hosts:\n            - 'host.docker.internal:host-gateway'\n        environment:\n            - STRIPE_API_KEY=${STRIPE_SECRET_KEY}\n        tty: true\n\nvolumes:\n    postgres_data:\n"
  },
  {
    "path": "jest.config.ts",
    "content": "const { getJestProjects } = require('@nrwl/jest')\n\nexport default {\n    projects: getJestProjects(),\n}\n"
  },
  {
    "path": "jest.preset.js",
    "content": "const nxPreset = require('@nrwl/jest/preset').default\n\nmodule.exports = { ...nxPreset }\n"
  },
  {
    "path": "libs/.gitkeep",
    "content": ""
  },
  {
    "path": "libs/client/features/.babelrc",
    "content": "{\n    \"presets\": [\n        [\n            \"@nrwl/react/babel\",\n            {\n                \"runtime\": \"automatic\",\n                \"useBuiltIns\": \"usage\"\n            }\n        ]\n    ],\n    \"plugins\": []\n}\n"
  },
  {
    "path": "libs/client/features/.eslintrc.json",
    "content": "{\n    \"extends\": [\"plugin:@nrwl/nx/react\", \"../../../.eslintrc.json\"],\n    \"ignorePatterns\": [\"!**/*\"],\n    \"overrides\": [\n        {\n            \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.ts\", \"*.tsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/client/features/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n    displayName: 'client-features',\n    preset: '../../../jest.preset.js',\n    transform: {\n        '^.+\\\\.[tj]sx?$': ['babel-jest', { presets: ['@nrwl/react/babel'] }],\n    },\n    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n    coverageDirectory: '../../../coverage/libs/client/features',\n}\n"
  },
  {
    "path": "libs/client/features/src/account/AccountMenu.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { useAccountContext } from '@maybe-finance/client/shared'\nimport { Menu } from '@maybe-finance/design-system'\nimport { RiDeleteBin5Line, RiPencilLine } from 'react-icons/ri'\nimport { useRouter } from 'next/router'\n\ntype Props = {\n    account?: SharedType.AccountDetail\n}\n\nexport function AccountMenu({ account }: Props) {\n    const { editAccount, deleteAccount } = useAccountContext()\n    const router = useRouter()\n\n    if (!account) return null\n\n    return (\n        <Menu data-testid=\"account-menu\">\n            <Menu.Button variant=\"icon\" data-testid=\"account-menu-btn\">\n                <i className=\"ri-more-2-fill text-white\" />\n            </Menu.Button>\n            <Menu.Items placement=\"bottom-end\">\n                <Menu.Item icon={<RiPencilLine />} onClick={() => editAccount(account)}>\n                    Edit\n                </Menu.Item>\n                {!account.accountConnectionId && (\n                    <Menu.Item\n                        icon={<RiDeleteBin5Line />}\n                        destructive\n                        onClick={() => deleteAccount(account, () => router.push('/'))}\n                    >\n                        Delete\n                    </Menu.Item>\n                )}\n            </Menu.Items>\n        </Menu>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/account/AccountsSidebar.tsx",
    "content": "import type { AccordionRowProps } from '@maybe-finance/design-system'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { useCallback, useMemo } from 'react'\nimport {\n    RiAlertLine,\n    RiCloseFill,\n    RiInformationLine as InfoIcon,\n    RiInformationLine,\n} from 'react-icons/ri'\nimport { AnimatePresence, motion } from 'framer-motion'\nimport { AccordionRow, LoadingPlaceholder, TrendLine } from '@maybe-finance/design-system'\nimport classNames from 'classnames'\nimport type DecimalJS from 'decimal.js'\nimport { AiOutlineExclamationCircle } from 'react-icons/ai'\nimport {\n    useAccountApi,\n    useQueryParam,\n    useUserAccountContext,\n    useProviderStatus,\n    useAccountContext,\n    useLocalStorage,\n} from '@maybe-finance/client/shared'\nimport { NumberUtil } from '@maybe-finance/shared'\n\nfunction SidebarAccountsLoader() {\n    return (\n        <div className=\"\">\n            {Array(6)\n                .fill(0)\n                .map((_, idx) => {\n                    return (\n                        <div key={idx} className=\"flex h-14 mb-1 rounded bg-gray overflow-hidden\">\n                            <LoadingPlaceholder isLoading={true} className=\"grow\" />\n                        </div>\n                    )\n                })}\n        </div>\n    )\n}\n\nexport default function AccountsSidebar() {\n    const { useAccountRollup } = useAccountApi()\n    const activeAccountId = useQueryParam('accountId', 'number')\n\n    const providerStatus = useProviderStatus()\n\n    const { someConnectionsSyncing, someAccountsSyncing, connectionsSyncing, syncProgress } =\n        useUserAccountContext()\n\n    const { dateRange } = useAccountContext()\n\n    const connectionsStatus = useMemo(\n        () => ({\n            syncing: someAccountsSyncing || someConnectionsSyncing,\n            pending:\n                connectionsSyncing.filter((connection) => connection.syncStatus === 'PENDING')\n                    .length > 0,\n        }),\n        [someAccountsSyncing, someConnectionsSyncing, connectionsSyncing]\n    )\n\n    const { error, data } = useAccountRollup(dateRange)\n\n    const isLoading = useMemo(() => {\n        if (error) {\n            return false\n        }\n\n        if (!connectionsStatus.syncing && data) {\n            return false\n        }\n\n        // If any connection is syncing and there are > 1 accounts, show the accounts\n        if (connectionsStatus.syncing && data && data.length > 0) {\n            return false\n        }\n\n        return true\n    }, [data, error, connectionsStatus])\n\n    const [toggleState, setToggleState] = useLocalStorage<{ [key: string]: boolean }>(\n        'ACCOUNTS_LIST_TOGGLE_STATE',\n        {}\n    )\n\n    const updateToggleState = useCallback(\n        (key: string, isExpanded: boolean) => {\n            setToggleState({\n                ...toggleState,\n                [key]: isExpanded,\n            })\n        },\n        [toggleState, setToggleState]\n    )\n\n    if (error) {\n        return (\n            <div className=\"flex items-center justify-center text-red h-20\">\n                <AiOutlineExclamationCircle className=\"w-5 h-5 mr-2\" />\n                <p>Unable to load accounts</p>\n            </div>\n        )\n    }\n\n    if (!isLoading && (!data || !data.length)) {\n        return (\n            <div className=\"flex items-center justify-center h-20\">\n                <InfoIcon className=\"w-5 h-5 mr-2\" />\n                <p>No accounts found</p>\n            </div>\n        )\n    }\n\n    return (\n        <div>\n            <AnimatePresence>\n                {syncProgress && (\n                    <motion.div\n                        className=\"overflow-hidden text-center text-base text-gray-100\"\n                        key=\"importing-message\"\n                        initial={{ height: 0 }}\n                        animate={{ height: 'auto' }}\n                        exit={{ height: 0 }}\n                    >\n                        {syncProgress.description}...\n                        <div className=\"my-4\">\n                            <div className=\"relative w-full h-[3px] rounded-full overflow-hidden bg-gray-200\">\n                                {syncProgress.progress ? (\n                                    <motion.div\n                                        key=\"progress-determinate\"\n                                        initial={{ width: 0 }}\n                                        animate={{ width: `${syncProgress.progress * 100}%` }}\n                                        transition={{ ease: 'easeOut', duration: 0.5 }}\n                                        className=\"h-full rounded-full bg-gray-100\"\n                                    ></motion.div>\n                                ) : (\n                                    <motion.div\n                                        key=\"progress-indeterminate\"\n                                        className=\"w-[40%] h-full rounded-full bg-gray-100\"\n                                        animate={{ translateX: ['-100%', '250%'] }}\n                                        transition={{ repeat: Infinity, duration: 1.8 }}\n                                    ></motion.div>\n                                )}\n                            </div>\n                        </div>\n                    </motion.div>\n                )}\n            </AnimatePresence>\n            {providerStatus.statusMessage && (\n                <div className=\"flex gap-2 bg-yellow rounded bg-opacity-10 text-yellow-500 p-3 mb-4\">\n                    <span>\n                        <RiAlertLine className=\"w-5 h-5\" />\n                    </span>\n                    {providerStatus.isCollapsed ? (\n                        <>\n                            <p className=\"text-base\">Data provider service disruption</p>\n                            <span className=\"ml-auto\" onClick={() => providerStatus.expand()}>\n                                <RiInformationLine className=\"w-5 h-5 cursor-pointer hover:opacity-80\" />\n                            </span>\n                        </>\n                    ) : (\n                        <>\n                            <p className=\"text-base\">{providerStatus.statusMessage}</p>\n                            <span className=\"ml-auto\" onClick={() => providerStatus.dismiss()}>\n                                <RiCloseFill className=\"w-5 h-5 cursor-pointer hover:opacity-80\" />\n                            </span>\n                        </>\n                    )}\n                </div>\n            )}\n            {isLoading || !data ? (\n                <SidebarAccountsLoader />\n            ) : (\n                data.map(({ key: classification, title, balances, items }) => (\n                    <AccountsSidebarRow\n                        key={classification}\n                        label={title}\n                        balances={balances.data}\n                        inverted={classification === 'liability'}\n                        onToggle={(isExpanded) => updateToggleState(title, isExpanded)}\n                        expanded={toggleState[title] !== false}\n                        syncing={items.some(({ items }) => items.some((a) => a.syncing))}\n                    >\n                        {items.map(({ key: category, title, balances, items }) => (\n                            <AccountsSidebarRow\n                                key={category}\n                                label={title}\n                                balances={balances.data}\n                                inverted={classification === 'liability'}\n                                onToggle={(isExpanded) =>\n                                    updateToggleState(`${title}-${classification}`, isExpanded)\n                                }\n                                expanded={toggleState[title] !== false}\n                                level={1}\n                                syncing={items.some((a) => a.syncing)}\n                            >\n                                {items.map(({ id, name, mask, connection, balances, syncing }) => (\n                                    <AccountsSidebarRow\n                                        key={id}\n                                        label={name}\n                                        institutionName={connection?.name}\n                                        accountMask={mask}\n                                        balances={balances.data}\n                                        inverted={classification === 'liability'}\n                                        level={2}\n                                        collapsible={false}\n                                        syncing={syncing}\n                                        active={id === activeAccountId}\n                                        url={`/accounts/${id}`}\n                                    />\n                                ))}\n                            </AccountsSidebarRow>\n                        ))}\n                    </AccountsSidebarRow>\n                ))\n            )}\n        </div>\n    )\n}\n\nfunction AccountsSidebarRow({\n    label,\n    level = 0,\n    balances,\n    institutionName,\n    accountMask,\n    inverted = false,\n    syncing = false,\n    active = false,\n    url,\n    ...rest\n}: AccordionRowProps & {\n    label: string\n    balances: { date: string; balance: SharedType.Decimal }[]\n    institutionName?: string | null\n    accountMask?: string | null\n    inverted?: boolean\n    syncing?: boolean\n    active?: boolean\n    url?: string\n}) {\n    const startBalance = balances[0].balance\n    const endBalance = balances[balances.length - 1].balance\n\n    const percentChange = NumberUtil.calculatePercentChange(startBalance, endBalance)\n\n    let isPositive = balances.length > 1 && (endBalance as DecimalJS).gt(startBalance as DecimalJS)\n    if (inverted) isPositive = !isPositive\n\n    const overlayClassName = ['!bg-gray-400', '!bg-gray-600', '!bg-gray-700'][level]\n\n    // Hide flat lines or inifite\n    const hasValidValue = !percentChange.isZero() && percentChange.isFinite()\n\n    return (\n        <AccordionRow\n            {...rest}\n            level={level}\n            href={level === 2 && url ? url : undefined}\n            active={active}\n            data-testid=\"account-accordion-row\"\n            label={\n                <div\n                    className=\"flex items-center space-x-1\"\n                    data-testid=\"account-accordion-row-name\"\n                >\n                    <div className=\"flex-1 min-w-0\">\n                        <p className=\"text-base font-normal line-clamp-2\">{label}</p>\n\n                        {(institutionName || accountMask) && (\n                            <div className=\"mt-0.5 flex flex-wrap items-center space-x-1 text-sm text-gray-100\">\n                                {institutionName && (\n                                    <span className=\"line-clamp-2\">{institutionName}</span>\n                                )}\n                                {accountMask && (\n                                    <span className=\"shrink-0\">\n                                        &nbsp;&#183;&#183;&#183;&#183; {accountMask}\n                                    </span>\n                                )}\n                            </div>\n                        )}\n                    </div>\n\n                    {balances.length && (\n                        <div className=\"shrink-0 flex flex-col justify-center items-end font-semibold tabular-nums h-9\">\n                            <LoadingPlaceholder\n                                isLoading={syncing}\n                                overlayClassName={overlayClassName}\n                            >\n                                <div data-testid=\"account-accordion-row-balance\" className=\"pb-1\">\n                                    {syncing\n                                        ? '$X,XXX,XXX'\n                                        : NumberUtil.format(endBalance, 'currency', {\n                                              minimumFractionDigits: 0,\n                                              maximumFractionDigits: 0,\n                                          })}\n                                </div>\n                            </LoadingPlaceholder>\n                            {(balances.length > 1 || syncing) && hasValidValue && (\n                                <div className=\"mt-1\">\n                                    <LoadingPlaceholder\n                                        isLoading={syncing}\n                                        overlayClassName={overlayClassName}\n                                        className=\"!inline-flex\"\n                                    >\n                                        {!syncing && (\n                                            <div className=\"inline-block w-8 h-3\">\n                                                <TrendLine\n                                                    inverted={inverted}\n                                                    data={balances.map(({ date, balance }) => ({\n                                                        key: date,\n                                                        value: balance.toNumber(),\n                                                    }))}\n                                                />\n                                            </div>\n                                        )}\n                                        <span\n                                            className={classNames(\n                                                'ml-1',\n                                                percentChange.isZero()\n                                                    ? 'text-gray-200'\n                                                    : isPositive\n                                                    ? 'text-teal'\n                                                    : 'text-red'\n                                            )}\n                                        >\n                                            {syncing\n                                                ? '+XXX%'\n                                                : NumberUtil.format(percentChange, 'percent')}\n                                        </span>\n                                    </LoadingPlaceholder>\n                                </div>\n                            )}\n                        </div>\n                    )}\n                </div>\n            }\n        />\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/account/PageTitle.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { SmallDecimals, TrendBadge } from '@maybe-finance/client/shared'\nimport { LoadingPlaceholder } from '@maybe-finance/design-system'\n\ntype Props = {\n    isLoading: boolean\n    title?: string\n    value?: string\n    trend?: SharedType.Trend\n    trendLabel?: string\n    trendNegative?: boolean\n}\n\nexport function PageTitle({ isLoading, title, value, trend, trendLabel, trendNegative }: Props) {\n    return (\n        <div className=\"space-y-2 min-h-[120px]\">\n            <LoadingPlaceholder\n                isLoading={isLoading}\n                maxContent\n                placeholderContent={<h3>Placeholder Title</h3>}\n            >\n                {title && <h3>{title}</h3>}\n            </LoadingPlaceholder>\n\n            <LoadingPlaceholder\n                isLoading={isLoading}\n                maxContent\n                placeholderContent={\n                    <h2>\n                        <SmallDecimals value={'$2,000.20'} />\n                    </h2>\n                }\n            >\n                <h2 data-testid=\"current-data-value\">\n                    <SmallDecimals value={value} />\n                </h2>\n            </LoadingPlaceholder>\n\n            <LoadingPlaceholder\n                isLoading={isLoading}\n                maxContent\n                placeholderContent={<div className=\"h-8 w-[150px]\" />}\n            >\n                <span className=\"text-sm text-gray-50\">\n                    {trend && (\n                        <TrendBadge\n                            trend={trend}\n                            label={trendLabel}\n                            negative={trendNegative}\n                            displayAmount\n                        />\n                    )}\n                </span>\n            </LoadingPlaceholder>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/account/index.ts",
    "content": "export * from './AccountMenu'\nexport { default as AccountSidebar } from './AccountsSidebar'\nexport * from './PageTitle'\n"
  },
  {
    "path": "libs/client/features/src/accounts-list/Account.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport cn from 'classnames'\nimport { Button, Menu, Toggle, Tooltip } from '@maybe-finance/design-system'\nimport { useAccountApi, useAccountContext } from '@maybe-finance/client/shared'\nimport { NumberUtil, AccountUtil } from '@maybe-finance/shared'\nimport { DateTime } from 'luxon'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport debounce from 'lodash/debounce'\n\ntype AccountProps = {\n    account: SharedType.Account\n    readonly?: boolean\n    onEdit?(): void\n    editLabel?: string\n    canDelete?: boolean\n    showAccountDescription?: boolean\n}\n\nexport default function Account({\n    account,\n    readonly = false,\n    onEdit,\n    editLabel = 'Edit',\n    canDelete = false,\n    showAccountDescription = true,\n}: AccountProps) {\n    const { useSyncAccount, useAccountBalances } = useAccountApi()\n\n    const { deleteAccount } = useAccountContext()\n\n    const syncAccount = useSyncAccount()\n    const accountBalancesQuery = useAccountBalances({\n        id: account.id,\n        start: DateTime.now().toISODate(),\n        end: DateTime.now().toISODate(),\n    })\n\n    let accountTypeName = AccountUtil.getAccountTypeName(account.category, account.subcategory)\n    accountTypeName = accountTypeName\n        ? accountTypeName.charAt(0).toUpperCase() + accountTypeName.slice(1)\n        : null\n\n    const renderAccountDescription = () => {\n        if (!showAccountDescription) return null\n\n        if (!accountTypeName && !account.mask) return null\n\n        return (\n            <div className=\"text-base text-gray-100 truncate\">\n                {accountTypeName ?? 'Account'}\n                {account.mask && <>&nbsp;ending in &#183;&#183;&#183;&#183; {account.mask}</>}\n            </div>\n        )\n    }\n\n    return (\n        <li className=\"px-3 py-4 flex items-center space-x-4\">\n            <AccountToggle account={account} disabled={readonly} />\n            <div className=\"flex-1 min-w-0 flex items-center space-x-3 group\">\n                <div\n                    className={cn(\n                        'text-base leading-normal overflow-x-hidden',\n                        !account.isActive && 'text-gray-100'\n                    )}\n                >\n                    <div className=\"truncate\">{account.name}</div>\n                    {renderAccountDescription()}\n                </div>\n                <div className=\"hidden group-hover:flex group-focus-within:flex items-center space-x-1\">\n                    {onEdit && (\n                        <Tooltip content={editLabel} placement=\"bottom\" offset={[0, 4]}>\n                            <Button variant=\"icon\" onClick={onEdit} disabled={readonly}>\n                                <i className=\"ri-pencil-line text-gray-100\" />\n                            </Button>\n                        </Tooltip>\n                    )}\n                    {canDelete && (\n                        <Tooltip content=\"Delete\" placement=\"bottom\" offset={[0, 4]}>\n                            <Button\n                                variant=\"icon\"\n                                onClick={() => deleteAccount(account)}\n                                disabled={readonly}\n                            >\n                                <i className=\"ri-delete-bin-line text-gray-100\" />\n                            </Button>\n                        </Tooltip>\n                    )}\n                    {process.env.NODE_ENV === 'development' && (\n                        <Menu>\n                            <Menu.Button variant=\"icon\">\n                                <i className=\"ri-tools-fill text-red\" />\n                            </Menu.Button>\n                            <Menu.Items placement=\"bottom-end\">\n                                <Menu.Item\n                                    destructive\n                                    onClick={() => syncAccount.mutate(account.id)}\n                                >\n                                    Sync\n                                </Menu.Item>\n                            </Menu.Items>\n                        </Menu>\n                    )}\n                </div>\n            </div>\n            {accountBalancesQuery.data ? (\n                <span\n                    className={cn('font-semibold text-base', !account.isActive && 'text-gray-200')}\n                >\n                    {NumberUtil.format(accountBalancesQuery.data.today?.balance, 'currency')}\n                </span>\n            ) : (\n                <span className=\"text-gray-200 animate-pulse\">...</span>\n            )}\n        </li>\n    )\n}\n\nfunction AccountToggle({\n    account,\n    disabled,\n}: {\n    account: SharedType.Account\n    disabled: boolean\n}): JSX.Element {\n    const { useUpdateAccount } = useAccountApi()\n    const { mutateAsync } = useUpdateAccount()\n\n    const [isActive, setIsActive] = useState(account.isActive)\n\n    useEffect(() => setIsActive(account.isActive), [account.isActive])\n\n    const debouncedMutate = useMemo(\n        () =>\n            debounce(async (checked: boolean) => {\n                try {\n                    await mutateAsync({\n                        id: account.id,\n                        data: { data: { isActive: checked } },\n                    })\n                } catch (e) {\n                    setIsActive(!checked)\n                }\n            }, 500),\n        [mutateAsync, account.id]\n    )\n\n    const onChange = useCallback(\n        (checked: boolean) => {\n            setIsActive(checked)\n            debouncedMutate(checked)\n        },\n        [debouncedMutate]\n    )\n\n    return (\n        <Toggle\n            screenReaderLabel=\"Toggle account\"\n            onChange={onChange}\n            checked={isActive}\n            disabled={disabled}\n        />\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-list/AccountDevTools.tsx",
    "content": "import Link from 'next/link'\nimport { useState } from 'react'\nimport {\n    useAccountConnectionApi,\n    useInstitutionApi,\n    useSecurityApi,\n    useUserApi,\n} from '@maybe-finance/client/shared'\nimport { Button, Input, Dialog } from '@maybe-finance/design-system'\nimport { type SubmitHandler, useForm } from 'react-hook-form'\nimport { toast } from 'react-hot-toast'\n\nexport function AccountDevTools() {\n    const [open, setOpen] = useState(false)\n\n    const { useDeleteAllConnections } = useAccountConnectionApi()\n    const { useSyncInstitutions, useDeduplicateInstitutions } = useInstitutionApi()\n    const { useSyncUSStockTickers, useSyncSecurityPricing } = useSecurityApi()\n\n    const deleteAllConnections = useDeleteAllConnections()\n    const syncInstitutions = useSyncInstitutions()\n    const deduplicateInstitutions = useDeduplicateInstitutions()\n    const syncUSStockTickers = useSyncUSStockTickers()\n    const syncSecurityPricing = useSyncSecurityPricing()\n\n    return process.env.NODE_ENV === 'development' ? (\n        <div className=\"relative mb-12 mx-2 sm:mx-0 p-4 bg-gray-700 rounded-md z-10\">\n            <h6 className=\"flex text-red\">\n                Dev Tools <i className=\"ri-tools-fill ml-1.5\" />\n            </h6>\n            <p className=\"text-sm my-2\">\n                This section along with anything in <span className=\"text-red\">red text</span> will\n                NOT show in production and are solely for making testing easier.\n            </p>\n            <div className=\"flex items-center text-sm mt-4\">\n                <p className=\"font-bold\">Actions:</p>\n                <button\n                    className=\"underline text-red ml-4\"\n                    onClick={() => deleteAllConnections.mutate()}\n                >\n                    Delete all connections\n                </button>\n                <Link href=\"http://localhost:3333/admin/bullmq\" className=\"underline text-red ml-4\">\n                    BullMQ Dashboard\n                </Link>\n                <button\n                    className=\"underline text-red ml-4\"\n                    onClick={() => syncInstitutions.mutate()}\n                >\n                    Sync institutions\n                </button>\n                <button\n                    className=\"underline text-red ml-4\"\n                    onClick={() => deduplicateInstitutions.mutate()}\n                >\n                    Deduplicate institutions\n                </button>\n                <button\n                    className=\"underline text-red ml-4\"\n                    onClick={() => syncUSStockTickers.mutate()}\n                >\n                    Sync stock tickers\n                </button>\n                <button\n                    className=\"underline text-red ml-4\"\n                    onClick={() => syncSecurityPricing.mutate()}\n                >\n                    Sync stock pricing\n                </button>\n                <button className=\"underline text-red ml-4\" onClick={() => setOpen(true)}>\n                    Test email\n                </button>\n            </div>\n            <Dialog isOpen={open} onClose={() => setOpen(false)}>\n                <Dialog.Title>Send test email</Dialog.Title>\n                <Dialog.Content>\n                    <TestEmailForm setOpen={setOpen} />\n                </Dialog.Content>\n            </Dialog>\n        </div>\n    ) : null\n}\n\nexport default AccountDevTools\n\ntype TestEmailFormProps = {\n    setOpen: (open: boolean) => void\n}\n\ntype EmailFormFields = {\n    recipient: string\n    subject: string\n    body: string\n}\n\nfunction TestEmailForm({ setOpen }: TestEmailFormProps) {\n    const { register, handleSubmit } = useForm<EmailFormFields>()\n    const { useSendTestEmail, useAuthProfile } = useUserApi()\n    const sendTestEmail = useSendTestEmail()\n    const authUser = useAuthProfile()\n\n    const onSubmit: SubmitHandler<EmailFormFields> = (data) => {\n        if (authUser.data?.email !== data.recipient) {\n            toast.error('You can only send test emails to yourself.')\n            return\n        }\n        sendTestEmail.mutate(data)\n        setOpen(false)\n    }\n\n    return (\n        <form className=\"flex flex-col space-y-2\" onSubmit={handleSubmit(onSubmit)}>\n            <Input\n                label=\"Recipient\"\n                defaultValue={authUser.data?.email ?? ''}\n                {...register('recipient')}\n            />\n            <Input\n                label=\"Subject\"\n                defaultValue=\"Test subject from Maybe\"\n                {...register('subject')}\n            />\n            <label>\n                <div className=\"text-base text-gray-50 mb-1\">Email body</div>\n                <textarea\n                    rows={4}\n                    className=\"block w-full bg-gray-500 text-base placeholder:text-gray-100 rounded border-0 focus:ring-0 resize-none\"\n                    placeholder=\"Email body\"\n                    defaultValue=\"This is a test email from Maybe. If you are seeing this, it means that the email service is working correctly.\"\n                    {...register('body')}\n                    onKeyDown={(e) => e.key === 'Enter' && e.stopPropagation()}\n                />\n            </label>\n            <Button type=\"submit\">Send test email</Button>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-list/AccountGroup.tsx",
    "content": "import { Disclosure } from '@headlessui/react'\nimport { Button } from '@maybe-finance/design-system'\nimport classNames from 'classnames'\n\ntype AccountGroupProps = {\n    title: string\n    subtitle: React.ReactNode\n    content: React.ReactNode\n    menu?: React.ReactNode\n    footer?: React.ReactNode\n}\n\nexport function AccountGroup({ title, subtitle, content, menu, footer }: AccountGroupProps) {\n    return (\n        <Disclosure\n            as=\"li\"\n            className=\"p-4 rounded-lg bg-gray-800 list-none\"\n            data-testid=\"account-group\"\n        >\n            {({ open }) => (\n                <>\n                    <div className=\"flex items-center justify-between\">\n                        <div className=\"text-base\">\n                            <p className=\"text-white\">{title}</p>\n                            <p className=\"text-gray-100\">{subtitle}</p>\n                        </div>\n                        <div className=\"flex items-center space-x-1\">\n                            {menu}\n                            <Disclosure.Button\n                                as={Button}\n                                variant=\"icon\"\n                                className={classNames(open && 'rotate-180')}\n                            >\n                                <i className=\"ri-arrow-down-s-line text-white\" />\n                            </Disclosure.Button>\n                        </div>\n                    </div>\n\n                    <Disclosure.Panel>\n                        <div className=\"mt-4 bg-gray-700 rounded-lg\">{content}</div>\n                        <div className={classNames(!!footer && 'mt-4')}>{footer}</div>\n                    </Disclosure.Panel>\n                </>\n            )}\n        </Disclosure>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-list/AccountGroupContainer.tsx",
    "content": "import type { PropsWithChildren } from 'react'\n\nexport type AccountGroupContainerProps = PropsWithChildren<{\n    title: string\n    subtitle?: string\n}>\n\nexport function AccountGroupContainer({\n    title,\n    subtitle = '',\n    children,\n}: AccountGroupContainerProps) {\n    return (\n        <section>\n            <header>\n                <h5>{title}</h5>\n                <p className=\"text-gray-100 text-base\">{subtitle}</p>\n            </header>\n            <ul className=\"mt-4 space-y-2\">{children}</ul>\n        </section>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-list/ConnectedAccountGroup.tsx",
    "content": "import { Button, Menu } from '@maybe-finance/design-system'\nimport type { SharedType } from '@maybe-finance/shared'\nimport Account from './Account'\nimport { AccountGroup } from './AccountGroup'\nimport { RiLinkUnlink as UnlinkIcon, RiRefreshLine, RiHistoryLine } from 'react-icons/ri'\nimport {\n    useAccountConnectionApi,\n    useAccountContext,\n    useAxiosWithAuth,\n    useLastUpdated,\n} from '@maybe-finance/client/shared'\nimport { DateTime } from 'luxon'\nimport { AiOutlineSync, AiOutlineExclamationCircle } from 'react-icons/ai'\nimport { RiDownloadLine } from 'react-icons/ri'\n\nexport interface ConnectedAccountGroupProps {\n    connection: SharedType.ConnectionWithAccounts\n}\n\nexport function ConnectedAccountGroup({ connection }: ConnectedAccountGroupProps) {\n    const { axios } = useAxiosWithAuth()\n\n    const { editAccount } = useAccountContext()\n\n    const { useDisconnectConnection, useSyncConnection, useDeleteConnection, useUpdateConnection } =\n        useAccountConnectionApi()\n\n    const disconnectConnection = useDisconnectConnection()\n    const deleteConnection = useDeleteConnection()\n    const syncConnection = useSyncConnection()\n    const updateConnection = useUpdateConnection()\n\n    const { status, message } = useAccountConnectionStatus(connection)\n\n    return (\n        <AccountGroup\n            title={connection.name}\n            subtitle={status}\n            content={\n                connection.accounts.length > 0 ? (\n                    <ul>\n                        {connection.accounts.map((account) => (\n                            <Account\n                                key={account.id}\n                                account={account}\n                                onEdit={() => editAccount(account)}\n                                editLabel=\"Edit\"\n                            />\n                        ))}\n                    </ul>\n                ) : (\n                    <p className=\"py-4 px-3 text-gray-100 text-sm\">\n                        {connection.syncStatus === 'PENDING' || connection.syncStatus === 'SYNCING'\n                            ? 'Your accounts are currently syncing. Please check back later.'\n                            : 'No accounts found. Try syncing again.'}\n                    </p>\n                )\n            }\n            menu={\n                <>\n                    {process.env.NODE_ENV === 'development' && (\n                        <Menu>\n                            <Menu.Button variant=\"icon\">\n                                <i className=\"ri-tools-fill text-red\" />\n                            </Menu.Button>\n                            <Menu.Items placement=\"bottom-end\">\n                                <Menu.Item\n                                    destructive\n                                    onClick={() =>\n                                        axios.post(`/connections/${connection.id}/sync/balances`)\n                                    }\n                                >\n                                    Sync Balances\n                                </Menu.Item>\n                                <Menu.Item\n                                    destructive\n                                    onClick={() =>\n                                        axios.post(`/connections/${connection.id}/sync/investments`)\n                                    }\n                                >\n                                    Sync Investments\n                                </Menu.Item>\n                                <Menu.Item\n                                    destructive\n                                    onClick={() => deleteConnection.mutate(connection.id)}\n                                >\n                                    Delete permanently\n                                </Menu.Item>\n                            </Menu.Items>\n                        </Menu>\n                    )}\n                    <Menu>\n                        <Menu.Button variant=\"icon\">\n                            <i className=\"ri-more-2-fill text-white\" />\n                        </Menu.Button>\n                        <Menu.Items placement=\"bottom-end\">\n                            <Menu.Item\n                                icon={<UnlinkIcon />}\n                                destructive\n                                onClick={() => disconnectConnection.mutate(connection.id)}\n                            >\n                                Disconnect account\n                            </Menu.Item>\n                        </Menu.Items>\n                    </Menu>\n                </>\n            }\n            footer={\n                <div className=\"flex items-center space-x-2\">\n                    <div className=\"grow\">{message}</div>\n                    <div className=\"flex items-center space-x-4\">\n                        {/* Provide user a fallback if their connection gets \"stuck\" in the syncing state */}\n                        {connection.syncStatus !== 'IDLE' && (\n                            <Button\n                                variant=\"secondary\"\n                                onClick={async () => {\n                                    await updateConnection.mutateAsync({\n                                        id: connection.id,\n                                        data: { syncStatus: 'IDLE' },\n                                    })\n                                }}\n                            >\n                                Cancel\n                            </Button>\n                        )}\n\n                        <Button\n                            variant=\"secondary\"\n                            onClick={() => syncConnection.mutate(connection.id)}\n                            disabled={syncConnection.isLoading || connection.syncStatus !== 'IDLE'}\n                        >\n                            {syncConnection.isLoading || connection.syncStatus !== 'IDLE' ? (\n                                <div className=\"flex items-center space-x-2\">\n                                    <AiOutlineSync className=\"h-4 w-4 animate-spin\" />\n                                    <span className=\"animate-pulse\">Syncing...</span>\n                                </div>\n                            ) : (\n                                'Sync Account'\n                            )}\n                        </Button>\n                    </div>\n                </div>\n            }\n        />\n    )\n}\n\nfunction useAccountConnectionStatus(connection: SharedType.ConnectionWithAccounts): {\n    status?: React.ReactNode\n    message?: React.ReactNode\n} {\n    const lastUpdated = useLastUpdated(DateTime.fromJSDate(connection.updatedAt))\n\n    switch (connection.syncStatus) {\n        case 'PENDING':\n        case 'SYNCING': {\n            return {\n                status: <span className=\"animate-pulse\">Syncing...</span>,\n            }\n        }\n    }\n\n    switch (connection.status) {\n        case 'OK': {\n            return {\n                status: lastUpdated,\n                message: null,\n            }\n        }\n        case 'ERROR': {\n            return {\n                status: (\n                    <div className=\"flex items-center space-x-1 text-red-500\">\n                        <AiOutlineExclamationCircle className=\"h-4 w-4\" />\n                        <p>Syncing issue detected</p>\n                    </div>\n                ),\n                message: (\n                    <div className=\"flex items-center space-x-2 text-red-500\">\n                        <RiRefreshLine className=\"h-4 w-4\" />\n                        <p className=\"text-base\">Please try syncing again</p>\n                    </div>\n                ),\n            }\n        }\n    }\n\n    return {}\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-list/DeleteConnectionDialog.tsx",
    "content": "import { useAccountConnectionApi } from '@maybe-finance/client/shared'\nimport { Alert, Button, Dialog } from '@maybe-finance/design-system'\nimport type { SharedType } from '@maybe-finance/shared'\n\nexport interface DeleteConnectionDialogProps {\n    connection: SharedType.ConnectionWithAccounts\n    isOpen: boolean\n    onClose: () => void\n}\n\nexport function DeleteConnectionDialog({\n    connection,\n    isOpen,\n    onClose,\n}: DeleteConnectionDialogProps) {\n    const { useDeleteConnection } = useAccountConnectionApi()\n\n    const deleteConnection = useDeleteConnection()\n\n    return (\n        <Dialog isOpen={isOpen} onClose={onClose} showCloseButton={false}>\n            <Dialog.Title>Delete account?</Dialog.Title>\n            <Dialog.Content>\n                <Alert isVisible variant=\"error\">\n                    This action cannot be undone\n                </Alert>\n                <p className=\"mt-4 text-base text-gray-50\">\n                    Deleting <span className=\"text-white\">{connection.name}</span> will permanently\n                    remove <span className=\"text-white\">{connection.accounts.length} accounts</span>{' '}\n                    and all other related data. This will impact other views such as your net worth\n                    dashboard.\n                </p>\n                <div className=\"mt-8 grid grid-cols-2 gap-4\">\n                    <Button variant=\"secondary\" onClick={onClose}>\n                        Cancel\n                    </Button>\n                    <Button\n                        variant=\"danger\"\n                        disabled={deleteConnection.isLoading}\n                        onClick={() => deleteConnection.mutate(connection.id)}\n                    >\n                        Delete\n                    </Button>\n                </div>\n            </Dialog.Content>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-list/DisconnectedAccountGroup.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { Menu } from '@maybe-finance/design-system'\nimport Account from './Account'\nimport { AccountGroup } from './AccountGroup'\nimport { RiMore2Fill, RiLink } from 'react-icons/ri'\nimport { FaRegTrashAlt } from 'react-icons/fa'\nimport { DeleteConnectionDialog } from './DeleteConnectionDialog'\nimport { useState } from 'react'\nimport { useAccountConnectionApi, useLastUpdated } from '@maybe-finance/client/shared'\nimport { DateTime } from 'luxon'\n\ntype DisconnectedAccountGroupProps = {\n    connection: SharedType.ConnectionWithAccounts\n}\n\nexport function DisconnectedAccountGroup({ connection }: DisconnectedAccountGroupProps) {\n    const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)\n\n    const { useReconnectConnection } = useAccountConnectionApi()\n    const reconnect = useReconnectConnection()\n\n    const lastUpdatedString = useLastUpdated(DateTime.fromJSDate(connection.updatedAt))\n\n    return (\n        <>\n            <AccountGroup\n                title={connection.name}\n                subtitle={lastUpdatedString}\n                content={\n                    <ul>\n                        {connection.accounts.map((account) => (\n                            <Account key={account.id} account={account} readonly />\n                        ))}\n                    </ul>\n                }\n                menu={\n                    <Menu>\n                        <Menu.Button variant=\"icon\">\n                            <RiMore2Fill />\n                        </Menu.Button>\n                        <Menu.Items placement=\"bottom-end\">\n                            <Menu.Item\n                                icon={<RiLink />}\n                                onClick={() => reconnect.mutate(connection.id)}\n                            >\n                                Reconnect account\n                            </Menu.Item>\n                            <Menu.Item\n                                icon={<FaRegTrashAlt />}\n                                destructive\n                                onClick={() => setDeleteDialogOpen(true)}\n                            >\n                                Delete permanently\n                            </Menu.Item>\n                        </Menu.Items>\n                    </Menu>\n                }\n            />\n\n            <DeleteConnectionDialog\n                connection={connection}\n                isOpen={deleteDialogOpen}\n                onClose={() => setDeleteDialogOpen(false)}\n            />\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-list/ManualAccountGroup.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { DateTime } from 'luxon'\nimport maxBy from 'lodash/maxBy'\nimport { useLastUpdated, useAccountContext } from '@maybe-finance/client/shared'\nimport Account from './Account'\nimport { AccountGroup } from './AccountGroup'\n\ntype ManualAccountGroupProps = {\n    title: string\n    subtitle: string\n    accounts: SharedType.Account[]\n}\n\nexport function ManualAccountGroup({ title, accounts }: ManualAccountGroupProps) {\n    const { editAccount } = useAccountContext()\n\n    // Use the most recently updated manual account in the group\n    const lastUpdatedString = useLastUpdated(\n        DateTime.fromJSDate(maxBy(accounts, (a) => a.updatedAt)?.updatedAt || new Date())\n    )\n\n    return (\n        <AccountGroup\n            title={title}\n            subtitle={lastUpdatedString}\n            content={\n                <ul>\n                    {accounts.map((account) => (\n                        <Account\n                            key={account.id}\n                            account={account}\n                            canDelete\n                            onEdit={() => editAccount(account)}\n                            showAccountDescription={false}\n                        />\n                    ))}\n                </ul>\n            }\n        />\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-list/index.ts",
    "content": "export * from './Account'\nexport * from './AccountGroupContainer'\nexport * from './DisconnectedAccountGroup'\nexport * from './ManualAccountGroup'\nexport * from './AccountDevTools'\nexport * from './ConnectedAccountGroup'\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/AccountTypeGrid.tsx",
    "content": "import type { IconType } from 'react-icons'\nimport type { BoxIconVariant } from '@maybe-finance/client/shared'\n\nimport {\n    RiBankLine,\n    RiBitCoinLine,\n    RiCarLine,\n    RiFolderLine,\n    RiHome2Line,\n    RiLineChartLine,\n} from 'react-icons/ri'\nimport { BoxIcon } from '@maybe-finance/client/shared'\n\nexport type AccountSelectorView =\n    | 'default'\n    | 'search'\n    | 'banks'\n    | 'brokerages'\n    | 'crypto'\n    | 'manual'\n    | 'property-form'\n    | 'vehicle-form'\n\nfunction AccountTypeGridItem({\n    icon,\n    variant,\n    title,\n    type,\n    onClick,\n}: {\n    icon: IconType\n    variant: BoxIconVariant\n    title: string\n    type: AccountSelectorView\n    onClick: (view: AccountSelectorView) => void\n}) {\n    return (\n        <div\n            className=\"flex flex-col items-center justify-between p-4 bg-gray-600 rounded-xl cursor-pointer hover:bg-gray-500 \"\n            onClick={() => onClick(type)}\n            data-testid={`${type}-add-account`}\n        >\n            <BoxIcon icon={icon} variant={variant} />\n            <p className=\"text-base mt-4\">{title}</p>\n        </div>\n    )\n}\n\nconst items = [\n    {\n        type: 'banks',\n        title: 'Bank account',\n        icon: RiBankLine,\n        variant: 'blue',\n    },\n    {\n        type: 'crypto',\n        title: 'Crypto',\n        icon: RiBitCoinLine,\n        variant: 'orange',\n    },\n    {\n        type: 'brokerages',\n        title: 'Investment',\n        icon: RiLineChartLine,\n        variant: 'teal',\n    },\n    {\n        type: 'vehicle-form',\n        title: 'Vehicle',\n        icon: RiCarLine,\n        variant: 'grape',\n    },\n    {\n        type: 'property-form',\n        title: 'Real estate',\n        icon: RiHome2Line,\n        variant: 'pink',\n    },\n    {\n        type: 'manual',\n        title: 'Manual account',\n        icon: RiFolderLine,\n        variant: 'yellow',\n    },\n]\n\nexport function AccountTypeGrid({ onChange }: { onChange: (view: AccountSelectorView) => void }) {\n    return (\n        <div className=\"grid grid-cols-2 gap-4\">\n            {items.map((item) => (\n                <AccountTypeGridItem\n                    key={item.title}\n                    type={item.type as AccountSelectorView}\n                    title={item.title}\n                    variant={item.variant as BoxIconVariant}\n                    icon={item.icon}\n                    onClick={onChange}\n                />\n            ))}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/AccountTypeSelector.tsx",
    "content": "import { useState, useRef, useEffect } from 'react'\nimport { RiFolderLine, RiHandCoinLine, RiLockLine, RiSearchLine } from 'react-icons/ri'\nimport maxBy from 'lodash/maxBy'\nimport {\n    BoxIcon,\n    useAccountContext,\n    useDebounce,\n    useTellerConfig,\n    useTellerConnect,\n} from '@maybe-finance/client/shared'\nimport { Input } from '@maybe-finance/design-system'\nimport InstitutionGrid from './InstitutionGrid'\nimport { AccountTypeGrid } from './AccountTypeGrid'\nimport InstitutionList, { MIN_QUERY_LENGTH } from './InstitutionList'\nimport { useLogger } from '@maybe-finance/client/shared'\n\nconst SEARCH_DEBOUNCE_MS = 300\n\nexport default function AccountTypeSelector({\n    view,\n    onViewChange,\n}: {\n    view: string\n    onViewChange: (view: string) => void\n}) {\n    const logger = useLogger()\n    const { setAccountManager } = useAccountContext()\n\n    const [searchQuery, setSearchQuery] = useState<string>('')\n    const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS)\n\n    const showInstitutionList =\n        searchQuery.length >= MIN_QUERY_LENGTH &&\n        debouncedSearchQuery.length >= MIN_QUERY_LENGTH &&\n        view !== 'manual'\n\n    const config = useTellerConfig(logger)\n\n    const { open: openTeller } = useTellerConnect(config, logger)\n\n    const inputRef = useRef<HTMLInputElement>(null)\n\n    useEffect(() => {\n        if (inputRef.current) {\n            inputRef.current.focus()\n        }\n    }, [])\n\n    return (\n        <div>\n            {/* Search */}\n            {view !== 'manual' && view !== 'crypto' && (\n                <Input\n                    className=\"mb-4\"\n                    type=\"text\"\n                    placeholder=\"Search for an institution\"\n                    fixedLeftOverride={<RiSearchLine className=\"w-5 h-5\" />}\n                    inputClassName=\"pl-11\"\n                    value={searchQuery}\n                    onChange={(e) => setSearchQuery(e.target.value)}\n                    ref={inputRef}\n                />\n            )}\n\n            {showInstitutionList && (\n                <InstitutionList\n                    searchQuery={debouncedSearchQuery}\n                    onClick={({ providers }) => {\n                        const providerInstitution = maxBy(providers, (p) => p.rank)\n                        if (!providerInstitution) {\n                            alert('No provider found for institution')\n                            return\n                        }\n\n                        switch (providerInstitution.provider) {\n                            case 'TELLER':\n                                openTeller(providerInstitution.providerId)\n                                break\n                            default:\n                                break\n                        }\n                    }}\n                    onAddManualAccountClick={() => onViewChange('manual')}\n                />\n            )}\n\n            {view === 'default' && !showInstitutionList && (\n                <div>\n                    <AccountTypeGrid\n                        onChange={(view) => {\n                            // Some actions go directly to a form while others go to a second modal view for refinement of criteria\n                            switch (view) {\n                                case 'property-form':\n                                    setAccountManager({ view: 'add-property', defaultValues: {} })\n                                    break\n                                case 'vehicle-form':\n                                    setAccountManager({ view: 'add-vehicle', defaultValues: {} })\n                                    break\n                                default:\n                                    // Go to a view below\n                                    onViewChange(view)\n                            }\n                        }}\n                    />\n                    <div className=\"flex mt-6 space-x-3 text-gray-100\">\n                        <span>\n                            <RiLockLine className=\"w-5 h-5\" />\n                        </span>\n                        <p className=\"mb-2 text-sm\">\n                            Adding your accounts is a big step. That&#39;s why we take this\n                            seriously. No one can access your accounts but you. Your information is\n                            always protected and secure.\n                        </p>\n                    </div>\n                </div>\n            )}\n\n            {(view === 'banks' || view === 'brokerages' || view === 'crypto') &&\n                !showInstitutionList && (\n                    <div className=\"flex flex-col\">\n                        {view === 'crypto' && (\n                            <p className=\"mb-4 text-sm text-gray-100\">\n                                At the moment we don't have integrations for crypto exchanges or\n                                assets, so for the time being you'll need to enter your portfolio\n                                manually.\n                            </p>\n                        )}\n                        <InstitutionGrid\n                            type={view}\n                            onClick={(data, cryptoExchangeName) => {\n                                // Crypto exchanges not supported yet, go directly to add asset form\n                                if (view === 'crypto') {\n                                    setAccountManager({\n                                        view: 'add-asset',\n                                        defaultValues: {\n                                            name: cryptoExchangeName,\n                                            categoryUser: 'crypto',\n                                        },\n                                    })\n                                    return\n                                }\n\n                                if (!data) {\n                                    return\n                                }\n\n                                switch (data.provider) {\n                                    case 'TELLER':\n                                        openTeller(data.providerId)\n                                        break\n                                    default:\n                                        break\n                                }\n                            }}\n                        />\n                    </div>\n                )}\n\n            {(view === 'banks' ||\n                view === 'brokerages' ||\n                view === 'crypto' ||\n                showInstitutionList) && (\n                <>\n                    <p className=\"mt-4 text-base text-center\">\n                        Can't find your institution?{' '}\n                        <span\n                            className=\"underline cursor-pointer text-cyan hover:opacity-80\"\n                            onClick={() => {\n                                switch (view) {\n                                    case 'banks':\n                                        setAccountManager({\n                                            view: 'add-asset',\n                                            defaultValues: { categoryUser: 'cash', name: 'Cash' },\n                                        })\n                                        break\n                                    case 'brokerages':\n                                        setAccountManager({\n                                            view: 'add-asset',\n                                            defaultValues: {\n                                                categoryUser: 'investment',\n                                                name: 'Investment',\n                                            },\n                                        })\n                                        break\n                                    case 'crypto':\n                                        setAccountManager({\n                                            view: 'add-asset',\n                                            defaultValues: {\n                                                categoryUser: 'crypto',\n                                                name: 'Cryptocurrency',\n                                            },\n                                        })\n                                        break\n                                    default:\n                                        onViewChange('manual')\n                                }\n                            }}\n                        >\n                            Add it manually\n                        </span>\n                    </p>\n                    {view === 'brokerages' && (\n                        <p className=\"mt-2 text-base text-center\">\n                            Want to manually add stocks?{' '}\n                            <span\n                                className=\"underline cursor-pointer text-cyan hover:opacity-80\"\n                                onClick={() => {\n                                    setAccountManager({\n                                        view: 'add-stock',\n                                        defaultValues: { categoryUser: 'stock', name: 'Stock' },\n                                    })\n                                }}\n                            >\n                                Add it manually\n                            </span>\n                        </p>\n                    )}\n                </>\n            )}\n\n            {view === 'manual' && (\n                <div className=\"grid grid-cols-2 gap-4\">\n                    <div\n                        className=\"flex flex-col items-center justify-between p-4 bg-gray-500 cursor-pointer rounded-xl hover:bg-gray-400\"\n                        onClick={() =>\n                            setAccountManager({\n                                view: 'add-asset',\n                                defaultValues: { name: debouncedSearchQuery },\n                            })\n                        }\n                        data-testid=\"manual-add-asset\"\n                    >\n                        <BoxIcon variant=\"teal\" icon={RiFolderLine} />\n                        <p className=\"mt-4 text-base\">Manual Asset</p>\n                    </div>\n                    <div\n                        className=\"flex flex-col items-center justify-between p-4 bg-gray-500 cursor-pointer rounded-xl hover:bg-gray-400 \"\n                        onClick={() =>\n                            setAccountManager({\n                                view: 'add-liability',\n                                defaultValues: { name: debouncedSearchQuery },\n                            })\n                        }\n                        data-testid=\"manual-add-debt\"\n                    >\n                        <BoxIcon variant=\"red\" icon={RiHandCoinLine} />\n                        <p className=\"mt-4 text-base\">Manual Debt</p>\n                    </div>\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/AccountValuationFormFields.tsx",
    "content": "import type { AccountClassification } from '@prisma/client'\n\nimport { Controller } from 'react-hook-form'\nimport { InputCurrency, DatePicker } from '@maybe-finance/design-system'\nimport { BrowserUtil } from '@maybe-finance/client/shared'\n\nexport type AccountValuationFieldProps = {\n    control: any\n    classification?: AccountClassification\n    currentBalanceEditable?: boolean\n}\n\nexport function AccountValuationFormFields({\n    control,\n    classification = 'asset',\n    currentBalanceEditable = true,\n}: AccountValuationFieldProps) {\n    return (\n        <>\n            <Controller\n                control={control}\n                name=\"startDate\"\n                rules={{ validate: BrowserUtil.validateFormDate }}\n                render={({ field, fieldState: { error } }) => (\n                    <DatePicker\n                        label={classification === 'liability' ? 'Origin date' : 'Purchase date'}\n                        error={error?.message}\n                        popperPlacement=\"top\"\n                        {...field}\n                    />\n                )}\n            />\n\n            <div className=\"flex gap-4 my-4\">\n                <Controller\n                    control={control}\n                    name=\"originalBalance\"\n                    rules={{ required: true, validate: (val) => val >= 0 }}\n                    render={({ field, fieldState: { error } }) => (\n                        <InputCurrency\n                            {...field}\n                            label={`${classification === 'liability' ? 'Start' : 'Purchase'} value`}\n                            placeholder=\"0\"\n                            error={error && 'Positive value is required'}\n                        />\n                    )}\n                />\n\n                {currentBalanceEditable && (\n                    <Controller\n                        control={control}\n                        name=\"currentBalance\"\n                        rules={{\n                            required: currentBalanceEditable,\n                            min: 0,\n                        }}\n                        shouldUnregister\n                        render={({ field, fieldState }) => (\n                            <InputCurrency\n                                {...field}\n                                label=\"Current value\"\n                                placeholder=\"0\"\n                                error={fieldState.error && 'Positive value is required'}\n                            />\n                        )}\n                    />\n                )}\n            </div>\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/AccountsManager.tsx",
    "content": "import { useAccountContext } from '@maybe-finance/client/shared'\nimport { Dialog } from '@maybe-finance/design-system'\n\nimport AccountTypeSelector from './AccountTypeSelector'\nimport { AddAsset } from './asset'\nimport { AddLiability } from './liability'\nimport { AddProperty } from './property'\nimport { AddVehicle } from './vehicle'\nimport EditAccount from './EditAccount'\nimport { DeleteAccount } from './DeleteAccount'\nimport { RiArrowLeftLine } from 'react-icons/ri'\nimport { useEffect, useMemo, useState } from 'react'\nimport { AddStock } from './stock'\n\nexport function AccountsManager() {\n    const { accountManager: am, setAccountManager } = useAccountContext()\n\n    const [subView, setSubView] = useState('default')\n\n    useEffect(() => {\n        if (am.view === 'idle') {\n            setSubView('default')\n        }\n    }, [am.view])\n\n    const accountTitle = () => {\n        switch (subView) {\n            case 'banks':\n                return 'Add bank'\n            case 'crypto':\n                return 'Add crypto'\n            case 'brokerages':\n                return 'Add investment'\n            default:\n                return 'Add account'\n        }\n    }\n\n    const view = useMemo(() => {\n        switch (am.view) {\n            case 'add-account':\n                return {\n                    title: accountTitle(),\n                    component: <AccountTypeSelector view={subView} onViewChange={setSubView} />,\n                }\n            case 'edit-account':\n                return {\n                    title: 'Edit account',\n                    component: <EditAccount accountId={am.accountId} />,\n                }\n            case 'delete-account':\n                return {\n                    title: 'Delete account',\n                    component: (\n                        <DeleteAccount\n                            accountId={am.accountId}\n                            accountName={am.accountName}\n                            onDelete={am.onDelete}\n                        />\n                    ),\n                }\n            case 'add-asset':\n                return {\n                    title: 'Manual asset',\n                    component: <AddAsset defaultValues={am.defaultValues} />,\n                }\n            case 'add-liability':\n                return {\n                    title: 'Manual debt',\n                    component: <AddLiability defaultValues={am.defaultValues} />,\n                }\n            case 'add-property':\n                return {\n                    title: 'Add real estate',\n                    component: <AddProperty defaultValues={am.defaultValues} />,\n                }\n            case 'add-vehicle':\n                return {\n                    title: 'Add vehicle',\n                    component: <AddVehicle defaultValues={am.defaultValues} />,\n                }\n            case 'add-stock':\n                return {\n                    title: 'Add investment',\n                    component: <AddStock defaultValues={am.defaultValues} />,\n                }\n            case 'custom':\n                return { title: 'Custom account', component: am.component }\n            default:\n                return null\n        }\n    }, [am, subView])\n\n    if (!view) return null\n\n    return (\n        <Dialog\n            isOpen={am.view !== 'idle'}\n            onClose={() => setAccountManager({ view: 'idle' })}\n            showCloseButton={am.view !== 'delete-account'}\n        >\n            <Dialog.Title>\n                <div className=\"flex items-center\">\n                    {!(am.view === 'add-account' && subView === 'default') &&\n                        am.view !== 'delete-account' && (\n                            <button\n                                type=\"button\"\n                                className=\"h-8 w-8 mr-3 flex items-center justify-center bg-transparent text-gray-50 hover:bg-gray-500 rounded focus:bg-gray-400 focus:outline-none\"\n                                onClick={() => {\n                                    setAccountManager({ view: 'add-account' })\n                                    setSubView('default')\n                                }}\n                            >\n                                <RiArrowLeftLine className=\"h-6 w-6\" />\n                            </button>\n                        )}\n                    {view.title}\n                </div>\n            </Dialog.Title>\n            <Dialog.Content className=\"mt-2\">{view.component}</Dialog.Content>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/DeleteAccount.tsx",
    "content": "import { useAccountApi, useAccountContext } from '@maybe-finance/client/shared'\nimport { Button, Dialog } from '@maybe-finance/design-system'\n\nexport interface DeleteAccountProps {\n    accountId: number\n    accountName: string\n    onDelete?: () => void\n}\n\nexport function DeleteAccount({ accountId, accountName, onDelete }: DeleteAccountProps) {\n    const { setAccountManager } = useAccountContext()\n\n    const { useDeleteAccount } = useAccountApi()\n    const deleteAccount = useDeleteAccount()\n\n    return (\n        <div>\n            <Dialog.Content>\n                <p className=\"text-base text-gray-50\">\n                    Deleting <span className=\"text-white\">{accountName}</span> will permanently\n                    remove this account and all other related data. This will impact other views\n                    such as your net worth dashboard.\n                </p>\n                <div className=\"mt-4 grid grid-cols-2 gap-4\">\n                    <Button variant=\"secondary\" onClick={() => setAccountManager({ view: 'idle' })}>\n                        Cancel\n                    </Button>\n                    <Button\n                        variant=\"danger\"\n                        disabled={deleteAccount.isLoading}\n                        onClick={async () => {\n                            setAccountManager({ view: 'idle' })\n                            await deleteAccount.mutate(accountId)\n                            if (onDelete) {\n                                onDelete()\n                            }\n                        }}\n                    >\n                        Delete\n                    </Button>\n                </div>\n            </Dialog.Content>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/EditAccount.tsx",
    "content": "import { useAccountApi } from '@maybe-finance/client/shared'\nimport { EditAsset } from './asset'\nimport { EditLiability } from './liability'\nimport { EditProperty } from './property'\nimport { EditVehicle } from './vehicle'\nimport { EditConnectedAccount } from './connected'\n\nexport default function EditAccount({ accountId }: { accountId?: number }) {\n    const { useAccount } = useAccountApi()\n    const accountQuery = useAccount(accountId!, { enabled: !!accountId })\n\n    if (!accountId) return null\n\n    if (accountQuery.data && accountQuery.data.type === 'LOAN') {\n        return <EditLiability account={accountQuery.data} />\n    }\n\n    if (accountQuery.data && accountQuery.data.provider !== 'user') {\n        return <EditConnectedAccount account={accountQuery.data} />\n    }\n\n    return (\n        <div>\n            {accountQuery.data && accountQuery.data.provider === 'user' ? (\n                <div>\n                    {accountQuery.data.type === 'PROPERTY' && (\n                        <EditProperty account={accountQuery.data} />\n                    )}\n\n                    {accountQuery.data.type === 'VEHICLE' && (\n                        <EditVehicle account={accountQuery.data} />\n                    )}\n\n                    {accountQuery.data.type === 'OTHER_ASSET' && (\n                        <EditAsset account={accountQuery.data} />\n                    )}\n\n                    {['OTHER_LIABILITY', 'CREDIT'].includes(accountQuery.data.type) && (\n                        <EditLiability account={accountQuery.data} />\n                    )}\n                </div>\n            ) : (\n                <p className=\"text-gray-50 animate-pulse\">Loading account details...</p>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/InstitutionGrid.tsx",
    "content": "import { useMemo } from 'react'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { Provider } from '@prisma/client'\n\ntype GridImage = {\n    src: string\n    alt: string\n    institution?: Pick<SharedType.ProviderInstitution, 'provider' | 'providerId'>\n}\n\nconst BASE_IMAGES_FOLDER = '/assets/images/financial-institutions/'\n\nconst banks: GridImage[] = [\n    {\n        src: 'chase-bank.png',\n        alt: 'Chase Bank',\n        institution: {\n            provider: Provider.TELLER,\n            providerId: 'chase',\n        },\n    },\n    {\n        src: 'capital-one.png',\n        alt: 'Capital One Bank',\n        institution: {\n            provider: Provider.TELLER,\n            providerId: 'capital_one',\n        },\n    },\n    {\n        src: 'wells-fargo.png',\n        alt: 'Wells Fargo Bank',\n        institution: {\n            provider: Provider.TELLER,\n            providerId: 'wells_fargo',\n        },\n    },\n    {\n        src: 'american-express.png',\n        alt: 'American Express Bank',\n        institution: {\n            provider: Provider.TELLER,\n            providerId: 'amex',\n        },\n    },\n    {\n        src: 'bofa.png',\n        alt: 'Bank of America',\n        institution: {\n            provider: Provider.TELLER,\n            providerId: 'bank_of_america',\n        },\n    },\n    {\n        src: 'usaa-bank.png',\n        alt: 'USAA Bank',\n        institution: {\n            provider: Provider.TELLER,\n            providerId: 'usaa',\n        },\n    },\n]\n\nconst brokerages: GridImage[] = [\n    { src: 'robinhood.png', alt: 'Robinhood' },\n    { src: 'fidelity.png', alt: 'Fidelity' },\n    { src: 'vanguard.png', alt: 'Vanguard' },\n    { src: 'wealthfront.png', alt: 'Wealthfront' },\n    { src: 'betterment.png', alt: 'Betterment' },\n    { src: 'interactive-brokers.png', alt: 'Interactive Brokers' },\n]\n\nconst cryptoExchanges: GridImage[] = [\n    { src: 'coinbase.png', alt: 'Coinbase' },\n    { src: 'binance.png', alt: 'Binance' },\n    { src: 'cash-app.png', alt: 'Cash App' },\n    { src: 'kraken.png', alt: 'Kraken' },\n    { src: 'crypto-dot-com.png', alt: 'Crypto Dot Com' },\n    { src: 'ftx.png', alt: 'FTX Exchange' },\n]\n\nexport default function InstitutionGrid({\n    type,\n    onClick,\n}: {\n    type: 'crypto' | 'banks' | 'brokerages'\n    onClick: (\n        institution?: Pick<SharedType.ProviderInstitution, 'provider' | 'providerId'>,\n        cryptoExchangeName?: string\n    ) => void\n}) {\n    const imageList = useMemo(() => {\n        switch (type) {\n            case 'crypto':\n                return cryptoExchanges\n            case 'brokerages':\n                return brokerages\n            case 'banks':\n                return banks\n        }\n    }, [type])\n\n    return (\n        <div className=\"grid grid-cols-2 gap-4\">\n            {imageList.map((img) => (\n                <img\n                    className=\"cursor-pointer hover:opacity-90 w-[193px] h-[116px]\"\n                    key={img.alt}\n                    src={`${BASE_IMAGES_FOLDER}${img.src}`}\n                    alt={img.alt}\n                    onClick={() => {\n                        switch (type) {\n                            case 'brokerages':\n                            case 'crypto':\n                                onClick(undefined, img.alt)\n                                break\n                            case 'banks':\n                                onClick(img.institution)\n                                break\n                            default:\n                                throw new Error('Invalid institution type')\n                        }\n                    }}\n                />\n            ))}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/InstitutionList.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { useMemo } from 'react'\nimport { InfiniteScroll, useInstitutionApi } from '@maybe-finance/client/shared'\nimport { Button, LoadingSpinner } from '@maybe-finance/design-system'\n\nexport const MIN_QUERY_LENGTH = 2\n\nexport default function InstitutionList({\n    searchQuery = '',\n    onClick,\n    onAddManualAccountClick,\n}: {\n    searchQuery?: string\n    onClick(institution: SharedType.Institution): void\n    onAddManualAccountClick(): void\n}) {\n    const { useInstitutions } = useInstitutionApi()\n\n    const institutionsQuery = useInstitutions(\n        { search: searchQuery },\n        { enabled: searchQuery.length >= MIN_QUERY_LENGTH }\n    )\n\n    const institutions = useMemo<SharedType.Institution[]>(() => {\n        if (!institutionsQuery.data?.pages) return []\n\n        // Flatten pages\n        return institutionsQuery.data.pages.reduce(\n            (institutions, page) => [...institutions, ...page.institutions],\n            [] as SharedType.Institution[]\n        )\n    }, [institutionsQuery.data])\n\n    return (\n        <div className=\"h-80 -ml-3 -mr-6 pr-4 custom-gray-scroll\">\n            <InfiniteScroll\n                useWindow={false}\n                initialLoad={false}\n                loadMore={() => institutionsQuery.fetchNextPage()}\n                hasMore={institutionsQuery.hasNextPage}\n            >\n                <div>\n                    {institutionsQuery?.data && (\n                        <ul className=\"space-y-2 text-base\">\n                            {institutions.map((institution) => (\n                                <li\n                                    key={institution.id}\n                                    className=\"flex items-center p-3 rounded-lg bg-transparent hover:bg-gray-600 overflow-x-hidden\"\n                                    role=\"button\"\n                                    onClick={() => onClick(institution)}\n                                >\n                                    <div className=\"shrink-0 mr-4\">\n                                        <div className=\"relative w-10 h-10 overflow-hidden rounded-full\">\n                                            {institution.logoUrl || institution.logo ? (\n                                                <img\n                                                    className=\"absolute w-full h-full\"\n                                                    src={\n                                                        institution.logoUrl ??\n                                                        `data:image/jpeg;base64, ${institution.logo}`\n                                                    }\n                                                    loading=\"lazy\"\n                                                    alt={`${institution.name} Logo`}\n                                                />\n                                            ) : (\n                                                <div\n                                                    className=\"w-full h-full bg-gray-400\"\n                                                    style={\n                                                        institution.primaryColor\n                                                            ? {\n                                                                  backgroundColor:\n                                                                      institution.primaryColor,\n                                                              }\n                                                            : undefined\n                                                    }\n                                                ></div>\n                                            )}\n                                        </div>\n                                    </div>\n                                    <div className=\"grow min-w-0\">\n                                        <span className=\"block leading-6 text-white overflow-x-hidden text-ellipsis\">\n                                            {institution.name}\n                                        </span>\n                                        {institution.url && (\n                                            <span className=\"block text-sm leading-4 text-gray-100 overflow-x-hidden text-ellipsis\">\n                                                {institution.url.replace(\n                                                    /^(https?:\\/\\/)?(www\\.)?/,\n                                                    ''\n                                                )}\n                                            </span>\n                                        )}\n                                    </div>\n                                </li>\n                            ))}\n                        </ul>\n                    )}\n\n                    {(institutionsQuery.isLoading || institutionsQuery.isFetchingNextPage) && (\n                        <div className=\"flex items-center justify-center py-4\">\n                            <LoadingSpinner variant=\"secondary\" />\n                        </div>\n                    )}\n                </div>\n            </InfiniteScroll>\n            {institutionsQuery?.data && !institutionsQuery.isLoading && !institutions.length && (\n                <div className=\"flex flex-col items-center justify-center w-full h-full px-1\">\n                    <span className=\"block text-lg text-white font-display font-bold\">\n                        No institutions found\n                    </span>\n                    <p className=\"mt-2 text-center text-base text-gray-50\">\n                        There were no institutions matching \"{searchQuery}\". Try another search\n                        term, or add it as a manual account.\n                    </p>\n                    <Button className=\"mt-4\" variant=\"secondary\" onClick={onAddManualAccountClick}>\n                        Add manual account\n                    </Button>\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/asset/AddAsset.tsx",
    "content": "import {\n    type CreateAssetFields,\n    useAccountApi,\n    useAccountContext,\n} from '@maybe-finance/client/shared'\nimport { DateTime } from 'luxon'\nimport AssetForm from './AssetForm'\n\nexport function AddAsset({ defaultValues }: { defaultValues: Partial<CreateAssetFields> }) {\n    const { setAccountManager } = useAccountContext()\n    const { useCreateAccount } = useAccountApi()\n    const createAccount = useCreateAccount()\n\n    return (\n        <div>\n            <AssetForm\n                mode=\"create\"\n                defaultValues={{\n                    name: defaultValues.name ?? 'Cash Asset',\n                    categoryUser: defaultValues.categoryUser ?? 'cash',\n                    startDate: defaultValues.startDate ?? null,\n                    currentBalance: defaultValues.currentBalance ?? null,\n                    originalBalance: defaultValues.originalBalance ?? null,\n                }}\n                onSubmit={async ({ originalBalance, currentBalance, ...rest }) => {\n                    await createAccount.mutateAsync({\n                        type: 'OTHER_ASSET',\n                        valuations: {\n                            originalBalance,\n                            currentBalance,\n                            currentDate: DateTime.now().toISODate(),\n                        },\n                        ...rest,\n                    })\n\n                    setAccountManager({ view: 'idle' })\n                }}\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/asset/AssetForm.tsx",
    "content": "import type { CreateAssetFields, UpdateAssetFields } from '@maybe-finance/client/shared'\nimport type { AccountType } from '@prisma/client'\nimport { Controller, useForm } from 'react-hook-form'\nimport { Button, Input, Listbox } from '@maybe-finance/design-system'\nimport { AccountUtil, DateUtil } from '@maybe-finance/shared'\nimport { AccountValuationFormFields } from '../AccountValuationFormFields'\nimport { useMemo } from 'react'\n\ntype Props =\n    | {\n          mode: 'create'\n          accountType?: never\n          defaultValues: CreateAssetFields\n          onSubmit(data: CreateAssetFields): void\n      }\n    | {\n          mode: 'update'\n          accountType?: AccountType\n          defaultValues: UpdateAssetFields\n          onSubmit(data: UpdateAssetFields): void\n      }\n\nexport default function AssetForm({ mode, defaultValues, onSubmit, accountType }: Props) {\n    const { register, watch, control, handleSubmit, formState } = useForm<\n        CreateAssetFields & UpdateAssetFields\n    >({\n        mode: 'onChange',\n        defaultValues,\n    })\n\n    const { errors, isSubmitting, isValid } = formState\n    const [startDate] = watch(['startDate'])\n    const currentBalanceEditable = !startDate || !DateUtil.isToday(startDate)\n    const categoryList = useMemo(() => {\n        const { cash, investment, crypto, valuable, other } = AccountUtil.CATEGORIES\n\n        if (mode === 'create') {\n            return [cash, investment, crypto, valuable, other]\n        } else {\n            return AccountUtil.CATEGORY_MAP[accountType!]\n        }\n    }, [mode, accountType])\n\n    return (\n        <form onSubmit={handleSubmit(onSubmit)} data-testid=\"asset-form\">\n            <section className=\"space-y-4 mb-8\">\n                <h6 className=\"text-white uppercase\">Details</h6>\n                <div className=\"space-y-4\">\n                    <Input\n                        type=\"text\"\n                        label=\"Name\"\n                        placeholder=\"e.g. Physical Cash\"\n                        error={errors.name && 'Name is required'}\n                        className=\"mb-4\"\n                        {...register('name', { required: true })}\n                    />\n                </div>\n\n                <div className=\"space-y-4\">\n                    <Controller\n                        control={control}\n                        name=\"categoryUser\"\n                        render={({ field }) => {\n                            return (\n                                <Listbox {...field}>\n                                    <Listbox.Button label=\"Category\" placeholder=\"Select\">\n                                        {AccountUtil.CATEGORIES[field.value].plural}\n                                    </Listbox.Button>\n                                    <Listbox.Options>\n                                        {categoryList.map((category) => (\n                                            <Listbox.Option\n                                                key={category.value}\n                                                value={category.value}\n                                            >\n                                                {category.plural}\n                                            </Listbox.Option>\n                                        ))}\n                                    </Listbox.Options>\n                                </Listbox>\n                            )\n                        }}\n                    />\n                </div>\n            </section>\n\n            {mode === 'create' && (\n                <section className=\"space-y-4\">\n                    <h6 className=\"text-white uppercase\">Valuation</h6>\n                    <div>\n                        <AccountValuationFormFields\n                            control={control}\n                            currentBalanceEditable={currentBalanceEditable}\n                        />\n                    </div>\n                </section>\n            )}\n\n            <Button\n                type=\"submit\"\n                fullWidth\n                disabled={isSubmitting || !isValid}\n                data-testid=\"asset-form-submit\"\n            >\n                {mode === 'create' ? 'Add asset' : 'Update asset'}\n            </Button>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/asset/EditAsset.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { useAccountApi, useAccountContext } from '@maybe-finance/client/shared'\nimport AssetForm from './AssetForm'\n\nexport function EditAsset({ account }: { account: SharedType.AccountDetail }) {\n    const { setAccountManager } = useAccountContext()\n\n    const { useUpdateAccount } = useAccountApi()\n    const updateAccount = useUpdateAccount()\n\n    return (\n        <AssetForm\n            mode=\"update\"\n            accountType={account.type}\n            defaultValues={{ name: account.name, categoryUser: account.category }}\n            onSubmit={async (data) => {\n                await updateAccount.mutateAsync({\n                    id: account.id,\n                    data: {\n                        provider: account.provider,\n                        data: {\n                            type: account.type,\n                            ...data,\n                        },\n                    },\n                })\n\n                setAccountManager({ view: 'idle' })\n            }}\n        />\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/asset/index.ts",
    "content": "export * from './AddAsset'\nexport * from './EditAsset'\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/connected/ConnectedAccountForm.tsx",
    "content": "import type { AccountCategory, AccountType } from '@prisma/client'\n\nimport { Controller, useForm } from 'react-hook-form'\nimport { Button, DatePicker, Input, Listbox } from '@maybe-finance/design-system'\nimport { AccountUtil } from '@maybe-finance/shared'\nimport { BrowserUtil } from '@maybe-finance/client/shared'\n\ntype FormData = {\n    name: string\n    categoryUser: AccountCategory\n    startDate: string | null\n}\n\ntype Props = {\n    accountType: AccountType\n    defaultValues: FormData\n    onSubmit(data: FormData): void\n}\n\nexport default function ConnectedAccountForm({ defaultValues, onSubmit, accountType }: Props) {\n    const {\n        control,\n        register,\n        handleSubmit,\n        formState: { isSubmitting, isValid },\n    } = useForm({\n        mode: 'onChange',\n        defaultValues,\n    })\n\n    return (\n        <form onSubmit={handleSubmit(onSubmit)} data-testid=\"connected-account-form\">\n            <Input\n                type=\"text\"\n                className=\"flex-1 bg-gray-700\"\n                label=\"Name\"\n                placeholder=\"Account Name\"\n                autoFocus\n                {...register('name', { required: true })}\n            />\n\n            <div className=\"mt-4\">\n                <Controller\n                    control={control}\n                    name=\"categoryUser\"\n                    render={({ field }) => (\n                        <Listbox {...field}>\n                            <Listbox.Button label=\"Category\" placeholder=\"Select\">\n                                {AccountUtil.CATEGORIES[field.value].plural}\n                            </Listbox.Button>\n                            <Listbox.Options>\n                                {AccountUtil.CATEGORY_MAP[accountType].map((category) => (\n                                    <Listbox.Option key={category.value} value={category.value}>\n                                        {category.plural}\n                                    </Listbox.Option>\n                                ))}\n                            </Listbox.Options>\n                        </Listbox>\n                    )}\n                />\n            </div>\n\n            <div className=\"mt-4 mb-6\">\n                <Controller\n                    control={control}\n                    name=\"startDate\"\n                    rules={{\n                        validate: (d) => BrowserUtil.validateFormDate(d, { required: false }),\n                    }}\n                    render={({ field, fieldState: { error } }) => {\n                        return (\n                            <DatePicker\n                                label=\"Account start date\"\n                                popperPlacement=\"top\"\n                                error={error?.message}\n                                {...field}\n                            />\n                        )\n                    }}\n                />\n            </div>\n\n            <Button type=\"submit\" fullWidth disabled={isSubmitting || !isValid}>\n                Update account\n            </Button>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/connected/EditConnectedAccount.tsx",
    "content": "import { DateUtil, type SharedType } from '@maybe-finance/shared'\nimport { useAccountApi, useAccountContext } from '@maybe-finance/client/shared'\nimport ConnectedAccountForm from './ConnectedAccountForm'\n\nexport function EditConnectedAccount({ account }: { account: SharedType.AccountDetail }) {\n    const { setAccountManager } = useAccountContext()\n\n    const { useUpdateAccount } = useAccountApi()\n    const updateAccount = useUpdateAccount()\n\n    return (\n        <ConnectedAccountForm\n            accountType={account.type}\n            defaultValues={{\n                name: account.name,\n                startDate: DateUtil.dateTransform(account.startDate),\n                categoryUser: account.category,\n            }}\n            onSubmit={async (data) => {\n                await updateAccount.mutateAsync({\n                    id: account.id,\n                    data: {\n                        provider: account.provider,\n                        data: {\n                            type: account.type,\n                            ...data,\n                        },\n                    },\n                })\n\n                setAccountManager({ view: 'idle' })\n            }}\n        />\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/connected/index.ts",
    "content": "export * from './EditConnectedAccount'\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/index.ts",
    "content": "export * from './AccountsManager'\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/liability/AddLiability.tsx",
    "content": "import {\n    type CreateLiabilityFields,\n    useAccountApi,\n    useAccountContext,\n} from '@maybe-finance/client/shared'\nimport { DateTime } from 'luxon'\nimport LiabilityForm from './LiabilityForm'\n\nexport function AddLiability({ defaultValues }: { defaultValues: Partial<CreateLiabilityFields> }) {\n    const { setAccountManager } = useAccountContext()\n    const { useCreateAccount } = useAccountApi()\n    const createAccount = useCreateAccount()\n\n    return (\n        <LiabilityForm\n            mode=\"create\"\n            defaultValues={{\n                name: defaultValues.name ?? 'Manual liability',\n                categoryUser: defaultValues.categoryUser ?? 'other',\n                maturityDate: defaultValues.maturityDate ?? '',\n                interestRate: defaultValues.interestRate ?? 5,\n                interestType: defaultValues.interestType ?? 'fixed',\n                loanType: defaultValues.loanType ?? 'mortgage',\n                startDate: null,\n                currentBalance: null,\n                originalBalance: null,\n            }}\n            onSubmit={async ({\n                categoryUser,\n                name,\n                currentBalance,\n                startDate,\n                originalBalance,\n                maturityDate,\n                interestRate,\n                interestType,\n                loanType,\n            }) => {\n                switch (categoryUser) {\n                    case 'loan':\n                        await createAccount.mutateAsync({\n                            name,\n                            type: 'LOAN',\n                            categoryUser: 'loan',\n                            currentBalance,\n                            startDate,\n                            loanUser: {\n                                originationDate: startDate,\n                                originationPrincipal: originalBalance,\n                                maturityDate,\n                                interestRate: {\n                                    type: interestType,\n                                    rate:\n                                        interestType === 'fixed' ? interestRate! / 100 : undefined,\n                                },\n                                loanDetail: {\n                                    type: loanType,\n                                },\n                            },\n                        })\n                        break\n                    default:\n                        await createAccount.mutateAsync({\n                            type: categoryUser === 'credit' ? 'CREDIT' : 'OTHER_LIABILITY',\n                            categoryUser,\n                            name,\n                            startDate,\n                            valuations: {\n                                originalBalance,\n                                currentBalance,\n                                currentDate: DateTime.now().toISODate(),\n                            },\n                        })\n                }\n\n                setAccountManager({ view: 'idle' })\n            }}\n        />\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/liability/EditLiability.tsx",
    "content": "import { DateUtil, type SharedType } from '@maybe-finance/shared'\nimport { useAccountApi, useAccountContext } from '@maybe-finance/client/shared'\nimport LiabilityForm from './LiabilityForm'\nimport { DateTime } from 'luxon'\n\nexport function EditLiability({ account }: { account: SharedType.AccountDetail }) {\n    const { setAccountManager } = useAccountContext()\n\n    const { useUpdateAccount } = useAccountApi()\n    const updateAccount = useUpdateAccount()\n\n    const defaultLoanValues = {\n        maturityDate: account.loan?.maturityDate ?? '',\n        interestRate:\n            account.loan?.interestRate.type === 'fixed'\n                ? account.loan.interestRate.rate\n                    ? account.loan.interestRate.rate * 100\n                    : null\n                : null,\n        loanType: account.loan?.loanDetail.type ?? null,\n        interestType: account.loan?.interestRate.type ?? null,\n        originalBalance: account.loan?.originationPrincipal ?? null,\n        currentBalance: account.currentBalance?.toNumber() ?? null,\n        startDate: DateUtil.dateTransform(account.startDate),\n    }\n\n    return (\n        <LiabilityForm\n            mode=\"update\"\n            accountType={account.type}\n            defaultValues={{\n                name: account.name,\n                categoryUser: account.category,\n                ...defaultLoanValues,\n            }}\n            onSubmit={async ({\n                name,\n                categoryUser,\n                maturityDate,\n                originalBalance,\n                currentBalance,\n                interestType,\n                loanType,\n                interestRate,\n                startDate,\n            }) => {\n                switch (categoryUser) {\n                    case 'loan': {\n                        await updateAccount.mutateAsync({\n                            id: account.id,\n                            data: {\n                                provider: account.provider,\n                                data: {\n                                    type: 'LOAN',\n                                    name,\n                                    categoryUser,\n                                    currentBalance,\n                                    startDate,\n                                    loanUser: {\n                                        originationDate: startDate,\n                                        originationPrincipal: originalBalance,\n                                        maturityDate,\n                                        interestRate: {\n                                            type: interestType,\n                                            rate:\n                                                interestType === 'fixed'\n                                                    ? interestRate! / 100\n                                                    : undefined,\n                                        },\n                                        loanDetail: { type: loanType },\n                                    },\n                                },\n                            },\n                        })\n                        break\n                    }\n                    default: {\n                        await updateAccount.mutateAsync({\n                            id: account.id,\n                            data: {\n                                provider: account.provider,\n                                data: {\n                                    type: categoryUser === 'credit' ? 'CREDIT' : 'OTHER_LIABILITY',\n                                    categoryUser,\n                                    name,\n                                },\n                            },\n                        })\n                    }\n                }\n\n                setAccountManager({ view: 'idle' })\n            }}\n        />\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/liability/LiabilityForm.tsx",
    "content": "import type { AccountType } from '@prisma/client'\nimport { Controller, useForm } from 'react-hook-form'\nimport { Button, Input, Listbox } from '@maybe-finance/design-system'\nimport { AccountUtil, DateUtil } from '@maybe-finance/shared'\nimport { AccountValuationFormFields } from '../AccountValuationFormFields'\nimport type { CreateLiabilityFields, UpdateLiabilityFields } from '@maybe-finance/client/shared'\nimport { NumericFormat } from 'react-number-format'\nimport { DateTime } from 'luxon'\n\nconst loanTypes = [\n    {\n        label: 'Home Loan',\n        value: 'mortgage',\n    },\n    {\n        label: 'Student Loan',\n        value: 'student',\n    },\n    {\n        label: 'Other Loan',\n        value: 'other',\n    },\n]\n\nconst loanInterestTypes = [\n    {\n        label: 'Fixed',\n        value: 'fixed',\n    },\n    {\n        label: 'Variable',\n        value: 'variable',\n    },\n    {\n        label: 'Adjustable Rate Mortgage',\n        value: 'arm',\n    },\n]\n\ntype Props =\n    | {\n          mode: 'create'\n          accountType?: never\n          defaultValues: CreateLiabilityFields\n          onSubmit(data: CreateLiabilityFields): void\n      }\n    | {\n          mode: 'update'\n          accountType: AccountType\n          defaultValues: UpdateLiabilityFields\n          onSubmit(data: UpdateLiabilityFields): void\n      }\n\nexport default function LiabilityForm({ mode, defaultValues, onSubmit, accountType }: Props) {\n    const {\n        register,\n        watch,\n        control,\n        handleSubmit,\n        formState: { errors, isSubmitting, isValid },\n    } = useForm<CreateLiabilityFields & UpdateLiabilityFields>({\n        mode: 'onChange',\n        defaultValues,\n    })\n\n    const [startDate, interestType, maturityDate, categoryUser] = watch([\n        'startDate',\n        'interestType',\n        'maturityDate',\n        'categoryUser',\n    ])\n\n    const currentBalanceEditable = !DateUtil.isToday(startDate)\n\n    const unroundedTerm =\n        maturityDate && startDate\n            ? DateUtil.datetimeTransform(maturityDate)\n                  .diff(DateUtil.datetimeTransform(startDate), 'months')\n                  .toObject().months\n            : null\n\n    const loanTerm = unroundedTerm ? Math.round(unroundedTerm) : null\n\n    // If in update mode, certain categories cannot be changed (e.g. cannot change from \"other\" to a \"loan\" after account has been created)\n    const categoryList =\n        mode === 'create' ? AccountUtil.LIABILITY_CATEGORIES : AccountUtil.CATEGORY_MAP[accountType]\n\n    return (\n        <form onSubmit={handleSubmit(onSubmit)} data-testid=\"liability-form\">\n            <h6 className=\"text-white uppercase\">Details</h6>\n\n            <section className=\"space-y-4 my-4\">\n                <Input\n                    type=\"text\"\n                    label=\"Name\"\n                    error={errors.name && 'Name is required'}\n                    className=\"mb-4\"\n                    placeholder={\n                        categoryUser === 'loan' ? 'e.g. Mortgage Loan' : 'e.g. Debt to a friend'\n                    }\n                    {...register('name', { required: true })}\n                />\n\n                <Controller\n                    control={control}\n                    name=\"categoryUser\"\n                    render={({ field }) => (\n                        <Listbox {...field}>\n                            <Listbox.Button label=\"Category\" placeholder=\"Select\">\n                                {AccountUtil.CATEGORIES[field.value].plural}\n                            </Listbox.Button>\n                            <Listbox.Options>\n                                {categoryList.map((category) => (\n                                    <Listbox.Option key={category.value} value={category.value}>\n                                        {category.plural}\n                                    </Listbox.Option>\n                                ))}\n                            </Listbox.Options>\n                        </Listbox>\n                    )}\n                />\n            </section>\n\n            {categoryUser === 'loan' && <h6 className=\"uppercase mt-6\">Loan Terms</h6>}\n\n            <section className=\"my-4 space-y-4\">\n                {categoryUser === 'loan' && (\n                    <>\n                        <Controller\n                            control={control}\n                            name=\"loanType\"\n                            render={({ field }) => (\n                                <Listbox {...field}>\n                                    <Listbox.Button label=\"Loan type\" placeholder=\"Select\">\n                                        {loanTypes.find((t) => t.value === field.value)?.label}\n                                    </Listbox.Button>\n                                    <Listbox.Options>\n                                        {loanTypes.map((type) => (\n                                            <Listbox.Option key={type.value} value={type.value}>\n                                                {type.label}\n                                            </Listbox.Option>\n                                        ))}\n                                    </Listbox.Options>\n                                </Listbox>\n                            )}\n                        />\n\n                        <Controller\n                            control={control}\n                            name=\"interestType\"\n                            render={({ field }) => (\n                                <Listbox {...field}>\n                                    <Listbox.Button label=\"Interest type\" placeholder=\"Select\">\n                                        {\n                                            loanInterestTypes.find((t) => t.value === field.value)\n                                                ?.label\n                                        }\n                                    </Listbox.Button>\n                                    <Listbox.Options>\n                                        {loanInterestTypes.map((type) => (\n                                            <Listbox.Option key={type.value} value={type.value}>\n                                                {type.label}\n                                            </Listbox.Option>\n                                        ))}\n                                    </Listbox.Options>\n                                </Listbox>\n                            )}\n                        />\n                    </>\n                )}\n\n                {(mode === 'create' || (mode === 'update' && categoryUser === 'loan')) && (\n                    <div>\n                        {categoryUser !== 'loan' && (\n                            <h6 className=\"text-white uppercase\">Balance</h6>\n                        )}\n                        <AccountValuationFormFields\n                            control={control}\n                            currentBalanceEditable={currentBalanceEditable}\n                            classification=\"liability\"\n                        />\n                    </div>\n                )}\n\n                {categoryUser === 'loan' && (\n                    <>\n                        <div className=\"flex gap-4\">\n                            <Controller\n                                control={control}\n                                name=\"maturityDate\"\n                                rules={{ required: true }}\n                                render={({ field }) => (\n                                    <NumericFormat\n                                        name={field.name}\n                                        label=\"Loan term\"\n                                        customInput={Input}\n                                        fixedRightOverride={\n                                            <span className=\"text-gray-100 text-base\">months</span>\n                                        }\n                                        placeholder=\"360\"\n                                        allowNegative={false}\n                                        value={loanTerm}\n                                        onValueChange={(value) => {\n                                            const newMaturityDate = DateUtil.datetimeTransform(\n                                                startDate\n                                            )\n                                                ?.plus({ months: value.floatValue })\n                                                .toISODate()\n\n                                            if (newMaturityDate) {\n                                                field.onChange(newMaturityDate)\n                                            }\n                                        }}\n                                    />\n                                )}\n                            />\n\n                            {interestType === 'fixed' && (\n                                <Controller\n                                    control={control}\n                                    name=\"interestRate\"\n                                    rules={{ required: true, validate: (val) => !val || val >= 0 }}\n                                    render={({ field }) => (\n                                        <NumericFormat\n                                            {...field}\n                                            label=\"Interest rate %\"\n                                            customInput={Input}\n                                            placeholder={'5.50'}\n                                            inputClassName=\"w-full\"\n                                            decimalScale={2}\n                                            allowLeadingZeros\n                                            fixedDecimalScale\n                                            allowNegative={false}\n                                            value={field.value}\n                                            onValueChange={(value) => {\n                                                field.onChange(value.value)\n                                            }}\n                                        />\n                                    )}\n                                />\n                            )}\n                        </div>\n\n                        {interestType !== 'fixed' && (\n                            <p className=\"text-sm text-gray-100 my-4\">\n                                We are still working on full support for non-standard loan terms.\n                                Your details will be saved, but your metrics shown will be limited.\n                            </p>\n                        )}\n                    </>\n                )}\n            </section>\n\n            <Button\n                type=\"submit\"\n                fullWidth\n                disabled={isSubmitting || !isValid}\n                data-testid=\"liability-form-submit\"\n            >\n                {mode === 'create' ? 'Add debt' : 'Update'}\n            </Button>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/liability/index.ts",
    "content": "export * from './AddLiability'\nexport * from './EditLiability'\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/property/AddProperty.tsx",
    "content": "import {\n    useAccountContext,\n    useAccountApi,\n    type CreatePropertyFields,\n} from '@maybe-finance/client/shared'\nimport { DateTime } from 'luxon'\nimport PropertyForm from './PropertyForm'\n\nexport function AddProperty({ defaultValues }: { defaultValues: Partial<CreatePropertyFields> }) {\n    const { setAccountManager } = useAccountContext()\n    const { useCreateAccount } = useAccountApi()\n    const createAccount = useCreateAccount()\n\n    return (\n        <div>\n            <PropertyForm\n                mode=\"create\"\n                defaultValues={{\n                    line1: defaultValues.line1 ?? '',\n                    city: defaultValues.city ?? '',\n                    state: defaultValues.state ?? '',\n                    country: defaultValues.country ?? 'US',\n                    zip: defaultValues.zip ?? '',\n                    startDate: defaultValues.startDate ?? null,\n                    originalBalance: defaultValues.originalBalance ?? null,\n                    currentBalance: defaultValues.currentBalance ?? null,\n                }}\n                onSubmit={async ({\n                    line1,\n                    city,\n                    state,\n                    country,\n                    zip,\n                    originalBalance,\n                    currentBalance,\n                    startDate,\n                }) => {\n                    await createAccount.mutateAsync({\n                        type: 'PROPERTY',\n                        categoryUser: 'property',\n                        name: line1,\n                        startDate,\n                        valuations: {\n                            originalBalance,\n                            currentBalance,\n                            currentDate: DateTime.now().toISODate(),\n                        },\n                        propertyMeta: {\n                            address: {\n                                line1,\n                                city,\n                                state,\n                                country,\n                                zip,\n                            },\n                        },\n                    })\n\n                    setAccountManager({ view: 'idle' })\n                }}\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/property/EditProperty.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport {\n    type UpdatePropertyFields,\n    useAccountApi,\n    useAccountContext,\n} from '@maybe-finance/client/shared'\nimport PropertyForm from './PropertyForm'\n\nexport function EditProperty({ account }: { account: SharedType.AccountDetail }) {\n    const { setAccountManager } = useAccountContext()\n\n    const { useUpdateAccount } = useAccountApi()\n    const updateAccount = useUpdateAccount()\n\n    return (\n        <PropertyForm\n            mode=\"update\"\n            defaultValues={(account.propertyMeta as any)?.address as UpdatePropertyFields}\n            onSubmit={async ({ line1, city, state, country, zip, ...rest }) => {\n                await updateAccount.mutateAsync({\n                    id: account.id,\n                    data: {\n                        provider: account.provider,\n                        data: {\n                            type: account.type,\n                            categoryUser: 'property',\n                            name: line1,\n                            propertyMeta: {\n                                address: {\n                                    line1,\n                                    city,\n                                    state,\n                                    country,\n                                    zip,\n                                },\n                            },\n                            ...rest,\n                        },\n                    },\n                })\n\n                setAccountManager({ view: 'idle' })\n            }}\n        />\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/property/PropertyForm.tsx",
    "content": "import type { CreatePropertyFields, UpdatePropertyFields } from '@maybe-finance/client/shared'\nimport { Button, Input, Listbox } from '@maybe-finance/design-system'\nimport { DateUtil, Geo } from '@maybe-finance/shared'\nimport { Controller, useForm } from 'react-hook-form'\nimport { AccountValuationFormFields } from '../AccountValuationFormFields'\n\ntype Props =\n    | {\n          mode: 'create'\n          defaultValues: CreatePropertyFields\n          onSubmit(data: CreatePropertyFields): void\n      }\n    | {\n          mode: 'update'\n          defaultValues: UpdatePropertyFields\n          onSubmit(data: UpdatePropertyFields): void\n      }\n\nexport default function PropertyForm({ mode, defaultValues, onSubmit }: Props) {\n    const {\n        register,\n        control,\n        handleSubmit,\n        watch,\n        formState: { errors, isSubmitting, isValid },\n    } = useForm<CreatePropertyFields & UpdatePropertyFields>({\n        mode: 'onChange',\n        defaultValues,\n    })\n\n    const startDate = watch('startDate')\n    const currentBalanceEditable = !DateUtil.isToday(startDate)\n\n    return (\n        <form onSubmit={handleSubmit(onSubmit)} data-testid=\"property-form\">\n            <section className=\"space-y-4 mb-8\">\n                <h6 className=\"text-white uppercase\">Location</h6>\n                <div className=\"space-y-4\">\n                    <Controller\n                        name=\"country\"\n                        rules={{ required: true }}\n                        control={control}\n                        render={({ field }) => (\n                            <Listbox {...field}>\n                                <Listbox.Button label=\"Country\">\n                                    {Geo.countries.find((c) => c.code === field.value)?.name ||\n                                        'Select'}\n                                </Listbox.Button>\n                                <Listbox.Options className=\"max-h-[300px] custom-gray-scroll\">\n                                    {Geo.countries.map((country) => (\n                                        <Listbox.Option key={country.code} value={country.code}>\n                                            {country.name}\n                                        </Listbox.Option>\n                                    ))}\n                                </Listbox.Options>\n                            </Listbox>\n                        )}\n                    />\n\n                    <Input\n                        type=\"text\"\n                        label=\"Address\"\n                        placeholder=\"Enter address\"\n                        error={errors.line1 && 'Address is required'}\n                        {...register('line1', { required: true })}\n                    />\n\n                    <Input\n                        type=\"text\"\n                        label=\"City\"\n                        placeholder=\"Enter city\"\n                        error={errors.city && 'City is required'}\n                        {...register('city', { required: true })}\n                    />\n\n                    <div className=\"flex gap-4\">\n                        <Input\n                            type=\"text\"\n                            label=\"State\"\n                            placeholder=\"Enter state\"\n                            error={errors.state && 'State is required'}\n                            {...register('state', { required: true })}\n                        />\n\n                        <Input\n                            type=\"text\"\n                            label=\"Postal Code\"\n                            placeholder=\"Enter postal code\"\n                            error={errors.zip && 'Postal code is required'}\n                            {...register('zip', { required: true })}\n                        />\n                    </div>\n                </div>\n            </section>\n\n            {mode === 'create' && (\n                <section className=\"space-y-4\">\n                    <h6 className=\"text-white uppercase\">Valuation</h6>\n                    <div>\n                        <AccountValuationFormFields\n                            control={control}\n                            currentBalanceEditable={currentBalanceEditable}\n                        />\n                    </div>\n                </section>\n            )}\n\n            <Button\n                type=\"submit\"\n                fullWidth\n                disabled={isSubmitting || !isValid}\n                data-testid=\"property-form-submit\"\n            >\n                {mode === 'create' ? 'Add property' : 'Update property'}\n            </Button>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/property/index.ts",
    "content": "export * from './AddProperty'\nexport * from './EditProperty'\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/stock/AddStock.tsx",
    "content": "import {\n    type UpdateStockFields,\n    useAccountApi,\n    useAccountContext,\n} from '@maybe-finance/client/shared'\nimport { DateTime } from 'luxon'\nimport StockForm from './StockForm'\n\n// STOCKTODO - Change CreateStockFields\nexport function AddStock({ defaultValues }: { defaultValues: Partial<UpdateStockFields> }) {\n    const { setAccountManager } = useAccountContext()\n    const { useCreateAccount } = useAccountApi()\n    // STOCKTODO - The account needs to be updated here, not created.\n    const createAccount = useCreateAccount()\n\n    return (\n        <div>\n            <StockForm\n                // STOCKTODO - This doesn't create a new account but adds a stock a pre-existing account\n                mode=\"update\"\n                defaultValues={{\n                    account_id: defaultValues.account_id ?? null,\n                    stock: defaultValues.stock ?? '',\n                    startDate: defaultValues.startDate ?? null,\n                    originalBalance: defaultValues.originalBalance ?? null,\n                    shares: defaultValues.shares ?? null,\n                }}\n                onSubmit={async ({ account_id, stock, startDate, originalBalance, shares }) => {\n                    // STOCKTODO : Figure out what all is required to create a stock account\n                    await createAccount.mutateAsync({\n                        // STOCKTODO : Change type based on whether you choose to go with the 'STOCK' type\n                        type: 'INVESTMENT',\n                        // STOCKTODO : Figure out what the categoryUser is\n                        categoryUser: 'investment',\n                        // STOCKTODO : Valuations should be based on the stocks\n                        valuations: {\n                            originalBalance,\n                            currentBalance,\n                            currentDate: DateTime.now().toISODate(),\n                        },\n                        // STOCKTODO : Change this according this to stock. this should be\n                        name: `${make} ${model}`,\n                        startDate,\n                    })\n\n                    setAccountManager({ view: 'idle' })\n                }}\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/stock/CreateStockAccount.tsx",
    "content": "import { useAccountApi } from '@maybe-finance/client/shared'\nimport { Button, Dialog, Input } from '@maybe-finance/design-system'\nimport { useState } from 'react'\nimport { useForm } from 'react-hook-form'\nimport { RiAddLine } from 'react-icons/ri'\n\ntype CreateStockAccountProps = {\n    name: string | null\n}\n\nexport default function CreateStockAccount() {\n    const {\n        register,\n        handleSubmit,\n        formState: { isSubmitting, isValid },\n        // STOCKTODO - Fix UpdateVehicleFields\n    } = useForm<CreateStockAccountProps>({\n        mode: 'onChange',\n    })\n\n    const [isOpen, setIsOpen] = useState<boolean>(false)\n\n    const { useCreateAccount } = useAccountApi()\n    const createAccount = useCreateAccount()\n\n    // STOCKTODO : Create a type for this\n    async function onSubmit({ name }: CreateStockAccountProps) {\n        setIsOpen(false)\n\n        // STOCKTODO - Figure out a way to get the value from <Input/> to `name` here\n        await createAccount.mutateAsync({\n            type: 'INVESTMENT',\n            categoryUser: 'investment',\n            name: name,\n        })\n    }\n\n    return (\n        <>\n            <Button className=\"h-10\" onClick={() => setIsOpen(true)}>\n                <RiAddLine size={20} />\n            </Button>\n            <Dialog isOpen={isOpen} onClose={() => setIsOpen(false)}>\n                <form onSubmit={handleSubmit(onSubmit)} data-testid=\"create-stock-account-form\">\n                    <Dialog.Title>Create Investment Account</Dialog.Title>\n                    <Dialog.Content>\n                        <div className=\"space-y-3\">\n                            <Input type=\"text\" label=\"Account name\" {...register('name')} />\n                        </div>\n                    </Dialog.Content>\n                    <Dialog.Actions>\n                        <Button type=\"submit\" fullWidth disabled={isSubmitting || !isValid}>\n                            Create Account\n                        </Button>\n                    </Dialog.Actions>\n                </form>\n            </Dialog>\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/stock/StockForm.tsx",
    "content": "import {\n    useAccountApi,\n    useAccountContext,\n    type UpdateStockFields,\n    type UpdateVehicleFields,\n} from '@maybe-finance/client/shared'\nimport { Button, Dialog, Input, Listbox } from '@maybe-finance/design-system'\nimport { DateUtil } from '@maybe-finance/shared'\nimport { useForm } from 'react-hook-form'\nimport { AccountValuationFormFields } from '../AccountValuationFormFields'\nimport { useState } from 'react'\nimport { RiAddLine } from 'react-icons/ri'\nimport CreateStockAccount from './CreateStockAccount'\n\ntype Props = {\n    mode: 'update'\n    defaultValues: UpdateStockFields\n    onSubmit(data: UpdateStockFields): void\n}\n\nexport default function StockForm({ mode, defaultValues, onSubmit }: Props) {\n    const {\n        register,\n        control,\n        handleSubmit,\n        watch,\n        formState: { errors, isSubmitting, isValid },\n    } = useForm<UpdateStockFields>({\n        mode: 'onChange',\n        defaultValues,\n    })\n\n    const startDate = watch('startDate')\n    const currentBalanceEditable = !startDate || !DateUtil.isToday(startDate)\n\n    const [stockSymbol, setStockSymbol] = useState<string | null>(null)\n    const [account, setAccount] = useState<string | null>(null)\n\n    const { useAccounts } = useAccountApi()\n    const { data: accountsData } = useAccounts()\n\n    const stockAccountsList = accountsData?.accounts.filter(\n        (account) => account.type === 'INVESTMENT'\n    )\n\n    return (\n        <form onSubmit={handleSubmit(onSubmit)} data-testid=\"stock-form\">\n            <section className=\"space-y-4 mb-8\">\n                <h6 className=\"text-white uppercase\">Details</h6>\n                <div className=\"space-y-4\">\n                    {/* \n                        STOCKTODO - Change this to a drop down where a pre-existing stock-account can be selected or a new stock account can be created\n\n                        Also make sure this this is submitted to the form. There was a ...register('make', { required: true }) here\n                    */}\n                    {/* STOCKTODO - Create currently selected stock state */}\n                    <div className=\"flex flex-row gap-2 items-end\">\n                        {stockAccountsList && (\n                            <Listbox value={account} onChange={setAccount} className=\"flex-1\">\n                                <Listbox.Button label=\"Investment account\"></Listbox.Button>\n                                <Listbox.Options>\n                                    {stockAccountsList?.map((account) => (\n                                        // STOCKTODO - Figure out the correct account value - probably will be the symbol\n                                        <Listbox.Option key={account.id} value={account.name}>\n                                            {account.name}\n                                        </Listbox.Option>\n                                    ))}\n                                </Listbox.Options>\n                            </Listbox>\n                        )}\n                        {/* STOCKTODO - When this button is clicked, go to the modal to create a new account */}\n                        <CreateStockAccount />\n                    </div>\n\n                    {/* STOCKTODO - Change to to a drop down where all the stocks will be listed and can be chosen by their ticker names */}\n                    {/* <Listbox value={stockSymbol} onChange={setStockSymbol}>\n                        <Listbox.Button label=\"Investment account\"></Listbox.Button>\n                        <Listbox.Options>\n                            {stocksList.map((stock) => (\n                                // STOCKTODO - Figure out the correct stock value - probably will be the symbol\n                                <Listbox.Option key={stock.key} value={stock.symbol}>\n                                    {stock.name}\n                                </Listbox.Option>\n                            ))}\n                        </Listbox.Options>\n                    </Listbox> */}\n                </div>\n            </section>\n\n            {mode === 'update' && (\n                <section className=\"space-y-4\">\n                    <h6 className=\"text-white uppercase\">Valuation</h6>\n                    <div>\n                        {/* \n                            STOCKTODO - Figure out a way to get the necessary properties here\n                            1. Purchase Date\n                            2. Total Purchase Value\n                            3. Number of Shares \n                        */}\n                        <AccountValuationFormFields\n                            control={control}\n                            currentBalanceEditable={currentBalanceEditable}\n                        />\n                    </div>\n                </section>\n            )}\n\n            <Button\n                type=\"submit\"\n                fullWidth\n                disabled={isSubmitting || !isValid}\n                data-testid=\"stock-form-submit\"\n            >\n                {mode === 'update' ? 'Add stock' : 'Update'}\n            </Button>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/stock/index.ts",
    "content": "export * from './AddStock'\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/vehicle/AddVehicle.tsx",
    "content": "import {\n    type CreateVehicleFields,\n    useAccountApi,\n    useAccountContext,\n} from '@maybe-finance/client/shared'\nimport { DateTime } from 'luxon'\nimport VehicleForm from './VehicleForm'\n\nexport function AddVehicle({ defaultValues }: { defaultValues: Partial<CreateVehicleFields> }) {\n    const { setAccountManager } = useAccountContext()\n    const { useCreateAccount } = useAccountApi()\n    const createAccount = useCreateAccount()\n\n    return (\n        <div>\n            <VehicleForm\n                mode=\"create\"\n                defaultValues={{\n                    make: defaultValues.make ?? '',\n                    model: defaultValues.model ?? '',\n                    year: defaultValues.year ?? '',\n                    startDate: defaultValues.startDate ?? null,\n                    originalBalance: defaultValues.originalBalance ?? null,\n                    currentBalance: defaultValues.currentBalance ?? null,\n                }}\n                onSubmit={async ({\n                    originalBalance,\n                    currentBalance,\n                    make,\n                    model,\n                    year,\n                    startDate,\n                }) => {\n                    await createAccount.mutateAsync({\n                        type: 'VEHICLE',\n                        categoryUser: 'vehicle',\n                        valuations: {\n                            originalBalance,\n                            currentBalance,\n                            currentDate: DateTime.now().toISODate(),\n                        },\n                        vehicleMeta: {\n                            make,\n                            model,\n                            year: parseInt(year),\n                        },\n                        name: `${make} ${model}`,\n                        startDate,\n                    })\n\n                    setAccountManager({ view: 'idle' })\n                }}\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/vehicle/EditVehicle.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport {\n    type UpdateVehicleFields,\n    useAccountApi,\n    useAccountContext,\n} from '@maybe-finance/client/shared'\nimport VehicleForm from './VehicleForm'\n\nexport function EditVehicle({ account }: { account: SharedType.AccountDetail }) {\n    const { setAccountManager } = useAccountContext()\n\n    const { useUpdateAccount } = useAccountApi()\n    const updateAccount = useUpdateAccount()\n\n    return (\n        <VehicleForm\n            mode=\"update\"\n            defaultValues={account.vehicleMeta as UpdateVehicleFields}\n            onSubmit={async ({ make, model, year, ...rest }) => {\n                await updateAccount.mutateAsync({\n                    id: account.id,\n                    data: {\n                        provider: account.provider,\n                        data: {\n                            type: account.type,\n                            categoryUser: 'vehicle',\n                            name: `${make} ${model}`,\n                            vehicleMeta: {\n                                make,\n                                model,\n                                year: parseInt(year),\n                            },\n                            ...rest,\n                        },\n                    },\n                })\n\n                setAccountManager({ view: 'idle' })\n            }}\n        />\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/vehicle/VehicleForm.tsx",
    "content": "import type { CreateVehicleFields, UpdateVehicleFields } from '@maybe-finance/client/shared'\nimport { Button, Input } from '@maybe-finance/design-system'\nimport { DateUtil } from '@maybe-finance/shared'\nimport { useForm } from 'react-hook-form'\nimport { AccountValuationFormFields } from '../AccountValuationFormFields'\n\ntype Props =\n    | {\n          mode: 'create'\n          defaultValues: CreateVehicleFields\n          onSubmit(data: CreateVehicleFields): void\n      }\n    | {\n          mode: 'update'\n          defaultValues: UpdateVehicleFields\n          onSubmit(data: UpdateVehicleFields): void\n      }\n\nexport default function VehicleForm({ mode, defaultValues, onSubmit }: Props) {\n    const {\n        register,\n        control,\n        handleSubmit,\n        watch,\n        formState: { errors, isSubmitting, isValid },\n    } = useForm<CreateVehicleFields & UpdateVehicleFields>({\n        mode: 'onChange',\n        defaultValues,\n    })\n\n    const startDate = watch('startDate')\n    const currentBalanceEditable = !startDate || !DateUtil.isToday(startDate)\n\n    return (\n        <form onSubmit={handleSubmit(onSubmit)} data-testid=\"vehicle-form\">\n            <section className=\"space-y-4 mb-8\">\n                <h6 className=\"text-white uppercase\">Details</h6>\n                <div className=\"space-y-4\">\n                    <Input\n                        type=\"text\"\n                        label=\"Make\"\n                        placeholder=\"Enter make\"\n                        error={errors.make && 'Make is required'}\n                        {...register('make', { required: true })}\n                    />\n\n                    <Input\n                        type=\"text\"\n                        label=\"Model\"\n                        placeholder=\"Enter model\"\n                        error={errors.model && 'Model is required'}\n                        {...register('model', { required: true })}\n                    />\n\n                    <Input\n                        type=\"text\"\n                        label=\"Year\"\n                        placeholder=\"Enter year\"\n                        error={errors.year && 'A valid year is required'}\n                        {...register('year', {\n                            required: true,\n                            validate: (v) =>\n                                v != null &&\n                                parseInt(v) > 1800 &&\n                                parseInt(v) < new Date().getFullYear() + 2,\n                        })}\n                    />\n                </div>\n            </section>\n\n            {mode === 'create' && (\n                <section className=\"space-y-4\">\n                    <h6 className=\"text-white uppercase\">Valuation</h6>\n                    <div>\n                        <AccountValuationFormFields\n                            control={control}\n                            currentBalanceEditable={currentBalanceEditable}\n                        />\n                    </div>\n                </section>\n            )}\n\n            <Button\n                type=\"submit\"\n                fullWidth\n                disabled={isSubmitting || !isValid}\n                data-testid=\"vehicle-form-submit\"\n            >\n                {mode === 'create' ? 'Add vehicle' : 'Update'}\n            </Button>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/accounts-manager/vehicle/index.ts",
    "content": "export * from './AddVehicle'\nexport * from './EditVehicle'\n"
  },
  {
    "path": "libs/client/features/src/data-editor/account/AccountEditor.tsx",
    "content": "import type { ColumnDef } from '@tanstack/react-table'\nimport type { AccountCategory } from '@prisma/client'\nimport type { TableType } from '@maybe-finance/client/shared'\nimport type { SharedType } from '@maybe-finance/shared'\n\nimport { DataTable, EditableCell, useAccountApi } from '@maybe-finance/client/shared'\nimport { Tooltip } from '@maybe-finance/design-system'\nimport { AccountUtil } from '@maybe-finance/shared'\nimport { DateTime } from 'luxon'\nimport { useMemo, useState } from 'react'\nimport { RiQuestionLine } from 'react-icons/ri'\nimport classNames from 'classnames'\nimport toast from 'react-hot-toast'\n\nexport type AccountEditorProps = { className?: string }\n\nexport type AccountRow = Pick<\n    SharedType.Account,\n    'id' | 'name' | 'mask' | 'category' | 'startDate' | 'classification' | 'type' | 'categoryUser'\n> & {\n    accountConnection?: SharedType.AccountConnection\n}\n\nexport function AccountEditor({ className }: AccountEditorProps) {\n    const { useUpdateAccount, useAccounts } = useAccountApi()\n\n    const updateAccountQuery = useUpdateAccount()\n\n    const accountsQuery = useAccounts()\n\n    const data = useMemo<AccountRow[]>(\n        () => AccountUtil.flattenAccounts(accountsQuery.data),\n        [accountsQuery.data]\n    )\n\n    const [autoResetPage] = useState(false)\n\n    const columns = useMemo<ColumnDef<AccountRow>[]>(\n        () => [\n            {\n                header: 'Institution',\n                accessorFn: (row) => {\n                    return row.accountConnection?.name\n                        ? `${row.accountConnection.name} (${row.mask})`\n                        : 'Manual'\n                },\n            },\n            {\n                header: 'Name',\n                accessorKey: 'name',\n                cell: EditableCell,\n                meta: { type: 'string' } as TableType.ColumnMeta<AccountRow>,\n                size: 200,\n            },\n            {\n                header: 'Category',\n                id: 'categoryUser',\n                accessorFn: (row) => row.category,\n                cell: EditableCell,\n                meta: {\n                    type: 'dropdown',\n                    options: (row) => {\n                        return AccountUtil.CATEGORY_MAP[row.original?.type ?? 'OTHER_ASSET'].map(\n                            (v) => v.value\n                        )\n                    },\n                    formatFn: (option) => AccountUtil.CATEGORIES[option as AccountCategory].plural,\n                } as TableType.ColumnMeta<AccountRow>,\n            },\n            {\n                id: 'startDate',\n                accessorFn: (row) =>\n                    row.startDate\n                        ? DateTime.fromJSDate(row.startDate, { zone: 'utc' }).toFormat(\n                              'MM / dd / yyyy'\n                          )\n                        : 'Enter date',\n                header: () => (\n                    <div className=\"flex items-center\">\n                        <p>Start date</p>\n                        <Tooltip content=\"The date you opened the account.  This will be the first balance shown in historical graphs for this account.\">\n                            <span className=\"ml-1.5\">\n                                <RiQuestionLine className=\"w-4 h-4\" />\n                            </span>\n                        </Tooltip>\n                    </div>\n                ),\n                cell: EditableCell,\n                meta: {\n                    type: 'date',\n                } as TableType.ColumnMeta<AccountRow>,\n            },\n        ],\n        []\n    )\n\n    if (accountsQuery.isLoading) {\n        return (\n            <div className=\"mt-6\">\n                <p className=\"text-gray-50 animate-pulse\">Loading accounts...</p>\n            </div>\n        )\n    }\n\n    if (accountsQuery.isError) {\n        return (\n            <div className=\"mt-6\">\n                <p className=\"text-gray-50\">Error loading account data</p>\n            </div>\n        )\n    }\n\n    if (accountsQuery.data && data.length === 0) {\n        return (\n            <div className=\"mt-6\">\n                <p className=\"text-gray-50\">No accounts found</p>\n            </div>\n        )\n    }\n\n    return (\n        <div className={classNames(className)}>\n            <DataTable\n                columns={columns}\n                data={data}\n                autoResetPage={autoResetPage}\n                mutateFn={(row, key, value) => {\n                    if (!row.original || !row.original.id) {\n                        toast.error('Something went wrong updating account')\n                        return\n                    }\n\n                    updateAccountQuery.mutate({\n                        id: row.original.id,\n                        data: {\n                            provider: undefined,\n                            data: {\n                                [key]: value,\n                            },\n                        },\n                    })\n                }}\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/data-editor/account/index.ts",
    "content": "export * from './AccountEditor'\n"
  },
  {
    "path": "libs/client/features/src/data-editor/index.ts",
    "content": "export * from './account'\nexport * from './transaction'\n"
  },
  {
    "path": "libs/client/features/src/data-editor/transaction/TransactionEditor.tsx",
    "content": "import type { ColumnDef, PaginationState } from '@tanstack/react-table'\nimport type { TableType } from '@maybe-finance/client/shared'\nimport type { SharedType } from '@maybe-finance/shared'\n\nimport { DataTable, EditableCell, useTransactionApi } from '@maybe-finance/client/shared'\nimport { NumberUtil, TransactionUtil } from '@maybe-finance/shared'\nimport classNames from 'classnames'\nimport { DateTime } from 'luxon'\nimport { useMemo, useState } from 'react'\nimport toast from 'react-hot-toast'\n\nexport type TransactionEditorProps = { className?: string }\n\nexport type TransactionRow = Pick<\n    SharedType.Transaction,\n    'id' | 'name' | 'category' | 'date' | 'amount' | 'excluded'\n> & {\n    account: SharedType.Account\n}\n\nconst PAGE_SIZE = 10\n\nexport function TransactionEditor({ className }: TransactionEditorProps) {\n    const [autoResetPage] = useState(true)\n\n    const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({\n        pageIndex: 0,\n        pageSize: PAGE_SIZE,\n    })\n\n    const { useTransactions, useUpdateTransaction } = useTransactionApi()\n\n    const transactionsQuery = useTransactions({ pageSize, pageIndex }, { keepPreviousData: true })\n\n    const updateTransactionQuery = useUpdateTransaction()\n\n    const columns = useMemo<ColumnDef<TransactionRow>[]>(\n        () => [\n            {\n                id: 'date',\n                accessorFn: (row) =>\n                    DateTime.fromJSDate(row.date, { zone: 'utc' }).toFormat('MMM dd, yyyy'),\n                header: 'Date',\n                minSize: 125,\n                size: 125,\n            },\n            {\n                id: 'account',\n                accessorFn: (row) => row.account.name,\n                header: 'Account',\n            },\n            {\n                header: 'Name',\n                accessorKey: 'name',\n                size: 300,\n            },\n            {\n                header: 'Amount',\n                accessorFn: (row) => NumberUtil.format(row.amount, 'currency'),\n            },\n            {\n                header: 'Category',\n                id: 'categoryUser',\n                accessorKey: 'category',\n                cell: EditableCell,\n                meta: {\n                    type: 'dropdown',\n                    options: TransactionUtil.CATEGORIES,\n                } as TableType.ColumnMeta<TransactionRow>,\n            },\n            {\n                header: 'Excluded',\n                accessorKey: 'excluded',\n                cell: EditableCell,\n                size: 40,\n                maxSize: 40,\n                meta: { type: 'boolean' } as TableType.ColumnMeta<TransactionRow>,\n            },\n        ],\n        []\n    )\n\n    if (transactionsQuery.isLoading) {\n        return (\n            <div className=\"mt-6\">\n                <p className=\"text-gray-50 animate-pulse\">Loading transactions...</p>\n            </div>\n        )\n    }\n\n    if (transactionsQuery.isError) {\n        return (\n            <div className=\"mt-6\">\n                <p className=\"text-gray-50\">Error loading transaction data</p>\n            </div>\n        )\n    }\n\n    if (transactionsQuery.data && transactionsQuery.data.transactions.length === 0) {\n        return (\n            <div className=\"mt-6\">\n                <p className=\"text-gray-50\">No transactions found in your account</p>\n            </div>\n        )\n    }\n\n    return (\n        <div className={classNames(className)}>\n            <p className=\"text-gray-50 text-base\">\n                For more advanced transaction editing, please go to the specific account page and\n                edit the transaction inline.\n            </p>\n\n            <DataTable\n                columns={columns}\n                data={\n                    transactionsQuery.data?.transactions.map((txn) => ({\n                        ...txn,\n                        amount: txn.amount.negated(),\n                    })) ?? []\n                }\n                autoResetPage={autoResetPage}\n                mutateFn={(row, key, value) => {\n                    if (!row.original || !row.original.id) {\n                        toast.error('Something went wrong updating transaction')\n                        return\n                    }\n\n                    updateTransactionQuery.mutate({\n                        id: row.original.id,\n                        data: {\n                            [key]: value,\n                        },\n                    })\n                }}\n                paginationOpts={{\n                    pagination: { pageIndex, pageSize },\n                    onChange: setPagination,\n                    pageCount: transactionsQuery.data?.pageCount ?? -1,\n                }}\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/data-editor/transaction/index.ts",
    "content": "export * from './TransactionEditor'\n"
  },
  {
    "path": "libs/client/features/src/holdings-list/CostBasisForm.tsx",
    "content": "import { Button, InputCurrency, RadioGroup, Tooltip } from '@maybe-finance/design-system'\nimport { Controller, useForm } from 'react-hook-form'\nimport { RiQuestionLine } from 'react-icons/ri'\n\ntype FormValues =\n    | { type: 'calculated'; costBasisUser: null }\n    | { type: 'manual'; costBasisUser: number }\n\ntype Props = {\n    defaultValues: FormValues\n    onSubmit(data: FormValues): void\n    onClose(): void\n    isEstimate: boolean\n}\n\nexport function CostBasisForm({ defaultValues, onSubmit, onClose, isEstimate }: Props) {\n    const {\n        watch,\n        control,\n        handleSubmit,\n        formState: { isSubmitting, isValid },\n    } = useForm<FormValues>({ mode: 'onChange', defaultValues })\n\n    const type = watch('type')\n\n    return (\n        <form onSubmit={handleSubmit(onSubmit)}>\n            <Controller\n                control={control}\n                name=\"type\"\n                render={({ field }) => (\n                    <RadioGroup value={field.value} onChange={field.onChange}>\n                        <RadioGroup.Option value=\"calculated\">\n                            <RadioGroup.Label className=\"flex items-center\">\n                                {isEstimate ? 'Use estimated average' : 'Use recommended value'}\n                                {isEstimate && (\n                                    <Tooltip\n                                        content={\n                                            <span className=\"text-base text-gray-50\">\n                                                We do our best to calculate exact cost basis, but in\n                                                some cases where we have missing data from our\n                                                providers, we rely on a crude average calculation.\n                                            </span>\n                                        }\n                                    >\n                                        <span className=\"ml-1.5\">\n                                            <RiQuestionLine className=\"w-5 h-5\" />\n                                        </span>\n                                    </Tooltip>\n                                )}\n                            </RadioGroup.Label>\n                        </RadioGroup.Option>\n                        <RadioGroup.Option value=\"manual\">\n                            <RadioGroup.Label className=\"flex items-center\">\n                                Use manual value\n                            </RadioGroup.Label>\n                        </RadioGroup.Option>\n                    </RadioGroup>\n                )}\n            />\n\n            {type === 'manual' && (\n                <Controller\n                    control={control}\n                    name=\"costBasisUser\"\n                    rules={{ required: 'Value required' }}\n                    render={({ field, fieldState: { error } }) => {\n                        return (\n                            <InputCurrency\n                                {...field}\n                                className=\"mt-2\"\n                                fixedRightOverride={\n                                    <span className=\"whitespace-nowrap\">per share</span>\n                                }\n                                error={error?.message}\n                            />\n                        )\n                    }}\n                />\n            )}\n\n            <div className=\"flex items-center justify-end gap-2 mt-3\">\n                <Button variant=\"secondary\" onClick={onClose}>\n                    Cancel\n                </Button>\n                <Button type=\"submit\" disabled={isSubmitting || !isValid}>\n                    Update\n                </Button>\n            </div>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/holdings-list/HoldingList.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport type { HoldingRowData } from './HoldingsTable'\nimport { useMemo } from 'react'\nimport { useAccountApi, useUserAccountContext, InfiniteScroll } from '@maybe-finance/client/shared'\nimport { LoadingSpinner } from '@maybe-finance/design-system'\nimport { HoldingsTable } from './HoldingsTable'\n\ninterface HoldingListProps {\n    accountId: number\n}\n\nexport function HoldingList({ accountId }: HoldingListProps) {\n    const { isReady } = useUserAccountContext()\n\n    const { useAccountHoldings } = useAccountApi()\n\n    const accountHoldingsQuery = useAccountHoldings(\n        { id: accountId },\n        { enabled: !!accountId && isReady }\n    )\n\n    const data = useMemo<HoldingRowData[]>(() => {\n        if (!accountHoldingsQuery.isSuccess) return []\n\n        const mapHolding = (holding: SharedType.AccountHolding) =>\n            ({\n                holdingId: holding.id,\n                securityId: holding.securityId,\n                symbol: holding.symbol,\n                name: holding.name ?? 'Holding',\n                costBasis: holding.costBasis,\n                costBasisUser: holding.costBasisUser,\n                costBasisProvider: holding.costBasisProvider,\n                price: holding.price,\n                value: holding.value,\n                quantity: holding.quantity,\n                sharesPerContract: holding.sharesPerContract,\n                returnTotal: holding.trend.total,\n                returnToday: holding.trend.today,\n                excluded: holding.excluded,\n                holding,\n            } as HoldingRowData)\n\n        return accountHoldingsQuery.data.pages.flatMap((p) => p.holdings).map((h) => mapHolding(h))\n    }, [accountHoldingsQuery])\n\n    return (\n        <div className=\"w-[calc(100%+2rem)] transform -translate-x-4 overflow-x-auto pb-8 custom-gray-scroll\">\n            <InfiniteScroll\n                getScrollParent={() => document.getElementById('mainScrollArea')}\n                useWindow={false}\n                initialLoad={false}\n                loadMore={() => accountHoldingsQuery.fetchNextPage()}\n                hasMore={accountHoldingsQuery.hasNextPage}\n            >\n                {accountHoldingsQuery.isSuccess &&\n                    (data.length ? (\n                        <HoldingsTable data={data} />\n                    ) : (\n                        <div className=\"text-base text-gray-100\">No holdings found</div>\n                    ))}\n            </InfiniteScroll>\n            {(accountHoldingsQuery.isLoading || accountHoldingsQuery.isFetchingNextPage) && (\n                <div className=\"flex items-center justify-center py-4\">\n                    <LoadingSpinner variant=\"secondary\" />\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/holdings-list/HoldingPopout.tsx",
    "content": "import type { Holding, Security } from '@prisma/client'\nimport { RiCloseFill, RiPencilLine, RiInformationLine, RiQuestionLine } from 'react-icons/ri'\nimport Image from 'next/legacy/image'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport {\n    BrowserUtil,\n    TrendBadge,\n    useHoldingApi,\n    usePopoutContext,\n    useSecurityApi,\n} from '@maybe-finance/client/shared'\nimport { Button, Toggle, FractionalCircle, Tooltip } from '@maybe-finance/design-system'\nimport debounce from 'lodash/debounce'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { AnimatePresence, motion } from 'framer-motion'\nimport { CostBasisForm } from './CostBasisForm'\nimport { SecurityPriceChart } from './SecurityPriceChart'\nimport AnimateHeight from 'react-animate-height'\n\nexport type HoldingPopoutProps = {\n    holdingId: Holding['id']\n    securityId: Security['id']\n}\n\nexport function HoldingPopout({ holdingId, securityId }: HoldingPopoutProps) {\n    const { close } = usePopoutContext()\n\n    const { useHolding, useUpdateHolding, useHoldingInsights } = useHoldingApi()\n    const { data: holding, isLoading } = useHolding(holdingId)\n    const { useSecurity, useSecurityDetails } = useSecurityApi()\n\n    const security = useSecurity(securityId)\n    const securityDetails = useSecurityDetails(securityId)\n\n    const updateHolding = useUpdateHolding()\n    const holdingInsights = useHoldingInsights(holdingId)\n\n    const [isEditingCost, setIsEditingCost] = useState(false)\n    const [excluded, setExcluded] = useState<boolean | undefined>()\n\n    useEffect(() => {\n        setExcluded(holding?.excluded)\n    }, [holding?.excluded])\n\n    const mutate = useCallback(\n        async (excluded: boolean) => {\n            try {\n                await updateHolding.mutateAsync({ id: holdingId, data: { excluded } })\n            } catch (e) {\n                setExcluded(!excluded)\n            }\n        },\n        [updateHolding, holdingId]\n    )\n\n    const debouncedMutate = useMemo(() => debounce(mutate, 1000), [mutate])\n\n    const onExcludedChange = useCallback(\n        (excluded: boolean) => {\n            setExcluded(excluded)\n            debouncedMutate(excluded)\n        },\n        [debouncedMutate]\n    )\n\n    return (\n        <div className=\"flex flex-col h-full overflow-hidden w-full lg:w-96\">\n            <div className=\"py-5 px-6\">\n                <Button variant=\"icon\" title=\"Close\" onClick={close}>\n                    <RiCloseFill className=\"w-6 h-6\" />\n                </Button>\n            </div>\n\n            {isLoading ? (\n                <div className=\"px-8 text-gray-100 animate-pulse\">Loading holding...</div>\n            ) : !holding ? (\n                <div className=\"px-8 text-gray-100\">\n                    Sorry, we couldn't load this holding. Please try again or contact us.\n                </div>\n            ) : (\n                <>\n                    <div className=\"flex justify-between space-x-4 px-6 pb-2\">\n                        <div>\n                            <h4 className=\"text-white\">{holding.name}</h4>\n                            <span className=\"block text-base text-gray-100\">{holding.symbol}</span>\n                        </div>\n                        <div className=\"relative w-12 h-12 shrink-0 bg-gray-400 rounded-xl overflow-hidden\">\n                            <Image\n                                loader={BrowserUtil.enhancerizerLoader}\n                                src={JSON.stringify({\n                                    kind: 'security',\n                                    name: holding!.symbol ?? holding!.name,\n                                })}\n                                layout=\"fill\"\n                                sizes=\"48px, 64px, 96px, 128px\"\n                                onError={({ currentTarget }) => {\n                                    // Fail gracefully and hide image\n                                    currentTarget.onerror = null\n                                    currentTarget.style.display = 'none'\n                                }}\n                            />\n                        </div>\n                    </div>\n                    <div className=\"grow px-6 pb-32 space-y-5 custom-gray-scroll\">\n                        <AnimateHeight height={security.data?.pricing ? 'auto' : 0}>\n                            {security.data?.pricing && (\n                                <SecurityPriceChart pricing={security.data.pricing} />\n                            )}\n                        </AnimateHeight>\n\n                        <dl className=\"grid grid-cols-2 gap-y-1 gap-x-3 text-sm text-gray-100\">\n                            <div className=\"flex items-center justify-between\">\n                                <dt>Open</dt>\n                                <dd className=\"font-medium text-white\">\n                                    {NumberUtil.format(securityDetails.data?.day?.open, 'currency')}\n                                </dd>\n                            </div>\n                            <div className=\"flex items-center justify-between\">\n                                <dt>Prev close</dt>\n                                <dd className=\"font-medium text-white\">\n                                    {NumberUtil.format(\n                                        securityDetails.data?.day?.prevClose,\n                                        'currency'\n                                    )}\n                                </dd>\n                            </div>\n                            <div className=\"flex items-center justify-between\">\n                                <dt>High</dt>\n                                <dd className=\"font-medium text-white\">\n                                    {NumberUtil.format(securityDetails.data?.day?.high, 'currency')}\n                                </dd>\n                            </div>\n                            <div className=\"flex items-center justify-between\">\n                                <dt>52-wk high</dt>\n                                <dd className=\"font-medium text-white\">\n                                    {NumberUtil.format(\n                                        securityDetails.data?.year?.high,\n                                        'currency'\n                                    )}\n                                </dd>\n                            </div>\n                            <div className=\"flex items-center justify-between\">\n                                <dt>Low</dt>\n                                <dd className=\"font-medium text-white\">\n                                    {NumberUtil.format(securityDetails.data?.day?.high, 'currency')}\n                                </dd>\n                            </div>\n                            <div className=\"flex items-center justify-between\">\n                                <dt>52-wk low</dt>\n                                <dd className=\"font-medium text-white\">\n                                    {NumberUtil.format(securityDetails.data?.year?.low, 'currency')}\n                                </dd>\n                            </div>\n                        </dl>\n\n                        <div>\n                            <h6 className=\"uppercase\">Overview</h6>\n\n                            <dl className=\"text-base mt-3 space-y-2 text-gray-100\">\n                                <div className=\"flex items-center justify-between\">\n                                    <dt>Holdings</dt>\n                                    <dd className=\"font-medium text-white\">\n                                        {NumberUtil.format(holding.value, 'currency')}\n                                    </dd>\n                                </div>\n\n                                <div className=\"flex items-center justify-between\">\n                                    <dt>Shares</dt>\n                                    <dd className=\"font-medium text-white\">\n                                        {holding.quantity.toNumber()}\n                                    </dd>\n                                </div>\n\n                                <div className=\"flex items-center justify-between\">\n                                    <dt>Weighting</dt>\n                                    <dd className=\"flex items-center gap-2 font-medium text-white\">\n                                        {holdingInsights.data?.allocation ? (\n                                            <>\n                                                <FractionalCircle\n                                                    percent={\n                                                        holdingInsights.data.allocation.toNumber() *\n                                                        100\n                                                    }\n                                                />\n                                                <span>\n                                                    {NumberUtil.format(\n                                                        holdingInsights.data.allocation,\n                                                        'percent',\n                                                        { signDisplay: 'never' }\n                                                    )}\n                                                </span>\n                                            </>\n                                        ) : (\n                                            <span className=\"text-gray-100\">--</span>\n                                        )}\n                                    </dd>\n                                </div>\n\n                                <div>\n                                    <div className=\"flex items-center justify-between\">\n                                        <dt>Average Cost</dt>\n                                        <dd className=\"flex items-center font-medium text-white\">\n                                            {NumberUtil.format(\n                                                holding.costBasis?.dividedBy(holding.quantity),\n                                                'currency'\n                                            )}\n                                            <span className=\"ml-1 text-base text-gray-100\">\n                                                per share\n                                            </span>\n                                            <RiPencilLine\n                                                className=\"w-5 h-5 text-gray-50 ml-2 mb-0.5 cursor-pointer hover:text-gray-100\"\n                                                onClick={() => setIsEditingCost((prev) => !prev)}\n                                            />\n                                        </dd>\n                                    </div>\n\n                                    <AnimatePresence>\n                                        {isEditingCost && (\n                                            <motion.div\n                                                className=\"bg-gray-600 rounded-lg p-3 my-2\"\n                                                initial={{ opacity: 0 }}\n                                                animate={{ opacity: 1 }}\n                                                exit={{ opacity: 0 }}\n                                            >\n                                                <CostBasisForm\n                                                    isEstimate={!holding.costBasisProvider}\n                                                    defaultValues={\n                                                        holding.costBasisUser\n                                                            ? {\n                                                                  type: 'manual',\n                                                                  costBasisUser:\n                                                                      holding.costBasisUser\n                                                                          .dividedBy(\n                                                                              holding.quantity\n                                                                          )\n                                                                          .toNumber(),\n                                                              }\n                                                            : {\n                                                                  type: 'calculated',\n                                                                  costBasisUser: null,\n                                                              }\n                                                    }\n                                                    onSubmit={async (data) => {\n                                                        await updateHolding.mutate({\n                                                            id: holding.id,\n                                                            data: {\n                                                                costBasisUser:\n                                                                    data.type === 'manual'\n                                                                        ? data.costBasisUser *\n                                                                          holding.quantity.toNumber()\n                                                                        : null,\n                                                            },\n                                                        })\n\n                                                        setIsEditingCost(false)\n                                                    }}\n                                                    onClose={() => setIsEditingCost(false)}\n                                                />\n                                            </motion.div>\n                                        )}\n                                    </AnimatePresence>\n                                </div>\n\n                                <div className=\"flex items-center justify-between\">\n                                    <dt>Daily gain</dt>\n                                    <dd className=\"font-medium text-white\">\n                                        {holding.trend.today ? (\n                                            <TrendBadge\n                                                trend={holding.trend.today}\n                                                badgeSize=\"sm\"\n                                                amountSize=\"md\"\n                                                displayAmount\n                                            />\n                                        ) : (\n                                            '--'\n                                        )}\n                                    </dd>\n                                </div>\n\n                                <div className=\"flex items-center justify-between\">\n                                    <dt>Total gain</dt>\n                                    <dd className=\"font-medium text-white\">\n                                        {holding.trend.total ? (\n                                            <TrendBadge\n                                                trend={holding.trend.total}\n                                                badgeSize=\"sm\"\n                                                amountSize=\"md\"\n                                                displayAmount\n                                            />\n                                        ) : (\n                                            '--'\n                                        )}\n                                    </dd>\n                                </div>\n\n                                <div className=\"flex items-center justify-between\">\n                                    <dt>Dividend yield</dt>\n                                    <dd className=\"font-medium text-white\">\n                                        {NumberUtil.format(\n                                            securityDetails?.data?.year?.dividends?.dividedBy(\n                                                holding.price\n                                            ),\n                                            'percent',\n                                            {\n                                                signDisplay: 'auto',\n                                                maximumFractionDigits: 2,\n                                            }\n                                        )}\n                                    </dd>\n                                </div>\n\n                                <div className=\"flex items-center justify-between\">\n                                    <dt>Total dividend income</dt>\n                                    <dd className=\"font-medium text-white\">\n                                        {holdingInsights?.data?.dividends ? (\n                                            NumberUtil.format(\n                                                holdingInsights?.data?.dividends\n                                                    ?.negated()\n                                                    .toNumber(),\n                                                'currency'\n                                            )\n                                        ) : (\n                                            <span className=\"text-gray-100\">--</span>\n                                        )}\n                                    </dd>\n                                </div>\n                            </dl>\n                        </div>\n\n                        <div>\n                            <h6 className=\"uppercase\">Market</h6>\n\n                            <dl className=\"text-base mt-3 space-y-2 text-gray-100\">\n                                <div className=\"flex items-center justify-between\">\n                                    <dt className=\"flex items-center\">\n                                        P/E ratio\n                                        <Tooltip\n                                            content={\n                                                <div className=\"text-base text-gray-50\">\n                                                    Approximated from most recent quarterly EPS\n                                                    value\n                                                </div>\n                                            }\n                                        >\n                                            <span>\n                                                <RiInformationLine className=\"w-4 h-4 text-gray-50 mx-1.5\" />\n                                            </span>\n                                        </Tooltip>\n                                    </dt>\n                                    <dd className=\"font-medium text-white\">\n                                        {securityDetails.data?.eps?.isPositive()\n                                            ? holding.price\n                                                  .dividedBy(securityDetails.data.eps.times(4))\n                                                  .toFixed(2)\n                                            : '--'}\n                                    </dd>\n                                </div>\n                                <div className=\"flex items-center justify-between\">\n                                    <dt className=\"flex items-center\">\n                                        Earnings per share\n                                        <Tooltip\n                                            content={\n                                                <div className=\"text-base text-gray-50\">\n                                                    Most recent quarterly value\n                                                </div>\n                                            }\n                                        >\n                                            <span>\n                                                <RiQuestionLine className=\"w-4 h-4 text-gray-50 mx-1.5\" />\n                                            </span>\n                                        </Tooltip>\n                                    </dt>\n                                    <dd className=\"font-medium text-white\">\n                                        {securityDetails.data?.eps?.toFixed(2) ?? '--'}\n                                    </dd>\n                                </div>\n                                <div className=\"flex items-center justify-between\">\n                                    <dt>Average volume</dt>\n                                    <dd className=\"font-medium text-white\">\n                                        {NumberUtil.format(\n                                            securityDetails.data?.year?.volume?.dividedBy(52 * 5),\n                                            'short-decimal'\n                                        )}\n                                    </dd>\n                                </div>\n                                <div className=\"flex items-center justify-between\">\n                                    <dt>Market cap</dt>\n                                    <dd className=\"font-medium text-white\">\n                                        {NumberUtil.format(\n                                            securityDetails?.data?.marketCap,\n                                            'short-currency'\n                                        )}\n                                    </dd>\n                                </div>\n                            </dl>\n                        </div>\n\n                        <div className=\"p-3 rounded-lg bg-gray-700\">\n                            <label className=\"flex items-center justify-between text-base\">\n                                Exclude from chart and insights\n                                <Toggle\n                                    checked={excluded}\n                                    onChange={onExcludedChange}\n                                    size=\"small\"\n                                />\n                            </label>\n                        </div>\n                    </div>\n                </>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/holdings-list/HoldingsTable.tsx",
    "content": "import type { ColumnDef } from '@tanstack/react-table'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { useMemo } from 'react'\nimport Image from 'next/legacy/image'\nimport { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { BrowserUtil, TrendBadge, usePopoutContext } from '@maybe-finance/client/shared'\nimport { HoldingPopout } from './HoldingPopout'\nimport { RiEyeOffLine, RiQuestionLine } from 'react-icons/ri'\nimport { Tooltip } from '@maybe-finance/design-system'\n\nexport interface HoldingRowData {\n    name: string\n    price: SharedType.Decimal\n    securityId: number\n    costBasis: SharedType.Decimal | null\n    costBasisUser: SharedType.Decimal | null\n    costBasisProvider: SharedType.Decimal | null\n    value: SharedType.Decimal\n    quantity: SharedType.Decimal\n    sharesPerContract: SharedType.Decimal | null\n    returnTotal: SharedType.Trend | null\n    returnToday: SharedType.Trend | null\n    symbol: string | null\n    holdingId?: number\n    excluded: boolean\n    holding: SharedType.AccountHolding\n}\n\ninterface HoldingsTableProps {\n    data: HoldingRowData[]\n}\n\nexport function HoldingsTable({ data }: HoldingsTableProps) {\n    const { open: openPopout } = usePopoutContext()\n\n    const columns = useMemo(\n        () => [\n            {\n                id: 'name',\n                header: 'Name',\n                accessorFn: (row) => row.symbol ?? row.name,\n                cell: ({ row: { original: holding } }) => (\n                    <div className=\"flex items-center space-x-4\">\n                        <div className=\"relative\">\n                            <div className=\"relative w-12 h-12 shrink-0 bg-gray-400 rounded-xl overflow-hidden\">\n                                <Image\n                                    loader={BrowserUtil.enhancerizerLoader}\n                                    src={JSON.stringify({\n                                        kind: 'security',\n                                        name: holding!.symbol ?? holding!.name,\n                                    })}\n                                    layout=\"fill\"\n                                    sizes=\"48px, 64px, 96px, 128px\"\n                                    onError={({ currentTarget }) => {\n                                        // Fail gracefully and hide image\n                                        currentTarget.onerror = null\n                                        currentTarget.style.display = 'none'\n                                    }}\n                                />\n                            </div>\n                            {holding.excluded && (\n                                <div className=\"absolute flex items-center justify-center -bottom-1 -right-2 w-5 h-5 box-border rounded-full border-2 border-black bg-gray-500\">\n                                    <RiEyeOffLine className=\"w-3.5 h-3.5\" />\n                                </div>\n                            )}\n                        </div>\n                        <div className=\"min-w-0\">\n                            <div className=\"truncate\">{holding!.name}</div>\n                            {holding!.symbol && (\n                                <div className=\"text-gray-100\">{holding!.symbol}</div>\n                            )}\n                        </div>\n                    </div>\n                ),\n            } as ColumnDef<HoldingRowData, HoldingRowData['name']>,\n            {\n                header: 'Price',\n                accessorKey: 'price',\n                cell: ({ getValue }) => (\n                    <div className=\"font-semibold tabular-nums text-right\">\n                        {NumberUtil.format(getValue(), 'currency')}\n                    </div>\n                ),\n            } as ColumnDef<HoldingRowData, HoldingRowData['price']>,\n            {\n                header: 'Cost',\n                accessorKey: 'costBasis',\n                cell: ({ getValue, row }) => {\n                    return (\n                        <div className=\"text-right\">\n                            <div className=\"font-medium tabular-nums\">\n                                {!row.original.costBasisProvider && '~'}\n                                {NumberUtil.format(getValue(), 'currency')}\n                            </div>\n                            <div className=\"text-gray-100 flex items-center justify-end\">\n                                per share\n                                {!row.original.costBasisProvider && (\n                                    <Tooltip\n                                        content={\n                                            <span className=\"text-base text-gray-50\">\n                                                This value is an estimated average, due to missing\n                                                data from one of our providers. You can adjust it by\n                                                clicking on the holding.\n                                            </span>\n                                        }\n                                    >\n                                        <span className=\"ml-1.5 inline-block\">\n                                            <RiQuestionLine className=\"w-5 h-5\" />\n                                        </span>\n                                    </Tooltip>\n                                )}\n                            </div>\n                        </div>\n                    )\n                },\n            } as ColumnDef<HoldingRowData, HoldingRowData['costBasis']>,\n            {\n                header: 'Holdings',\n                accessorKey: 'quantity',\n                cell: ({ getValue, row: { original: holding } }) => (\n                    <div className=\"text-right\">\n                        <div className=\"font-medium tabular-nums\">\n                            {NumberUtil.format(holding!.value, 'currency')}\n                        </div>\n                        <div className=\"text-gray-100\">\n                            {getValue().toNumber()}{' '}\n                            {holding!.sharesPerContract == null ? 'share' : 'contract'}\n                            {!getValue().eq(1) && 's'}\n                        </div>\n                    </div>\n                ),\n            } as ColumnDef<HoldingRowData, HoldingRowData['quantity']>,\n            {\n                header: 'Daily gain',\n                accessorKey: 'returnToday',\n                cell: (info) => <PerformanceMetric trend={info.getValue()} />,\n            } as ColumnDef<HoldingRowData, HoldingRowData['returnToday']>,\n            {\n                header: 'Total gain',\n                accessorKey: 'returnTotal',\n                cell: (info) => <PerformanceMetric trend={info.getValue()} />,\n            } as ColumnDef<HoldingRowData, HoldingRowData['returnTotal']>,\n        ],\n        []\n    )\n\n    const table = useReactTable({\n        data,\n        columns,\n        getCoreRowModel: getCoreRowModel(),\n    })\n\n    return (\n        <table className=\"table-fixed min-w-full gap-x-5 text-base\">\n            <thead>\n                {table.getHeaderGroups().map((headerGroup) => (\n                    <tr key={headerGroup.id}>\n                        {headerGroup.headers.map((header) => (\n                            <th\n                                key={header.id}\n                                colSpan={header.colSpan}\n                                className=\"px-2.5 first:pl-4 last:pr-4 whitespace-nowrap font-medium text-gray-100 text-right first:text-left\"\n                            >\n                                {!header.isPlaceholder &&\n                                    flexRender(header.column.columnDef.header, header.getContext())}\n                            </th>\n                        ))}\n                    </tr>\n                ))}\n            </thead>\n            <tbody>\n                {table.getRowModel().rows.map((row) => (\n                    <tr\n                        key={row.id}\n                        className=\"cursor-pointer hover:bg-gray-800\"\n                        onClick={() =>\n                            openPopout(\n                                <HoldingPopout\n                                    key={row.id}\n                                    holdingId={row.original.holding.id}\n                                    securityId={row.original.holding.securityId}\n                                />\n                            )\n                        }\n                    >\n                        {row.getVisibleCells().map((cell) => (\n                            <td\n                                key={cell.id}\n                                className=\"px-2.5 first:pl-4 last:pr-4 py-4 first:rounded-l-lg last:rounded-r-lg whitespace-nowrap font-normal truncate\"\n                            >\n                                {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                            </td>\n                        ))}\n                    </tr>\n                ))}\n            </tbody>\n        </table>\n    )\n}\n\nfunction PerformanceMetric({ trend }: { trend: SharedType.Trend | null }) {\n    if (!trend) return null\n\n    return (\n        <div className=\"flex flex-col items-end space-y-0.5\">\n            <div className=\"font-medium tabular-nums\">\n                {NumberUtil.format(trend.amount, 'currency', {\n                    minimumFractionDigits: 2,\n                    maximumFractionDigits: 2,\n                })}\n            </div>\n            <TrendBadge trend={trend} badgeSize=\"sm\" />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/holdings-list/SecurityPriceChart.tsx",
    "content": "import { Badge } from '@maybe-finance/design-system'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { LinearGradient } from '@visx/gradient'\nimport { ParentSize } from '@visx/responsive'\nimport { scaleBand, scaleLinear } from '@visx/scale'\nimport { Area, AreaClosed, Circle } from '@visx/shape'\nimport { DateTime } from 'luxon'\nimport { useMemo } from 'react'\n\nexport function SecurityPriceChart({\n    pricing,\n}: {\n    pricing: SharedType.SecurityPricing[]\n}): JSX.Element {\n    const xDomain = useMemo(() => pricing.map(({ date }) => date), [pricing])\n    const yDomain = useMemo(() => {\n        const values = pricing.map(({ priceClose }) => priceClose.toNumber())\n        return [Math.min(...values), Math.max(...values)]\n    }, [pricing])\n\n    const xScale = scaleBand({\n        domain: xDomain,\n    })\n\n    const yScale = scaleLinear<number>({\n        domain: yDomain,\n    })\n\n    return pricing.length > 1 ? (\n        <div>\n            <div className=\"flex justify-end\">\n                <Badge variant=\"cyan\" highlighted={true} size=\"sm\">\n                    {NumberUtil.format(pricing[pricing.length - 1].priceClose, 'currency')}\n                </Badge>\n            </div>\n            <div className=\"w-full h-32\">\n                <ParentSize className=\"relative\" debounceTime={100}>\n                    {({ width, height }) => {\n                        xScale.range([0, width])\n                        yScale.range([height, 0])\n\n                        return (\n                            <svg width={width} height={height} className=\"overflow-visible\">\n                                {/* Cyan gradient under line */}\n                                <LinearGradient\n                                    id=\"area-gradient\"\n                                    className=\"text-cyan\"\n                                    fromOffset=\"41%\"\n                                    from=\"currentColor\"\n                                    fromOpacity={0.1}\n                                    toOffset=\"97%\"\n                                    to=\"currentColor\"\n                                    toOpacity={0}\n                                />\n\n                                {/* Closed area beneath line with gradient */}\n                                <AreaClosed\n                                    yScale={yScale}\n                                    data={pricing}\n                                    x={({ date }) => xScale(date) || 0}\n                                    y={({ priceClose }) => yScale(priceClose.toNumber())}\n                                    className=\"text-cyan\"\n                                    fill=\"url(#area-gradient)\"\n                                />\n\n                                {/* Actual line */}\n                                <Area\n                                    data={pricing}\n                                    x={({ date }) => xScale(date) || 0}\n                                    y={({ priceClose }) => yScale(priceClose.toNumber())}\n                                    className=\"text-cyan\"\n                                    stroke=\"currentColor\"\n                                    strokeWidth={2}\n                                />\n\n                                {/* Circle on last data point */}\n                                <Circle\n                                    cx={xScale(pricing[pricing.length - 1].date)}\n                                    cy={yScale(pricing[pricing.length - 1].priceClose.toNumber())}\n                                    r={4}\n                                    className=\"text-cyan\"\n                                    fill=\"currentColor\"\n                                />\n                            </svg>\n                        )\n                    }}\n                </ParentSize>\n            </div>\n            <div className=\"flex justify-between mt-2 text-sm text-gray-100\">\n                {[0, pricing.length - 1].map((idx) => (\n                    <span key={idx}>\n                        {DateTime.fromJSDate(pricing[idx].date).toFormat('MMM yy')}\n                    </span>\n                ))}\n            </div>\n        </div>\n    ) : (\n        <div className=\"flex items-center justify-center w-full h-32 text-gray-100\">\n            No historical data available\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/holdings-list/index.ts",
    "content": "export * from './HoldingList'\n"
  },
  {
    "path": "libs/client/features/src/index.ts",
    "content": "export * from './accounts-list'\nexport * from './valuations-list'\nexport * from './holdings-list'\nexport * from './insights'\nexport * from './user-billing'\nexport * from './user-details'\nexport * from './user-security'\nexport * from './transactions-list'\nexport * from './investment-transactions-list'\nexport * from './layout'\nexport * from './accounts-manager'\nexport * from './net-worth-insights'\nexport * from './data-editor'\nexport * from './loan-details'\nexport * from './account'\nexport * from './plans'\nexport * from './onboarding'\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/index.ts",
    "content": "export * from './investments'\nexport * from './net-worth'\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/investments/AverageReturn.tsx",
    "content": "import { IndexTabs } from '@maybe-finance/design-system'\nimport { useRef } from 'react'\nimport { RiArticleLine } from 'react-icons/ri'\n\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n    ExplainerPerformanceBlock,\n} from '@maybe-finance/client/shared'\n\nexport function AverageReturn(): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const learnMore = useRef<HTMLDivElement>(null)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Average return</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Learn more',\n                            elementRef: learnMore,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    This is the portfolio&rsquo;s return on investment. Basically, it&rsquo;s the\n                    money made or lost on an investment over some period of time. It&rsquo;s also\n                    the figure most investors refer to, to understand performance when compared to\n                    benchmarks like the S&amp;P 500.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        It&rsquo;s your &ldquo;how much have I made?&rdquo; number\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. We use the{' '}\n                    <a\n                        rel=\"noreferrer\"\n                        target=\"_blank\"\n                        href=\"https://www.investopedia.com/terms/m/modifieddietzmethod.asp\"\n                        className=\"text-cyan underline\"\n                    >\n                        Modified Dietz Return\n                    </a>{' '}\n                    method to calculate this from your holding and transaction data.\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Learn more\" ref={learnMore}>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/equities-as-an-asset-class\"\n                    >\n                        Article from the Maybe blog on making equity investing part of your\n                        portfolio\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/investments/Contributions.tsx",
    "content": "import { IndexTabs } from '@maybe-finance/design-system'\nimport { useRef } from 'react'\nimport { RiArticleLine } from 'react-icons/ri'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n} from '@maybe-finance/client/shared'\n\nexport function Contributions(): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const learnMore = useRef<HTMLDivElement>(null)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Contributions</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Learn more',\n                            elementRef: learnMore,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    This is how much you&rsquo;ve invested into your portfolio, along with a monthly\n                    average.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        It&rsquo;s your &ldquo;how much have I invested?&rdquo; number\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">Total Deposits - Total Withdrawals</span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Learn more\" ref={learnMore}>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/equities-as-an-asset-class\"\n                    >\n                        Article from the Maybe blog on making equity investing part of your\n                        portfolio\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/investments/PotentialGainLoss.tsx",
    "content": "import { IndexTabs } from '@maybe-finance/design-system'\nimport { useRef } from 'react'\nimport { RiArticleLine } from 'react-icons/ri'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n} from '@maybe-finance/client/shared'\n\nexport function PotentialGainLoss(): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const learnMore = useRef<HTMLDivElement>(null)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Potential gain or loss</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Learn more',\n                            elementRef: learnMore,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    The potential gain or loss is an indicator of what your &ldquo;unrealized&rdquo;\n                    (this means holdings you haven&rsquo;t sold yet) gains or loss looks like.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        This is your &ldquo;paper&rdquo; gains or losses\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">Total value - total cost basis</span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Learn more\" ref={learnMore}>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/equities-as-an-asset-class\"\n                    >\n                        Article from the Maybe blog on making equity investing part of your\n                        portfolio\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/investments/SectorAllocation.tsx",
    "content": "import { IndexTabs } from '@maybe-finance/design-system'\nimport { useRef } from 'react'\nimport { RiArticleLine, RiMicLine } from 'react-icons/ri'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n} from '@maybe-finance/client/shared'\n\nexport function SectorAllocation(): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const learnMore = useRef<HTMLDivElement>(null)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Sector allocation</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Learn more',\n                            elementRef: learnMore,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    The sector allocation shows what % of your portfolio is in stocks in comparison\n                    to other asset classes. There are a few different methods of allocation and\n                    there&rsquo;s no right or wrong answer as it&rsquo;s always dependent on the\n                    investor&rsquo;s attributes and their risk tolerance.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        % of stocks vs. % of other assets\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">\n                            Total stock holdings / portfolio value, Total non-stock holdings /\n                            portfolio value\n                        </span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Learn more\" ref={learnMore}>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/equities-as-an-asset-class\"\n                    >\n                        Article from the Maybe blog on making equity investing part of your\n                        portfolio\n                    </ExplainerExternalLink>\n                    <ExplainerExternalLink\n                        icon={RiMicLine}\n                        href=\"https://maybe.co/podcast/11-how-can-i-match-my-investment-retirement-portfolio-with-my-risk-tolerance\"\n                    >\n                        Podcast episode on matching your portfolio with your risk tolerance\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/investments/TotalFees.tsx",
    "content": "import { IndexTabs } from '@maybe-finance/design-system'\nimport { useRef } from 'react'\nimport { RiArticleLine } from 'react-icons/ri'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n} from '@maybe-finance/client/shared'\n\nexport function TotalFees(): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const learnMore = useRef<HTMLDivElement>(null)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Total fees</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Learn more',\n                            elementRef: learnMore,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    These are the sum total of fees that you pay your broker for transacting on\n                    their platform. Some examples of fees could be deposit, withdrawal, processing,\n                    or advisory fees and even commissions on orders.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        What your broker charges you for using their platform\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">Total fees paid to brokerage</span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Learn more\" ref={learnMore}>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/equities-as-an-asset-class\"\n                    >\n                        Article from the Maybe blog on making equity investing part of your\n                        portfolio\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/investments/index.ts",
    "content": "export * from './SectorAllocation'\nexport * from './AverageReturn'\nexport * from './Contributions'\nexport * from './PotentialGainLoss'\nexport * from './TotalFees'\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/net-worth/BadDebt.tsx",
    "content": "import { IndexTabs } from '@maybe-finance/design-system'\nimport type { ReactNode } from 'react'\nimport { useRef } from 'react'\nimport { RiArticleLine, RiMicLine } from 'react-icons/ri'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n} from '@maybe-finance/client/shared'\n\nconst Em = ({ children }: { children: ReactNode }) => (\n    <em className=\"not-italic text-gray-25\">{children}</em>\n)\n\nexport function BadDebt(): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const resources = useRef<HTMLDivElement>(null)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Bad debt</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Resources',\n                            elementRef: resources,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    Bad debt is debt that provides no future value to you such as a personal loan or\n                    credit card debt. Ideally this is <Em>less than 35%</Em>.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        Debt that's not building wealth\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">\n                            All liabilities - [Liability type = investment or property]\n                        </span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Resources\" ref={resources}>\n                    <ExplainerExternalLink\n                        icon={RiMicLine}\n                        href=\"https://maybe.co/podcast/6-should-you-invest-more-or-pay-off-debt\"\n                    >\n                        Podcast episode on investing vs. paying debt\n                    </ExplainerExternalLink>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/ask-the-advisor-invest-more-or-pay-off-debt\"\n                    >\n                        Article from the Maybe blog on investing vs. paying debt\n                    </ExplainerExternalLink>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/asset-allocation-and-how-to-use-it-to-reach-your-financial-goals\"\n                    >\n                        Article from the Maybe blog on asset allocation\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/net-worth/GoodDebt.tsx",
    "content": "import { IndexTabs } from '@maybe-finance/design-system'\nimport type { ReactNode } from 'react'\nimport { useRef } from 'react'\nimport { RiArticleLine, RiMicLine } from 'react-icons/ri'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n} from '@maybe-finance/client/shared'\n\nconst Em = ({ children }: { children: ReactNode }) => (\n    <em className=\"not-italic text-gray-25\">{children}</em>\n)\n\nexport function GoodDebt(): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const resources = useRef<HTMLDivElement>(null)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Good debt</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Resources',\n                            elementRef: resources,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    Good debt is debt that grows future wealth such as a student loan, or builds\n                    equity in a productive asset such as a home loan. Ideally this is{' '}\n                    <Em>more than 65%</Em>.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        Debt that's building wealth\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">\n                            Liability type = investment or property\n                        </span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Resources\" ref={resources}>\n                    <ExplainerExternalLink\n                        icon={RiMicLine}\n                        href=\"https://maybe.co/podcast/6-should-you-invest-more-or-pay-off-debt\"\n                    >\n                        Podcast episode on investing vs. paying debt\n                    </ExplainerExternalLink>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/ask-the-advisor-invest-more-or-pay-off-debt\"\n                    >\n                        Article from the Maybe blog on investing vs. paying debt\n                    </ExplainerExternalLink>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/asset-allocation-and-how-to-use-it-to-reach-your-financial-goals\"\n                    >\n                        Article from the Maybe blog on asset allocation\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/net-worth/IlliquidAssets.tsx",
    "content": "import { IndexTabs } from '@maybe-finance/design-system'\nimport type { ReactNode } from 'react'\nimport { useRef } from 'react'\nimport { RiArticleLine } from 'react-icons/ri'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n} from '@maybe-finance/client/shared'\n\nconst Em = ({ children }: { children: ReactNode }) => (\n    <em className=\"not-italic text-gray-25\">{children}</em>\n)\n\nexport function IlliquidAssets(): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const resources = useRef<HTMLDivElement>(null)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Hard to cash in assets</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Resources',\n                            elementRef: resources,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    Also called <Em>illiquid assets</Em>, these are assets you own that will take a\n                    significant amount of time to convert into spendable cash.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        Asset &rarr; cash, done slow\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">Asset type = property</span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Resources\" ref={resources}>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/asset-allocation-and-how-to-use-it-to-reach-your-financial-goals\"\n                    >\n                        Article from the Maybe blog on asset allocation\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/net-worth/IncomePayingDebt.tsx",
    "content": "import { IndexTabs, Listbox } from '@maybe-finance/design-system'\nimport Link from 'next/link'\nimport type { ReactNode } from 'react'\nimport { useState } from 'react'\nimport { useRef } from 'react'\nimport { RiArticleLine, RiMicLine } from 'react-icons/ri'\nimport type { InsightState } from '../../'\nimport { InsightStateNames, InsightStateColors } from '../../'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n    ExplainerPerformanceBlock,\n} from '@maybe-finance/client/shared'\n\nconst Em = ({ children }: { children: ReactNode }) => (\n    <em className=\"not-italic text-gray-25\">{children}</em>\n)\n\nexport function IncomePayingDebt({ defaultState }: { defaultState: InsightState }): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const whyYouShouldCare = useRef<HTMLDivElement>(null)\n    const incorrectData = useRef<HTMLDivElement>(null)\n    const learnMore = useRef<HTMLDivElement>(null)\n\n    const [performance, setPerformance] = useState<InsightState>(defaultState)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Income paying debt</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Why you should care',\n                            elementRef: whyYouShouldCare,\n                        },\n                        {\n                            name: 'Incorrect data?',\n                            elementRef: incorrectData,\n                        },\n                        {\n                            name: 'Learn more',\n                            elementRef: learnMore,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    This is how much of your income is going towards paying debt. This is also\n                    called the debt-to-income ratio. Ideally this is <Em>less than 36%</Em>.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        The % of money you make that pays debt\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">\n                            Monthly debt payments &divide; Monthly income\n                        </span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Why you should care\" ref={whyYouShouldCare}>\n                    The income paying debt is one of the ways lenders measure your ability to manage\n                    monthly payments to repay money that you intend to borrow.\n                    <ExplainerPerformanceBlock variant={InsightStateColors[performance]}>\n                        <div className=\"flex items-center\">\n                            If your income paying debt is{' '}\n                            <Listbox\n                                className=\"inline-block ml-2\"\n                                size=\"small\"\n                                value={performance}\n                                onChange={setPerformance}\n                            >\n                                <Listbox.Button\n                                    buttonClassName=\"!p-1.5 !h-auto !font-medium\"\n                                    variant={InsightStateColors[performance]}\n                                >\n                                    {InsightStateNames[performance]}\n                                </Listbox.Button>\n                                <Listbox.Options>\n                                    {['healthy', 'review', 'at-risk'].map((option) => (\n                                        <Listbox.Option key={option} value={option}>\n                                            {InsightStateNames[option as InsightState]}\n                                        </Listbox.Option>\n                                    ))}\n                                </Listbox.Options>\n                            </Listbox>\n                        </div>\n                        <ul className=\"mt-2 list-disc list-outside ml-[1.5em]\">\n                            {performance === 'at-risk' && (\n                                <>\n                                    <li>\n                                        You may be spending too much of your income paying debts\n                                    </li>\n                                    <li>\n                                        Postpone any large purchases that may use credit, to make\n                                        sure you avoid taking on more debt\n                                    </li>\n                                    <li>\n                                        If possible, increase the amount you pay monthly towards\n                                        your debt to reduce your debt-to-income\n                                    </li>\n                                </>\n                            )}\n                            {performance === 'review' && (\n                                <>\n                                    <li>\n                                        Postpone any large purchases that may use credit, to make\n                                        sure you avoid taking on more debt\n                                    </li>\n                                    <li>\n                                        If possible, increase the amount you pay monthly towards\n                                        your debt to reduce your debt-to-income\n                                    </li>\n                                    <li>\n                                        You could also see if you could reconfigure some parts of\n                                        your loans, like extending duration or seeing if\n                                        you&rsquo;re eligible for a lower interest rate\n                                    </li>\n                                </>\n                            )}\n                            {performance === 'healthy' && (\n                                <>\n                                    <li>Your income is paying for an appropriate amount of debt</li>\n                                    <li>Lenders are likely to be willing to offer credit to you</li>\n                                </>\n                            )}\n                        </ul>\n                    </ExplainerPerformanceBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Incorrect data?\" ref={incorrectData}>\n                    If you think the value we&rsquo;re showing you is incorrect, here&rsquo;s what\n                    might be happening.\n                    <ul className=\"list-disc list-outside ml-[1.5em]\">\n                        <li>\n                            Our data provider misclassified a transaction/s, which you can{' '}\n                            <Link href=\"/data-editor\" className=\"text-cyan underline\">\n                                fix here\n                            </Link>\n                        </li>\n                        <li>\n                            We may not be getting enough data from the data provider around an\n                            account(s)\n                        </li>\n                        <li>\n                            You haven&rsquo;t connected all of your accounts, which is returning an\n                            incorrect value, make sure to add all your accounts\n                        </li>\n                    </ul>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Learn more\" ref={learnMore}>\n                    <ExplainerExternalLink\n                        icon={RiMicLine}\n                        href=\"https://maybe.co/podcast/6-should-you-invest-more-or-pay-off-debt\"\n                    >\n                        Podcast episode on investing vs. paying debt\n                    </ExplainerExternalLink>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/ask-the-advisor-invest-more-or-pay-off-debt\"\n                    >\n                        Article from the Maybe blog on investing vs. paying debt\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/net-worth/LiquidAssets.tsx",
    "content": "import { IndexTabs } from '@maybe-finance/design-system'\nimport type { ReactNode } from 'react'\nimport { useRef } from 'react'\nimport { RiArticleLine } from 'react-icons/ri'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n} from '@maybe-finance/client/shared'\n\nconst Em = ({ children }: { children: ReactNode }) => (\n    <em className=\"not-italic text-gray-25\">{children}</em>\n)\n\nexport function LiquidAssets(): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const resources = useRef<HTMLDivElement>(null)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Easy to cash in assets</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Resources',\n                            elementRef: resources,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    Also called <Em>liquid assets</Em>, these are assets you own that can easily be\n                    converted into spendable cash within a short period of time.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        Asset &rarr; cash, done fast\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">Asset type = cash</span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Resources\" ref={resources}>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/asset-allocation-and-how-to-use-it-to-reach-your-financial-goals\"\n                    >\n                        Article from the Maybe blog on asset allocation\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/net-worth/NetWorthTrend.tsx",
    "content": "import { IndexTabs } from '@maybe-finance/design-system'\nimport classNames from 'classnames'\nimport Link from 'next/link'\nimport type { ReactNode } from 'react'\nimport { useRef } from 'react'\nimport { RiArrowRightDownLine, RiArrowRightUpLine, RiArticleLine } from 'react-icons/ri'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n    ExplainerPerformanceBlock,\n} from '@maybe-finance/client/shared'\n\nconst Em = ({ children }: { children: ReactNode }) => (\n    <em className=\"not-italic text-gray-25\">{children}</em>\n)\n\nexport function NetWorthTrend(): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const whyYouShouldCare = useRef<HTMLDivElement>(null)\n    const incorrectData = useRef<HTMLDivElement>(null)\n    const learnMore = useRef<HTMLDivElement>(null)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Net worth trend</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Why you should care',\n                            elementRef: whyYouShouldCare,\n                        },\n                        {\n                            name: 'Incorrect data?',\n                            elementRef: incorrectData,\n                        },\n                        {\n                            name: 'Learn more',\n                            elementRef: learnMore,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    This is an indicator of how your net worth is changing over time. Ideally the{' '}\n                    trend is <Em>positive over a long period of time</Em> as you pay down debt and\n                    acquire assets.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        It&rsquo;s your &ldquo;how am I doing money-wise?&rdquo;\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">\n                            [Latest net worth - Earliest net worth] &divide; Earliest net worth\n                        </span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Why you should care\" ref={whyYouShouldCare}>\n                    The net worth trend value helps to <Em>review where you are financially</Em>. It\n                    lets you know <Em>what</Em> progress you&rsquo;re making and what moves you\n                    should make.\n                    <ExplainerPerformanceBlock variant=\"teal\">\n                        {(color) => (\n                            <>\n                                <div className=\"flex items-center\">\n                                    <RiArrowRightUpLine\n                                        className={classNames('w-6 h-6 mr-2', color)}\n                                    />\n                                    <span>\n                                        If you're trending <span className={color}>positively</span>\n                                    </span>\n                                </div>\n                                <ul className=\"mt-2 list-disc list-outside ml-[1.5em]\">\n                                    <li>\n                                        You&rsquo;re getting closer to your short and long-term\n                                        goals\n                                    </li>\n                                    <li>\n                                        Your actions are paying off, whether that&rsquo;s increasing\n                                        you&rsquo;re income, having a good savings rate, or\n                                        investing wisely\n                                    </li>\n                                </ul>\n                            </>\n                        )}\n                    </ExplainerPerformanceBlock>\n                    <ExplainerPerformanceBlock variant=\"red\">\n                        {(color) => (\n                            <>\n                                <div className=\"flex items-center\">\n                                    <RiArrowRightDownLine\n                                        className={classNames('w-6 h-6 mr-2', color)}\n                                    />\n                                    <span>\n                                        If you're trending <span className={color}>negatively</span>\n                                    </span>\n                                </div>\n                                <ul className=\"mt-2 list-disc list-outside ml-[1.5em]\">\n                                    <li>\n                                        Don&rsquo;t panic, this just means there&rsquo;s more room\n                                        for improvement\n                                    </li>\n                                    <li>\n                                        Understand your expenses and cut any unnecessary spending\n                                    </li>\n                                    <li>Build a plan for paying down any bad debt</li>\n                                    <li>\n                                        Take a more aggressive stance on saving your money and\n                                        investing\n                                    </li>\n                                </ul>\n                            </>\n                        )}\n                    </ExplainerPerformanceBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Incorrect data?\" ref={incorrectData}>\n                    If you think the value we&rsquo;re showing you is incorrect, here&rsquo;s what\n                    might be happening.\n                    <ul className=\"list-disc list-outside ml-[1.5em]\">\n                        <li>\n                            Our data provider misclassified a transaction/s, which you can{' '}\n                            <Link href=\"/data-editor\" className=\"text-cyan underline\">\n                                fix here\n                            </Link>\n                        </li>\n                        <li>\n                            We may not be getting enough data from the data provider around an\n                            account(s)\n                        </li>\n                        <li>\n                            You haven&rsquo;t connected all of your accounts, which is returning an\n                            incorrect value, make sure to add all your accounts\n                        </li>\n                    </ul>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Learn more\" ref={learnMore}>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/the-complete-guide-to-managing-and-optimizing-your-expenses\"\n                    >\n                        Article from the Maybe blog on expenses\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/net-worth/SafetyNet.tsx",
    "content": "import { IndexTabs, Listbox } from '@maybe-finance/design-system'\nimport Link from 'next/link'\nimport type { ReactNode } from 'react'\nimport { useState } from 'react'\nimport { useRef } from 'react'\nimport { RiArticleLine, RiMicLine } from 'react-icons/ri'\nimport type { InsightState } from '../../'\nimport { InsightStateNames, InsightStateColors } from '../../'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n    ExplainerPerformanceBlock,\n} from '@maybe-finance/client/shared'\n\nconst Em = ({ children }: { children: ReactNode }) => (\n    <em className=\"not-italic text-gray-25\">{children}</em>\n)\n\nexport function SafetyNet({ defaultState }: { defaultState: InsightState }): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const whyYouShouldCare = useRef<HTMLDivElement>(null)\n    const incorrectData = useRef<HTMLDivElement>(null)\n    const learnMore = useRef<HTMLDivElement>(null)\n\n    const [performance, setPerformance] = useState<InsightState>(defaultState)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Safety net</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Why you should care',\n                            elementRef: whyYouShouldCare,\n                        },\n                        {\n                            name: 'Incorrect data?',\n                            elementRef: incorrectData,\n                        },\n                        {\n                            name: 'Learn more',\n                            elementRef: learnMore,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    Your safety net is the number of months <Em>you could pay expenses</Em> if you\n                    were to only rely on your emergency funds.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        It&rsquo;s like your personal runway in case of emergency\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">\n                            Cash or cash equivalent assets &divide; Monthly Expenses\n                        </span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Why you should care\" ref={whyYouShouldCare}>\n                    The safety net is a good measure of{' '}\n                    <Em>\n                        how much of a buffer you&rsquo;ve built, if something unexpected were to\n                        happen\n                    </Em>\n                    , home repair, medical bills or anything of that nature.\n                    <ExplainerPerformanceBlock variant={InsightStateColors[performance]}>\n                        <div className=\"flex items-center\">\n                            If your safety net is{' '}\n                            <Listbox\n                                className=\"inline-block ml-2\"\n                                size=\"small\"\n                                value={performance}\n                                onChange={setPerformance}\n                            >\n                                <Listbox.Button\n                                    buttonClassName=\"!p-1.5 !h-auto !font-medium\"\n                                    variant={InsightStateColors[performance]}\n                                >\n                                    {InsightStateNames[performance]}\n                                </Listbox.Button>\n                                <Listbox.Options>\n                                    {['excessive', 'healthy', 'review', 'at-risk'].map((option) => (\n                                        <Listbox.Option key={option} value={option}>\n                                            {InsightStateNames[option as InsightState]}\n                                        </Listbox.Option>\n                                    ))}\n                                </Listbox.Options>\n                            </Listbox>\n                        </div>\n                        <ul className=\"mt-2 list-disc list-outside ml-[1.5em]\">\n                            {performance === 'at-risk' && (\n                                <>\n                                    <li>\n                                        You may not have enough cash to handle unexpected spending\n                                        or emergencies\n                                    </li>\n                                    <li>\n                                        You may want to review your savings and rate of spending\n                                    </li>\n                                </>\n                            )}\n                            {performance === 'review' && (\n                                <>\n                                    <li>You may have enough cash for some financial emergencies</li>\n                                    <li>\n                                        You should consider working to decrease spending or increase\n                                        cash reserves\n                                    </li>\n                                </>\n                            )}\n                            {performance === 'healthy' && (\n                                <>\n                                    <li>\n                                        You have enough cash to handle emergencies such as a\n                                        temporary loss of income\n                                    </li>\n                                    <li>You can comfortably invest any excess income</li>\n                                </>\n                            )}\n                            {performance === 'excessive' && (\n                                <>\n                                    <li>\n                                        You may be running the risk of losing money to inflation if\n                                        you hold onto too much cash\n                                    </li>\n                                    <li>\n                                        If you&rsquo;re planning on deploying cash by timing the\n                                        market, you should know this almost never works out in the\n                                        long-term\n                                    </li>\n                                </>\n                            )}\n                        </ul>\n                    </ExplainerPerformanceBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Incorrect data?\" ref={incorrectData}>\n                    If you think the value we&rsquo;re showing you is incorrect, here&rsquo;s what\n                    might be happening.\n                    <ul className=\"list-disc list-outside ml-[1.5em]\">\n                        <li>\n                            Our data provider misclassified a transaction/s, which you can{' '}\n                            <Link href=\"/data-editor\" className=\"text-cyan underline\">\n                                fix here\n                            </Link>\n                        </li>\n                        <li>\n                            We may not be getting enough data from the data provider around an\n                            account(s)\n                        </li>\n                        <li>\n                            You haven&rsquo;t connected all of your accounts, which is returning an\n                            incorrect value, make sure to add all your accounts\n                        </li>\n                    </ul>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Learn more\" ref={learnMore}>\n                    <ExplainerExternalLink\n                        icon={RiMicLine}\n                        href=\"https://maybe.co/podcast/4-how-much-cash-should-you-have-on-hand\"\n                    >\n                        Podcast episode on safety net\n                    </ExplainerExternalLink>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/ask-the-advisor-invest-more-or-pay-off-debt-cash-on-hand\"\n                    >\n                        Article from the Maybe blog on safety net\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/net-worth/TotalDebtRatio.tsx",
    "content": "import { IndexTabs } from '@maybe-finance/design-system'\nimport type { ReactNode } from 'react'\nimport { useRef } from 'react'\nimport { RiArticleLine, RiMicLine } from 'react-icons/ri'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n} from '@maybe-finance/client/shared'\n\nconst Em = ({ children }: { children: ReactNode }) => (\n    <em className=\"not-italic text-gray-25\">{children}</em>\n)\n\nexport function TotalDebtRatio(): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const resources = useRef<HTMLDivElement>(null)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Total debt ratio</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Resources',\n                            elementRef: resources,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    The total debt ratio is the measure of your borrowing ability in relation to\n                    your assets. Ideally this is <Em>less than 50%</Em>. <br />\n                    <br />\n                    This ratio also helps you understand how much of your assets are leveraged, as\n                    well as your debt levels.\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        It's the &ldquo;what % of my assets have I borrowed to get&rdquo; number\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">Liabilities &divide; Assets</span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Resources\" ref={resources}>\n                    <ExplainerExternalLink\n                        icon={RiMicLine}\n                        href=\"https://maybe.co/podcast/6-should-you-invest-more-or-pay-off-debt\"\n                    >\n                        Podcast episode on investing vs. paying debt\n                    </ExplainerExternalLink>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/ask-the-advisor-invest-more-or-pay-off-debt\"\n                    >\n                        Article from the Maybe blog on investing vs. paying debt\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/net-worth/YieldingAssets.tsx",
    "content": "import { IndexTabs } from '@maybe-finance/design-system'\nimport type { ReactNode } from 'react'\nimport { useRef } from 'react'\nimport { RiArticleLine } from 'react-icons/ri'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n} from '@maybe-finance/client/shared'\n\nconst Em = ({ children }: { children: ReactNode }) => (\n    <em className=\"not-italic text-gray-25\">{children}</em>\n)\n\nexport function YieldingAssets(): JSX.Element {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const definition = useRef<HTMLDivElement>(null)\n    const howAreWeGettingThisValue = useRef<HTMLDivElement>(null)\n    const resources = useRef<HTMLDivElement>(null)\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">Assets that work for you</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Definition', elementRef: definition },\n                        {\n                            name: 'How are we getting this value?',\n                            elementRef: howAreWeGettingThisValue,\n                        },\n                        {\n                            name: 'Resources',\n                            elementRef: resources,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={definition}>\n                    These are assets you own that should provide some sort of appreciation. Ideally\n                    this is <Em>more than 50%.</Em>\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        These assets are your &ldquo;wealth builders&rdquo;\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"How are we getting this value?\"\n                    ref={howAreWeGettingThisValue}\n                >\n                    Is what you&rsquo;re probably asking when seeing that number. Well, below is the\n                    formula we use:\n                    <ExplainerInfoBlock title=\"Formula\">\n                        <span className=\"font-mono italic\">\n                            Asset type = investment or property\n                        </span>\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Resources\" ref={resources}>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://maybe.co/articles/asset-allocation-and-how-to-use-it-to-reach-your-financial-goals\"\n                    >\n                        Article from the Maybe blog on asset allocation\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/insights/explainers/net-worth/index.ts",
    "content": "export * from './IncomePayingDebt'\nexport * from './NetWorthTrend'\nexport * from './SafetyNet'\n\nexport * from './YieldingAssets'\nexport * from './LiquidAssets'\nexport * from './IlliquidAssets'\n\nexport * from './TotalDebtRatio'\nexport * from './GoodDebt'\nexport * from './BadDebt'\n"
  },
  {
    "path": "libs/client/features/src/insights/index.ts",
    "content": "export * from './insight-states'\nexport * as Explainers from './explainers'\n"
  },
  {
    "path": "libs/client/features/src/insights/insight-states.ts",
    "content": "import type { Prisma } from '@prisma/client'\n\nexport type InsightState = 'healthy' | 'review' | 'at-risk' | 'excessive'\n\nexport const InsightStateNames: Record<InsightState, string> = {\n    healthy: 'Healthy',\n    review: 'Review',\n    'at-risk': 'At risk',\n    excessive: 'Excessive',\n}\n\nexport const InsightStateColors: Record<InsightState, 'teal' | 'yellow' | 'red'> = {\n    healthy: 'teal',\n    review: 'yellow',\n    'at-risk': 'red',\n    excessive: 'yellow',\n}\n\nexport const InsightStateColorClasses: Record<InsightState, string> = {\n    healthy: 'text-teal',\n    review: 'text-yellow',\n    'at-risk': 'text-red',\n    excessive: 'text-yellow',\n}\n\nexport function safetyNetState(months: Prisma.Decimal): InsightState {\n    if (months.gt(12)) return 'excessive'\n    if (months.gte(6)) return 'healthy'\n    if (months.gte(3)) return 'review'\n    return 'at-risk'\n}\n\nexport function incomePayingDebtState(debtIncomeRatio: Prisma.Decimal): InsightState {\n    if (debtIncomeRatio.lt(0.25)) return 'healthy'\n    if (debtIncomeRatio.lt(0.36)) return 'review'\n    return 'at-risk'\n}\n"
  },
  {
    "path": "libs/client/features/src/investment-transactions-list/InvestmentTransactionList.tsx",
    "content": "import { useMemo } from 'react'\nimport { LoadingSpinner } from '@maybe-finance/design-system'\nimport { DateTime } from 'luxon'\nimport { useAccountApi, useUserAccountContext, InfiniteScroll } from '@maybe-finance/client/shared'\nimport groupBy from 'lodash/groupBy'\nimport { InvestmentTransactionListItem } from './InvestmentTransactionListItem'\nimport type { SharedType } from '@maybe-finance/shared'\n\nexport function InvestmentTransactionList({\n    accountId,\n    filter,\n}: {\n    accountId: number\n    filter?: { category?: SharedType.InvestmentTransactionCategory }\n}) {\n    const { isReady } = useUserAccountContext()\n\n    const { useAccountInvestmentTransactions } = useAccountApi()\n\n    const accountTransactionsQuery = useAccountInvestmentTransactions(\n        { id: accountId, ...filter },\n        { enabled: !!accountId && isReady, keepPreviousData: true }\n    )\n\n    const groupedTransactions = useMemo(() => {\n        if (!accountTransactionsQuery.data?.pages) return {}\n\n        // Flatten, normalize, and group transactions by date\n        const transactions = accountTransactionsQuery.data.pages\n            .flatMap((page) => page.investmentTransactions)\n            .map((txn) => ({\n                ...txn,\n                dateFormatted: DateTime.fromJSDate(txn.date, { zone: 'utc' }).toFormat(\n                    'MMM d yyyy'\n                ),\n                // Flip amount values to be more user-friendly (positive inflow, negative outflow)\n                amount: txn.amount.negated(),\n            }))\n\n        return groupBy(transactions, (t) => t.dateFormatted)\n    }, [accountTransactionsQuery.data])\n\n    return (\n        <div className=\"relative w-full pb-4 overflow-x-auto overflow-y-hidden custom-gray-scroll\">\n            {accountTransactionsQuery?.data && (\n                <>\n                    {Object.keys(groupedTransactions).length ? (\n                        <InfiniteScroll\n                            getScrollParent={() => document.getElementById('mainScrollArea')}\n                            useWindow={false}\n                            initialLoad={false}\n                            loadMore={() => accountTransactionsQuery.fetchNextPage()}\n                            hasMore={accountTransactionsQuery.hasNextPage}\n                        >\n                            <div className=\"text-base\">\n                                {[...Object.keys(groupedTransactions)].map((group) => (\n                                    <div key={group}>\n                                        <div className=\"font-medium text-gray-100\">{group}</div>\n                                        <div className=\"mt-4 mb-6 rounded-xl bg-gray-800 overflow-hidden\">\n                                            <table className=\"w-full\">\n                                                <tbody>\n                                                    {groupedTransactions[group].map(\n                                                        (transaction) => (\n                                                            <InvestmentTransactionListItem\n                                                                transaction={transaction}\n                                                                key={transaction.id}\n                                                            />\n                                                        )\n                                                    )}\n                                                </tbody>\n                                            </table>\n                                        </div>\n                                    </div>\n                                ))}\n                            </div>\n                        </InfiniteScroll>\n                    ) : (\n                        <div className=\"text-base text-gray-100\">No transactions found</div>\n                    )}\n\n                    {accountTransactionsQuery.isPreviousData &&\n                        Object.keys(groupedTransactions).length && (\n                            <div className=\"flex justify-center absolute top-0 left-0 w-full h-full bg-black bg-opacity-80\">\n                                <LoadingSpinner variant=\"secondary\" className=\"mt-12\" />\n                            </div>\n                        )}\n                </>\n            )}\n            {((accountTransactionsQuery.isLoading && !accountTransactionsQuery?.data) ||\n                accountTransactionsQuery.isFetchingNextPage) && (\n                <div className=\"flex items-center justify-center pt-4 pb-32\">\n                    <LoadingSpinner variant=\"secondary\" />\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/investment-transactions-list/InvestmentTransactionListItem.tsx",
    "content": "import type { ReactNode } from 'react'\nimport {\n    RiAddLine as PlusIcon,\n    RiSubtractLine as MinusIcon,\n    RiPercentLine as PercentIcon,\n    RiArrowUpLine as UpArrowIcon,\n    RiArrowDownLine as DownArrowIcon,\n    RiArrowRightUpLine as UpRightArrowIcon,\n    RiArrowRightDownLine as DownRightArrowIcon,\n    RiCloseLine as XIcon,\n    RiCoinLine as CoinIcon,\n} from 'react-icons/ri'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport type { SharedType } from '@maybe-finance/shared'\n\nconst Em = ({ children }: { children: ReactNode }) => (\n    <em className=\"not-italic text-gray-25\">{children}</em>\n)\n\nexport function InvestmentTransactionListItem({\n    transaction,\n}: {\n    transaction: SharedType.AccountInvestmentTransaction\n}) {\n    const isPositive = transaction.amount.isPositive()\n\n    const iconColor =\n        transaction.category === 'buy' ||\n        transaction.category === 'sell' ||\n        transaction.category === 'dividend'\n            ? { buy: 'text-teal', sell: 'text-red', dividend: 'text-teal' }[transaction.category]\n            : 'text-white'\n\n    const Icon = {\n        buy: PlusIcon,\n        sell: MinusIcon,\n        dividend: PercentIcon,\n        transfer:\n            transaction.securityId == null\n                ? isPositive\n                    ? UpArrowIcon\n                    : DownArrowIcon\n                : isPositive\n                ? DownRightArrowIcon\n                : UpRightArrowIcon,\n        tax: CoinIcon,\n        fee: CoinIcon,\n        cancel: XIcon,\n        other: null,\n    }[transaction.category]\n\n    return (\n        <tr>\n            <td className=\"flex items-center py-4 pl-4\">\n                <div className=\"relative\">\n                    <div\n                        className={`relative flex items-center justify-center w-12 h-12 rounded-xl overflow-hidden ${iconColor}`}\n                    >\n                        {/* Use absolute element for background because we can't use bg-opacity with bg-current */}\n                        <div className=\"absolute w-full h-full bg-current opacity-10\"></div>\n\n                        {Icon && <Icon className={`w-5 h-5`} />}\n                    </div>\n                </div>\n                <div className=\"ml-4 text-white font-normal\">\n                    {(() => {\n                        const { category, security, amount, price } = transaction\n                        const quantity = transaction.quantity.abs()\n\n                        switch (category) {\n                            case 'buy':\n                            case 'sell':\n                                return (\n                                    <>\n                                        {security?.name ?? transaction.name}\n                                        <div className=\"text-gray-100\">\n                                            {category === 'buy' ? 'Bought ' : 'Sold '}\n                                            <Em>\n                                                {quantity.toDecimalPlaces(2).toNumber()}{' '}\n                                                {security?.sharesPerContract == null\n                                                    ? 'share'\n                                                    : 'contract'}\n                                                {!quantity.equals(1) && 's'}\n                                            </Em>{' '}\n                                            at{' '}\n                                            <Em>\n                                                {NumberUtil.format(price, 'currency')}\n                                                {!quantity.equals(1) && ' each'}\n                                            </Em>\n                                        </div>\n                                    </>\n                                )\n                            case 'dividend':\n                                return (\n                                    <>\n                                        {security?.name ?? transaction.name}\n                                        <div className=\"text-gray-100\">\n                                            Received{' '}\n                                            <Em>{NumberUtil.format(amount, 'currency')}</Em> in\n                                            dividends\n                                        </div>\n                                    </>\n                                )\n                            case 'transfer': {\n                                const hasSecurity = security != null\n                                return (\n                                    <>\n                                        {hasSecurity\n                                            ? isPositive\n                                                ? 'Received'\n                                                : 'Sent'\n                                            : isPositive\n                                            ? 'Deposit'\n                                            : 'Withdrawal'}\n                                        <div className=\"text-gray-100\">\n                                            {hasSecurity ? (\n                                                <>\n                                                    {isPositive ? 'Received' : 'Sent'}{' '}\n                                                    <Em>{NumberUtil.format(amount, 'currency')}</Em>{' '}\n                                                    in holdings\n                                                </>\n                                            ) : (\n                                                <>\n                                                    {isPositive ? 'Deposited' : 'Withdrew'}{' '}\n                                                    <Em>{NumberUtil.format(amount, 'currency')}</Em>{' '}\n                                                    {isPositive ? 'into' : 'from'} account\n                                                </>\n                                            )}\n                                        </div>\n                                    </>\n                                )\n                            }\n                            case 'fee':\n                                return (\n                                    <>\n                                        {transaction.name}\n                                        <div className=\"text-gray-100\">\n                                            Incurred a fee of{' '}\n                                            <Em>{NumberUtil.format(amount, 'currency')}</Em>\n                                        </div>\n                                    </>\n                                )\n                        }\n\n                        return <div>{transaction.name}</div>\n                    })()}\n                </div>\n            </td>\n            <td className=\"pr-4 md:pl-8 lg:pl-16 text-right font-semibold tabular-nums\">\n                {NumberUtil.format(\n                    // Negate a buy transaction to keep it positive\n                    transaction.amount.times(transaction.category === 'buy' ? -1 : 1),\n                    'currency'\n                )}\n            </td>\n        </tr>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/investment-transactions-list/index.ts",
    "content": "export * from './InvestmentTransactionList'\n"
  },
  {
    "path": "libs/client/features/src/layout/DesktopLayout.tsx",
    "content": "import classNames from 'classnames'\nimport {\n    ProfileCircle,\n    useAccountContext,\n    usePopoutContext,\n    LayoutContextProvider,\n    useUserApi,\n} from '@maybe-finance/client/shared'\nimport { useMemo, useState, useEffect, useRef, type PropsWithChildren, type ReactNode } from 'react'\nimport { AnimatePresence, motion } from 'framer-motion'\nimport Link from 'next/link'\nimport { useRouter } from 'next/router'\nimport type { IconType } from 'react-icons'\nimport {\n    RiAddFill,\n    RiFolderOpenLine,\n    RiMenuFoldLine,\n    RiMenuUnfoldLine,\n    RiPieChart2Line,\n    RiFlagLine,\n    RiArrowRightSLine,\n} from 'react-icons/ri'\nimport { Button, Tooltip } from '@maybe-finance/design-system'\nimport { MenuPopover } from './MenuPopover'\nimport { UpgradePrompt } from '../user-billing'\nimport { SidebarOnboarding } from '../onboarding'\nimport { useSession } from 'next-auth/react'\n\nexport interface DesktopLayoutProps {\n    sidebar: React.ReactNode\n    children: React.ReactNode\n}\n\nconst LayoutVariants = {\n    collapsed: {\n        gridTemplateColumns: '88px 0px 1fr 0px',\n    },\n    expanded: {\n        gridTemplateColumns: '88px 330px 1fr 0px',\n    },\n    popout: {\n        gridTemplateColumns: '88px 0px 1fr 384px',\n    },\n}\n\nfunction NavItem({\n    href,\n    icon: Icon,\n    label,\n    active,\n}: {\n    href: string\n    icon: IconType\n    label: string\n    active?: (pathname: string) => boolean\n}) {\n    const { pathname } = useRouter()\n\n    const isActive = active ? active(pathname) : pathname === href\n\n    return (\n        <li>\n            <div>\n                <Link\n                    href={href}\n                    passHref\n                    className={classNames(\n                        'relative flex flex-col items-center w-[88px] rounded-lg cursor-pointer text-gray-100 hover:text-gray-50 transition-colors',\n                        isActive && 'text-gray-25'\n                    )}\n                >\n                    {isActive && (\n                        <motion.div\n                            layoutId=\"nav-selection\"\n                            className=\"absolute inset-0\"\n                            transition={{ duration: 0.3 }}\n                        >\n                            <div className=\"absolute left-0 w-1 h-5 -translate-y-1/2 bg-white rounded-r-lg top-1/2\"></div>\n                        </motion.div>\n                    )}\n                    <Icon className=\"w-6 h-6 shrink-0\" />\n                    <span className=\"shrink-0 mt-1.5 text-sm font-medium text-center\">{label}</span>\n                </Link>\n            </div>\n        </li>\n    )\n}\n\nexport function DesktopLayout({ children, sidebar }: DesktopLayoutProps) {\n    const overlayContainer = useRef<HTMLDivElement>(null)\n    const router = useRouter()\n\n    const [onboardingExpanded, setOnboardingExpanded] = useState(false)\n\n    const { popoutContents, close: closePopout } = usePopoutContext()\n    const { data: session } = useSession()\n    const user = session!.user\n    const { useOnboarding, useUpdateOnboarding } = useUserApi()\n    const onboarding = useOnboarding('sidebar')\n    const updateOnboarding = useUpdateOnboarding()\n\n    const [collapsed, setCollapsed] = useState(false)\n    const layout = useMemo(() => {\n        if (popoutContents) return 'popout'\n\n        return collapsed ? 'collapsed' : 'expanded'\n    }, [collapsed, popoutContents])\n\n    useEffect(() => {\n        router.events.on('routeChangeComplete', () => closePopout())\n\n        return () => {\n            router.events.off('routeChangeComplete', () => undefined)\n        }\n    }, [router, closePopout])\n\n    const showOnboardingOverride = router.query.show_sidebar_onboarding === 'true'\n\n    const hideOnboardingWidgetForever = () => {\n        // User does not want to see onboarding widget anymore, so mark flow complete\n        updateOnboarding.mutate({\n            flow: 'sidebar',\n            updates: [],\n            markedComplete: true,\n        })\n    }\n\n    // This flow requires the user to \"mark flow complete\" to remove the widget\n    const showWidget =\n        showOnboardingOverride || (onboarding.data && !onboarding.data.isMarkedComplete)\n\n    return (\n        <motion.div\n            className=\"min-h-screen grid grid-rows-[100vh] bg-gray-800\"\n            layout\n            variants={LayoutVariants}\n            animate={layout}\n            initial={false}\n            transition={{ duration: 0.4 }}\n        >\n            <nav className=\"flex flex-col items-center justify-between pt-8 pb-6 border-r border-gray-700\">\n                <div className=\"flex flex-col items-center\">\n                    <Link href=\"/\">\n                        <img\n                            src=\"/assets/maybe.svg\"\n                            alt=\"Maybe Finance Logo\"\n                            className=\"mb-6\"\n                            height={36}\n                            width={36}\n                        />\n                    </Link>\n\n                    <motion.ul\n                        initial={{ opacity: 0 }}\n                        animate={{ opacity: 1 }}\n                        className=\"flex flex-col items-center gap-5 mt-4\"\n                    >\n                        <NavItem label=\"Net worth\" href=\"/\" icon={RiPieChart2Line} />\n                        <NavItem label=\"Accounts\" href=\"/accounts\" icon={RiFolderOpenLine} />\n                        <NavItem\n                            label=\"Planning\"\n                            href=\"/plans\"\n                            icon={RiFlagLine}\n                            active={(path) => path.startsWith('/plans')}\n                        />\n                    </motion.ul>\n                </div>\n\n                <div className=\"flex flex-col items-center gap-3\">\n                    <Tooltip\n                        content={layout === 'expanded' ? 'Minimize' : 'Maximize'}\n                        placement=\"right\"\n                    >\n                        <div\n                            className=\"flex items-center justify-center w-12 h-12 p-2 rounded-lg cursor-pointer hover:bg-gray-500\"\n                            onClick={() => {\n                                if (layout === 'expanded') {\n                                    setCollapsed(true)\n                                } else {\n                                    setCollapsed(false)\n                                    closePopout()\n                                }\n                            }}\n                        >\n                            {layout === 'expanded' ? (\n                                <RiMenuFoldLine className=\"w-6 h-6\" />\n                            ) : (\n                                <RiMenuUnfoldLine className=\"w-6 h-6\" />\n                            )}\n                        </div>\n                    </Tooltip>\n\n                    <MenuPopover\n                        isHeader={false}\n                        icon={<ProfileCircle />}\n                        placement={'right-end'}\n                    />\n                </div>\n            </nav>\n\n            <motion.aside\n                variants={{\n                    expanded: { opacity: [0, 1] },\n                    collapsed: { opacity: 0 },\n                    popout: { opacity: 0 },\n                }}\n                layout=\"position\"\n                animate={layout}\n                className=\"flex flex-col gap-4 px-4 pt-8 pb-6 bg-gray-800\"\n            >\n                {layout === 'expanded' &&\n                    (onboardingExpanded && showWidget ? (\n                        <SidebarOnboarding\n                            onClose={() => setOnboardingExpanded(false)}\n                            onHide={() => {\n                                setOnboardingExpanded(false)\n                                hideOnboardingWidgetForever()\n                            }}\n                        />\n                    ) : (\n                        <DefaultContent\n                            onboarding={\n                                <AnimatePresence>\n                                    {onboarding.data && showWidget && (\n                                        <motion.div\n                                            initial={{ opacity: 0 }}\n                                            animate={{ opacity: 1 }}\n                                            exit={{ opacity: 0 }}\n                                            className=\"p-3 text-base bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-600\"\n                                            onClick={() => setOnboardingExpanded(true)}\n                                        >\n                                            <div className=\"flex items-center justify-between mb-1 text-sm\">\n                                                <p className=\"text-gray-50\">Getting started</p>\n                                                {onboarding.data.isComplete && (\n                                                    <button\n                                                        onClick={(e) => {\n                                                            e.stopPropagation()\n                                                            hideOnboardingWidgetForever()\n                                                        }}\n                                                        className=\"p-1 bg-gray-600 rounded hover:bg-gray-500\"\n                                                    >\n                                                        Hide\n                                                    </button>\n                                                )}\n                                            </div>\n                                            <div className=\"flex items-center justify-between gap-2\">\n                                                <span className=\"text-cyan\">\n                                                    {onboarding.data?.progress.completed} of{' '}\n                                                    {onboarding.data?.progress.total} done\n                                                </span>\n\n                                                {!onboarding.data.isComplete && (\n                                                    <RiArrowRightSLine\n                                                        size={24}\n                                                        className=\"shrink-0\"\n                                                    />\n                                                )}\n                                            </div>\n                                            <div className=\"relative h-2 mt-2 bg-gray-600 rounded-sm\">\n                                                <div\n                                                    className=\"absolute inset-0 h-2 rounded-sm bg-cyan\"\n                                                    style={{\n                                                        width: `${\n                                                            (onboarding.data?.progress.percent ??\n                                                                0) * 100\n                                                        }%`,\n                                                    }}\n                                                ></div>\n                                            </div>\n                                        </motion.div>\n                                    )}\n                                </AnimatePresence>\n                            }\n                            name={user?.name ?? ''}\n                            email={user?.email ?? ''}\n                        >\n                            {sidebar}\n                        </DefaultContent>\n                    ))}\n            </motion.aside>\n\n            <main id=\"mainScrollArea\" className=\"p-12 bg-black custom-gray-scroll\">\n                <div className=\"relative h-full max-w-screen-xl mx-auto\">\n                    <LayoutContextProvider overlayContainer={overlayContainer}>\n                        <div className=\"relative min-h-full pb-24\">\n                            {children}\n                            <div ref={overlayContainer}></div>\n                        </div>\n                    </LayoutContextProvider>\n                </div>\n            </main>\n\n            <motion.aside\n                variants={{\n                    expanded: { opacity: 0 },\n                    collapsed: { opacity: 0 },\n                    popout: { opacity: [0, 1] },\n                }}\n                animate={layout}\n                className=\"bg-gray-800 custom-gray-scroll\"\n            >\n                {layout === 'popout' && popoutContents}\n            </motion.aside>\n        </motion.div>\n    )\n}\n\nexport default DesktopLayout\n\nfunction DefaultContent({\n    children,\n    onboarding,\n    name,\n    email,\n}: PropsWithChildren<{ onboarding?: ReactNode; name?: string; email?: string }>) {\n    const { addAccount } = useAccountContext()\n    return (\n        <>\n            <div className=\"flex items-center justify-between mb-4\">\n                <h5 className=\"uppercase\">Assets & Debts</h5>\n                <Tooltip content=\"Add account\" placement=\"bottom\">\n                    <Button\n                        className=\"-mt-1\"\n                        variant=\"icon\"\n                        onClick={addAccount}\n                        data-testid=\"add-account-button\"\n                    >\n                        <RiAddFill className=\"w-6 h-6\" />\n                    </Button>\n                </Tooltip>\n            </div>\n\n            {/* Margin and padding offsets pin the scrollbar to the right edge of container */}\n            <div className=\"relative pr-4 -mr-4 grow custom-gray-scroll\">\n                {children}\n                <div className=\"sticky bottom-0 w-full h-16 pointer-events-none bg-gradient-to-t from-gray-800\" />\n            </div>\n\n            {onboarding && onboarding}\n\n            {process.env.STRIPE_API_KEY && <UpgradePrompt />}\n\n            <div className=\"flex items-center justify-between\">\n                <div className=\"text-base\">\n                    <p data-testid=\"user-name\">{name ?? ''}</p>\n                    <p className=\"text-gray-100\">{email ?? ''}</p>\n                </div>\n            </div>\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/layout/FullPageLayout.tsx",
    "content": "import { SCREEN, useScreenSize, Toaster } from '@maybe-finance/client/shared'\nimport { type PropsWithChildren, useEffect, useState } from 'react'\n\nexport function FullPageLayout({ children }: PropsWithChildren) {\n    const screen = useScreenSize()\n\n    const [isMounted, setIsMounted] = useState(false)\n    useEffect(() => {\n        setIsMounted(true)\n    }, [])\n\n    if (!isMounted) return null\n\n    return (\n        <>\n            <Toaster mobile={screen === SCREEN.MOBILE} />\n            <div className=\"fixed h-full w-full custom-gray-scroll flex flex-col\">{children}</div>\n        </>\n    )\n}\n\nexport default FullPageLayout\n"
  },
  {
    "path": "libs/client/features/src/layout/MenuPopover.tsx",
    "content": "import { signOut } from 'next-auth/react'\nimport { Menu } from '@maybe-finance/design-system'\nimport type { ComponentProps } from 'react'\nimport {\n    RiSettings3Line as SettingsIcon,\n    RiShutDownLine as LogoutIcon,\n    RiDatabase2Line,\n} from 'react-icons/ri'\n\nexport function MenuPopover({\n    icon,\n    placement = 'top-end',\n    isHeader,\n}: {\n    icon: JSX.Element\n    placement?: ComponentProps<typeof Menu.Item>['placement']\n    isHeader: boolean\n}) {\n    return (\n        <Menu>\n            <Menu.Button variant=\"profileIcon\">{icon}</Menu.Button>\n            <Menu.Items\n                placement={placement}\n                className={isHeader ? 'bg-gray-600' : 'min-w-[200px]'}\n            >\n                <Menu.ItemNextLink icon={<SettingsIcon />} href=\"/settings\">\n                    Settings\n                </Menu.ItemNextLink>\n                <Menu.ItemNextLink icon={<RiDatabase2Line />} href=\"/data-editor\">\n                    Fix my data\n                </Menu.ItemNextLink>\n                <Menu.Item icon={<LogoutIcon />} destructive={true} onClick={() => signOut()}>\n                    Log out\n                </Menu.Item>\n            </Menu.Items>\n        </Menu>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/layout/MobileLayout.tsx",
    "content": "import React, { useEffect, useMemo, useRef, useState } from 'react'\nimport { motion } from 'framer-motion'\nimport {\n    RiCloseLine,\n    RiFlagLine,\n    RiFolderOpenLine,\n    RiMenuLine,\n    RiMore2Fill,\n    RiPieChart2Line,\n} from 'react-icons/ri'\nimport { Button } from '@maybe-finance/design-system'\nimport { MenuPopover } from './MenuPopover'\nimport Link from 'next/link'\nimport { useRouter } from 'next/router'\nimport { UpgradePrompt } from '../user-billing'\nimport { ProfileCircle } from '@maybe-finance/client/shared'\nimport { usePopoutContext, LayoutContextProvider } from '@maybe-finance/client/shared'\nimport classNames from 'classnames'\nimport type { IconType } from 'react-icons'\n\nexport interface MobileLayoutProps {\n    sidebar: React.ReactNode\n    children: React.ReactNode\n}\n\nconst AsideVariants = {\n    expanded: {\n        transform: 'translateX(0%)',\n    },\n    collapsed: {\n        transform: 'translateX(-100%)',\n    },\n    popout: {\n        transform: 'translateX(-100%)',\n    },\n}\n\nconst MainVariants = {\n    expanded: {\n        transform: 'translateX(100%)',\n    },\n    collapsed: {\n        transform: 'translateX(0%)',\n    },\n    popout: {\n        transform: 'translateX(-100%)',\n    },\n}\n\nconst PopoutVariants = {\n    expanded: {\n        transform: 'translateX(100%)',\n    },\n    collapsed: {\n        transform: 'translateX(100%)',\n    },\n    popout: {\n        transform: 'translateX(0)',\n    },\n}\n\nfunction NavItem({\n    href,\n    icon: Icon,\n    label,\n    active,\n}: {\n    href: string\n    icon: IconType\n    label: string\n    active?: (pathname: string) => boolean\n}) {\n    const { pathname } = useRouter()\n\n    const isActive = active ? active(pathname) : pathname === href\n\n    return (\n        <li>\n            <div>\n                <Link\n                    href={href}\n                    passHref\n                    className={classNames(\n                        'relative flex flex-col items-center w-[82px] py-3 rounded-lg cursor-pointer text-gray-100 hover:text-gray-50 transition-colors',\n                        isActive && 'text-gray-25'\n                    )}\n                >\n                    {isActive && (\n                        <motion.div\n                            layoutId=\"nav-selection\"\n                            className=\"absolute inset-0\"\n                            transition={{ duration: 0.3 }}\n                        >\n                            <div className=\"absolute bottom-0 w-5 h-1 -translate-x-1/2 bg-white rounded-t-lg left-1/2\"></div>\n                        </motion.div>\n                    )}\n                    <Icon className=\"w-6 h-6 shrink-0\" />\n                    <span className=\"shrink-0 mt-1.5 text-sm font-medium text-center\">{label}</span>\n                </Link>\n            </div>\n        </li>\n    )\n}\n\nexport function MobileLayout({ children, sidebar }: MobileLayoutProps) {\n    const overlayContainer = useRef<HTMLDivElement>(null)\n\n    const { popoutContents, close: closePopout } = usePopoutContext()\n\n    const [collapsed, setCollapsed] = useState(true)\n    const layout = useMemo(() => {\n        if (popoutContents) return 'popout'\n\n        return collapsed ? 'collapsed' : 'expanded'\n    }, [collapsed, popoutContents])\n\n    const router = useRouter()\n\n    useEffect(() => {\n        router.events.on('routeChangeComplete', () => {\n            setCollapsed(true)\n            closePopout()\n        })\n\n        return () => {\n            router.events.off('routeChangeComplete', () => undefined)\n        }\n    }, [router, closePopout])\n\n    return (\n        <div>\n            <motion.aside\n                layout\n                initial={false}\n                animate={layout}\n                variants={AsideVariants}\n                transition={{ duration: 0.4 }}\n                className=\"fixed w-full h-screen\"\n            >\n                <div>\n                    <nav>\n                        <div className=\"flex items-center justify-between h-20 px-4\">\n                            <div className=\"w-10\">\n                                <Button variant=\"icon\" onClick={() => setCollapsed(true)}>\n                                    <RiCloseLine className=\"w-6 h-6\" />\n                                </Button>\n                            </div>\n                            <Link href=\"/\" className=\"flex items-center cursor-pointer\">\n                                <img\n                                    src=\"/assets/maybe.svg\"\n                                    alt=\"Maybe Finance Logo\"\n                                    height={36}\n                                    width={36}\n                                />\n                            </Link>\n                            <Link href=\"/settings\">\n                                <ProfileCircle className=\"!w-10 !h-10\" />\n                            </Link>\n                        </div>\n                        <ul className=\"flex items-end justify-center border-b border-gray-700 xs:gap-2\">\n                            <NavItem label=\"Net worth\" href=\"/\" icon={RiPieChart2Line} />\n                            <NavItem label=\"Accounts\" href=\"/accounts\" icon={RiFolderOpenLine} />\n                            <NavItem\n                                label=\"Planning\"\n                                href=\"/plans\"\n                                icon={RiFlagLine}\n                                active={(path) => path.startsWith('/plans')}\n                            />\n                        </ul>\n                    </nav>\n                    <div className=\"flex flex-col h-[calc(100vh-80px)] px-4 pt-6 pb-24\">\n                        <section className=\"grow h-[calc(100vh-80px)] custom-gray-scroll\">\n                            {sidebar}\n                        </section>\n\n                        <div className=\"pt-6 shrink-0\">\n                            {process.env.STRIPE_API_KEY && <UpgradePrompt />}\n                        </div>\n                    </div>\n                </div>\n            </motion.aside>\n\n            <motion.div\n                layout\n                initial={false}\n                animate={layout}\n                variants={MainVariants}\n                transition={{ duration: 0.4 }}\n                className=\"fixed w-full\"\n            >\n                <header className=\"flex items-center justify-between h-20 px-4\">\n                    <Button variant=\"icon\" onClick={() => setCollapsed(false)}>\n                        <RiMenuLine className=\"w-6 h-6\" />\n                    </Button>\n                    <Link href=\"/\" className=\"flex items-center cursor-pointer\">\n                        <img\n                            src=\"/assets/maybe.svg\"\n                            alt=\"Maybe Finance Logo\"\n                            height={36}\n                            width={36}\n                        />\n                    </Link>\n                    <MenuPopover isHeader={false} icon={<RiMore2Fill />} placement=\"bottom-end\" />\n                </header>\n\n                <main\n                    id=\"mainScrollArea\"\n                    className=\"relative px-4 pt-6 h-[calc(100vh-80px)] custom-gray-scroll\"\n                >\n                    <LayoutContextProvider overlayContainer={overlayContainer}>\n                        <div className=\"relative min-h-full pb-24\">\n                            {children}\n                            <div ref={overlayContainer}></div>\n                        </div>\n                    </LayoutContextProvider>\n                </main>\n            </motion.div>\n            <motion.aside\n                variants={PopoutVariants}\n                initial={false}\n                animate={layout}\n                transition={{ duration: 0.4 }}\n                className=\"fixed w-full h-screen bg-gray-800 custom-gray-scroll\"\n            >\n                {layout === 'popout' && popoutContents}\n            </motion.aside>\n        </div>\n    )\n}\n\nexport default MobileLayout\n"
  },
  {
    "path": "libs/client/features/src/layout/NotFound.tsx",
    "content": "import { Button } from '@maybe-finance/design-system'\nimport Link from 'next/link'\n\nfunction NotFoundSVG() {\n    return (\n        <svg\n            className=\"max-w-xs px-8 mb-10 sm:p-0\"\n            viewBox=\"0 0 325 92\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n        >\n            <rect\n                x=\"67.4531\"\n                y=\"24.5283\"\n                width=\"30.6604\"\n                height=\"18.3962\"\n                rx=\"9.19811\"\n                fill=\"#4361EE\"\n            />\n            <rect\n                x=\"294.34\"\n                y=\"24.5283\"\n                width=\"30.6604\"\n                height=\"18.3962\"\n                rx=\"9.19811\"\n                fill=\"#4361EE\"\n            />\n            <rect\n                x=\"18.3965\"\n                y=\"24.5283\"\n                width=\"36.7925\"\n                height=\"18.3962\"\n                rx=\"9.19811\"\n                fill=\"#4361EE\"\n            />\n            <rect\n                x=\"245.283\"\n                y=\"24.5283\"\n                width=\"36.7925\"\n                height=\"18.3962\"\n                rx=\"9.19811\"\n                fill=\"#4361EE\"\n            />\n            <rect\n                x=\"67.4531\"\n                y=\"73.5848\"\n                width=\"30.6604\"\n                height=\"18.3962\"\n                rx=\"9.19811\"\n                fill=\"#F72585\"\n            />\n            <rect\n                x=\"294.34\"\n                y=\"73.5848\"\n                width=\"30.6604\"\n                height=\"18.3962\"\n                rx=\"9.19811\"\n                fill=\"#F72585\"\n            />\n            <rect\n                x=\"183.963\"\n                y=\"24.5283\"\n                width=\"30.6604\"\n                height=\"18.3962\"\n                rx=\"9.19811\"\n                fill=\"#4361EE\"\n            />\n            <rect\n                x=\"119.576\"\n                y=\"24.5283\"\n                width=\"30.6604\"\n                height=\"18.3962\"\n                rx=\"9.19811\"\n                fill=\"#4361EE\"\n            />\n            <rect\n                x=\"141.037\"\n                y=\"73.5848\"\n                width=\"49.0566\"\n                height=\"18.3962\"\n                rx=\"9.19811\"\n                fill=\"#F72585\"\n            />\n            <g className=\"animate-flicker-fast\">\n                <rect x=\"52.123\" width=\"45.9906\" height=\"18.3962\" rx=\"9.19811\" fill=\"#4CC9F0\" />\n                <rect x=\"279.01\" width=\"45.9906\" height=\"18.3962\" rx=\"9.19811\" fill=\"#4CC9F0\" />\n                <rect x=\"141.037\" width=\"49.0566\" height=\"18.3962\" rx=\"9.19811\" fill=\"#4CC9F0\" />\n            </g>\n            <g className=\"animate-flicker-slow\">\n                <rect\n                    x=\"116.51\"\n                    y=\"49.0566\"\n                    width=\"33.7264\"\n                    height=\"18.3962\"\n                    rx=\"9.19811\"\n                    fill=\"#7209B7\"\n                />\n                <rect\n                    x=\"183.963\"\n                    y=\"49.0566\"\n                    width=\"30.6604\"\n                    height=\"18.3962\"\n                    rx=\"9.19811\"\n                    fill=\"#7209B7\"\n                />\n                <rect y=\"49.0566\" width=\"98.1132\" height=\"18.3962\" rx=\"9.19811\" fill=\"#7209B7\" />\n                <rect\n                    x=\"226.887\"\n                    y=\"49.0566\"\n                    width=\"98.1132\"\n                    height=\"18.3962\"\n                    rx=\"9.19811\"\n                    fill=\"#7209B7\"\n                />\n            </g>\n        </svg>\n    )\n}\n\nexport function NotFoundPage() {\n    return (\n        <div className=\"h-screen flex flex-col items-center justify-center p-4\">\n            <NotFoundSVG />\n\n            <h1 className=\"mb-2 font-extrabold text-base md:text-2xl text-white\">\n                Welp, this is awkward.\n            </h1>\n\n            <p className=\"mb-10 text-base text-gray-50\">\n                Looks like this page didn’t survive the economic downturn.\n            </p>\n\n            {/* TODO: Add forwardRef functionality to Button so this doesn't throw a warning */}\n            <Link href=\"/\" passHref legacyBehavior>\n                <Button>Back to dashboard</Button>\n            </Link>\n        </div>\n    )\n}\n\nexport default NotFoundPage\n"
  },
  {
    "path": "libs/client/features/src/layout/SidebarNav.tsx",
    "content": "import classNames from 'classnames'\nimport Link from 'next/link'\nimport { useRouter } from 'next/router'\nimport { RiBriefcaseLine, RiPieChartLine } from 'react-icons/ri'\n\ntype SidebarNavProps = {\n    accountsNotification?: 'error' | 'update' | null\n}\n\nexport function SidebarNav({ accountsNotification }: SidebarNavProps) {\n    const router = useRouter()\n\n    return (\n        <nav className=\"flex flex-col space-y-1\">\n            <Link\n                href=\"/\"\n                className={classNames(\n                    'flex items-center space-x-2 h-8 hover:bg-gray-600 rounded pl-2',\n                    router.pathname === '/' ? 'bg-gray-500' : ''\n                )}\n            >\n                <RiPieChartLine\n                    className={classNames('h-5 w-5', router.pathname !== '/' && 'text-gray-100')}\n                />\n                <span className=\"text-base\">Net Worth</span>\n            </Link>\n            <Link\n                href=\"/accounts\"\n                className={classNames(\n                    'flex items-center space-x-2 h-8 hover:bg-gray-600 rounded pl-2',\n                    router.pathname === '/accounts' ? 'bg-gray-500' : ''\n                )}\n            >\n                <span className=\"inline-block relative\">\n                    <RiBriefcaseLine\n                        className={classNames(\n                            'h-5 w-5',\n                            router.pathname !== '/accounts' && 'text-gray-100'\n                        )}\n                    />\n                    {accountsNotification && (\n                        <span\n                            className={classNames(\n                                'absolute top-0 right-0 block h-[5px] w-[5px] transform translate-x-1/2 rounded-full ring-2 ring-gray-600',\n                                accountsNotification === 'update' ? 'bg-cyan' : 'bg-red'\n                            )}\n                        />\n                    )}\n                </span>\n                <span className=\"text-base\">Accounts</span>\n            </Link>\n        </nav>\n    )\n}\n\nexport default SidebarNav\n"
  },
  {
    "path": "libs/client/features/src/layout/WithOnboardingLayout.tsx",
    "content": "import { useScreenSize, SCREEN, Toaster } from '@maybe-finance/client/shared'\nimport { useEffect, useState } from 'react'\nimport { Breadcrumb, Button } from '@maybe-finance/design-system'\nimport { RiCloseLine } from 'react-icons/ri'\nimport Link from 'next/link'\n\ntype Path = {\n    title: string\n    href?: string\n}\n\nexport interface WithOnboardingLayoutProps {\n    paths: Path[]\n    children: React.ReactNode\n}\n\nexport function WithOnboardingLayout({ paths, children }: WithOnboardingLayoutProps) {\n    const screen = useScreenSize()\n\n    const [isMounted, setIsMounted] = useState(false)\n    useEffect(() => {\n        setIsMounted(true)\n    }, [])\n\n    if (!isMounted) return null\n\n    return (\n        <div className=\"p-4 sm:p-12 h-screen custom-gray-scroll flex justify-center\">\n            <div className=\"h-full w-full max-w-screen-xl flex flex-col items-center\">\n                <Toaster\n                    mobile={screen === SCREEN.MOBILE}\n                    sidebarOffset={screen === SCREEN.DESKTOP ? 'ml-96' : undefined}\n                />\n\n                {/* Mobile only - shows breadcrumbs and close icon at top of content */}\n                <div className=\"lg:hidden w-full flex items-center justify-around gap-4 mb-8 pt-4\">\n                    <Breadcrumb.Group>\n                        {paths.map((path) => (\n                            <Breadcrumb key={path.title} href={path.href}>\n                                {path.title}\n                            </Breadcrumb>\n                        ))}\n                    </Breadcrumb.Group>\n                    <Link href={paths[0]?.href ?? '/'} passHref legacyBehavior>\n                        <Button variant=\"icon\">\n                            <RiCloseLine className=\"w-6 h-6\" />\n                        </Button>\n                    </Link>\n                </div>\n\n                <div className=\"flex justify-between h-full lg:w-full\">\n                    <Breadcrumb.Group className=\"self-start hidden lg:inline-flex\">\n                        {paths.map((path) => (\n                            <Breadcrumb key={path.title} href={path.href}>\n                                {path.title}\n                            </Breadcrumb>\n                        ))}\n                    </Breadcrumb.Group>\n                    <div className=\"max-w-lg flex justify-center h-full\">{children}</div>\n                    <div className=\"lg:w-[200px] hidden lg:flex lg:justify-end\">\n                        <Link href={paths[0]?.href ?? '/'} passHref legacyBehavior>\n                            <Button variant=\"icon\">\n                                <RiCloseLine className=\"w-6 h-6\" />\n                            </Button>\n                        </Link>\n                    </div>\n                </div>\n            </div>\n        </div>\n    )\n}\n\nexport default WithOnboardingLayout\n"
  },
  {
    "path": "libs/client/features/src/layout/WithSidebarLayout.tsx",
    "content": "import { useScreenSize, SCREEN, Toaster, PopoutProvider } from '@maybe-finance/client/shared'\nimport MobileLayout from './MobileLayout'\nimport DesktopLayout from './DesktopLayout'\nimport { useEffect, useState } from 'react'\n\nexport interface WithSidebarLayoutProps {\n    sidebar: React.ReactNode\n    children: React.ReactNode\n}\n\nexport function WithSidebarLayout({ sidebar, children }: WithSidebarLayoutProps) {\n    const screen = useScreenSize()\n\n    // Due to SSR and the conditional components we are loading based on screen size,\n    // ensure that app is mounted to prevent rehydration issues\n    const [isMounted, setIsMounted] = useState(false)\n    useEffect(() => {\n        setIsMounted(true)\n    }, [])\n\n    if (!isMounted) return null\n\n    return (\n        <PopoutProvider>\n            <Toaster\n                mobile={screen === SCREEN.MOBILE}\n                sidebarOffset={screen === SCREEN.DESKTOP ? 'ml-96' : undefined}\n            />\n\n            {screen === SCREEN.MOBILE ? (\n                <MobileLayout sidebar={sidebar}>{children}</MobileLayout>\n            ) : (\n                <DesktopLayout sidebar={sidebar}>{children}</DesktopLayout>\n            )}\n        </PopoutProvider>\n    )\n}\n\nexport default WithSidebarLayout\n"
  },
  {
    "path": "libs/client/features/src/layout/index.ts",
    "content": "export * from './FullPageLayout'\nexport * from './WithSidebarLayout'\nexport * from './NotFound'\nexport * from './WithOnboardingLayout'\n"
  },
  {
    "path": "libs/client/features/src/loan-details/LoanCard.tsx",
    "content": "import type { PropsWithChildren, ReactNode } from 'react'\n\nimport { LoadingPlaceholder, Tooltip } from '@maybe-finance/design-system'\nimport { RiQuestionLine } from 'react-icons/ri'\nimport classNames from 'classnames'\n\nexport type LoanCardProps = PropsWithChildren<{\n    isLoading: boolean\n    title: string\n\n    detail?: {\n        metricValue: string\n        metricDetail: ReactNode\n    }\n    className?: string\n    info?: ReactNode\n    headerRight?: ReactNode\n}>\n\nexport function LoanCard({\n    isLoading,\n    title,\n    detail,\n    className,\n    info,\n    headerRight,\n}: LoanCardProps) {\n    return (\n        <div className={classNames('bg-gray-800 rounded-lg w-full p-4', className)}>\n            <div className=\"flex items-center justify-between space-x-1.5\">\n                <div className=\"flex items-center\">\n                    <p className=\"text-base text-gray-100\">{title}</p>\n                    {info && (\n                        <Tooltip\n                            content={<div className=\"text-base text-gray-50\">{info}</div>}\n                            className=\"max-w-[350px]\"\n                        >\n                            <span>\n                                <RiQuestionLine className=\"w-5 h-5 text-gray-50 mx-1.5\" />\n                            </span>\n                        </Tooltip>\n                    )}\n                </div>\n                <div className=\"whitespace-nowrap\">{headerRight}</div>\n            </div>\n\n            <div className=\"mt-4\">\n                <LoadingPlaceholder isLoading={isLoading} className=\"\">\n                    {detail ? (\n                        <>\n                            <h3>{detail.metricValue}</h3>\n                            <div className=\"mt-1 text-base text-gray-100\">\n                                {detail.metricDetail}\n                            </div>\n                        </>\n                    ) : (\n                        <p className=\"text-gray-100 text-base\">\n                            We need some data from your end to calculate this metric accurately\n                        </p>\n                    )}\n                </LoadingPlaceholder>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/loan-details/LoanDetail.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { useAccountContext, BrowserUtil } from '@maybe-finance/client/shared'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { DateTime } from 'luxon'\nimport toast from 'react-hot-toast'\nimport { RiAddLine, RiPencilLine } from 'react-icons/ri'\nimport { LoanCard } from './LoanCard'\n\nexport type LoanDetailProps = {\n    account: SharedType.AccountDetail\n    showComingSoon?: boolean\n}\n\nexport function LoanDetail({ account, showComingSoon = false }: LoanDetailProps) {\n    const { loan } = account\n\n    const { editAccount } = useAccountContext()\n\n    return (\n        <div data-testid=\"loan-detail-cards\">\n            <div className=\"flex justify-between items-center mb-4\">\n                <h5 className=\"uppercase\">Loan Overview</h5>\n                <button\n                    className=\"flex items-center px-2 py-1 font-medium bg-gray-500 rounded hover:bg-gray-400\"\n                    onClick={() => {\n                        if (!account) {\n                            toast.error('Unable to edit loan')\n                            return\n                        }\n\n                        editAccount(account)\n                    }}\n                >\n                    {!loan ? (\n                        <>\n                            <RiAddLine className=\"w-5 h-5 text-gray-50\" />\n                            <span className=\"ml-2 text-base\">Add terms</span>\n                        </>\n                    ) : (\n                        <>\n                            <RiPencilLine className=\"w-5 h-5 text-gray-50\" />\n                            <span className=\"ml-2 text-base\">Edit terms</span>\n                        </>\n                    )}\n                </button>\n            </div>\n\n            <div className=\"relative grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3\">\n                <LoanCard\n                    isLoading={false}\n                    title=\"Loan amount\"\n                    info=\"The contractual starting balance for this loan\"\n                    detail={\n                        loan\n                            ? {\n                                  metricValue: NumberUtil.format(\n                                      loan.originationPrincipal,\n                                      'currency'\n                                  ),\n                                  metricDetail: `Originated on ${DateTime.fromISO(\n                                      loan.originationDate ?? ''\n                                  ).toFormat('MMM d, yyyy')}`,\n                              }\n                            : undefined\n                    }\n                />\n\n                <LoanCard\n                    isLoading={false}\n                    title=\"Remaining balance\"\n                    info=\"The remaining balance on this loan\"\n                    detail={\n                        loan && loan.originationPrincipal && account.currentBalance\n                            ? {\n                                  metricValue: NumberUtil.format(\n                                      account.currentBalance.toNumber(),\n                                      'currency'\n                                  ),\n                                  metricDetail: `${NumberUtil.format(\n                                      loan.originationPrincipal - account.currentBalance.toNumber(),\n                                      'currency'\n                                  )} principal already paid`,\n                              }\n                            : undefined\n                    }\n                />\n\n                <LoanCard\n                    isLoading={false}\n                    title=\"Loan terms\"\n                    info=\"The details of your loan contract\"\n                    detail={\n                        loan && loan.originationDate && loan.maturityDate && loan.interestRate\n                            ? {\n                                  metricValue: BrowserUtil.formatLoanTerm(loan),\n                                  metricDetail:\n                                      loan.interestRate.type !== 'fixed'\n                                          ? 'Variable rate loan'\n                                          : loan.interestRate.rate\n                                          ? `Fixed rate, ${NumberUtil.format(\n                                                loan.interestRate.rate,\n                                                'percent',\n                                                {\n                                                    signDisplay: 'auto',\n                                                    minimumFractionDigits: 2,\n                                                    maximumFractionDigits: 2,\n                                                }\n                                            )} interest annually`\n                                          : 'Fixed rate loan',\n                              }\n                            : undefined\n                    }\n                />\n\n                {!loan && (\n                    <div className=\"absolute inset-0 flex flex-col items-center justify-center w-full h-full text-center bg-black rounded bg-opacity-90 backdrop-blur-sm\">\n                        <div className=\"text-gray-50 text-base max-w-[400px]\">\n                            <p>This is where your loan details will show. </p>\n                            <p>\n                                <button\n                                    className=\"text-white hover:text-opacity-90\"\n                                    onClick={() => editAccount(account)}\n                                >\n                                    Add loan terms\n                                </button>{' '}\n                                to see both the chart and these metrics.\n                            </p>\n                        </div>\n                    </div>\n                )}\n            </div>\n\n            {showComingSoon && (\n                <div className=\"p-10 my-10\">\n                    <div className=\"flex flex-col items-center max-w-lg mx-auto text-center\">\n                        <img alt=\"Maybe\" width={74} height={60} src=\"/assets/maybe-gray.svg\" />\n                        <h4 className=\"mt-3 mb-2\">\n                            This view is kinda empty, but there&apos;s a reason for that\n                        </h4>\n                        <p className=\"text-base text-gray-50\">\n                            We are constantly reviewing and prioritizing your feedback, and we know\n                            you're itching to see some more details about your loan! Hold tight,\n                            this feature is on our roadmap and we're getting to it as quickly as we\n                            can!\n                        </p>\n                    </div>\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/loan-details/index.ts",
    "content": "export * from './LoanCard'\nexport * from './LoanDetail'\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/NetWorthInsightBadge.tsx",
    "content": "import { Badge } from '@maybe-finance/design-system'\nimport type { InsightState } from '../insights'\n\nexport default function NetWorthInsightBadge({ variant }: { variant: InsightState }) {\n    return variant === 'at-risk' ? (\n        <Badge variant=\"red\">At risk</Badge>\n    ) : variant === 'review' ? (\n        <Badge variant=\"warn\">Review</Badge>\n    ) : variant === 'healthy' ? (\n        <Badge variant=\"teal\">Healthy</Badge>\n    ) : variant === 'excessive' ? (\n        <Badge variant=\"warn\">Excessive</Badge>\n    ) : null\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/NetWorthInsightCard.tsx",
    "content": "import type { ClientType } from '@maybe-finance/client/shared'\nimport type { PropsWithChildren, ReactNode } from 'react'\nimport { Badge, LoadingPlaceholder, Tooltip } from '@maybe-finance/design-system'\nimport { RiQuestionLine } from 'react-icons/ri'\nimport classNames from 'classnames'\n\ntype InsightCardProps = PropsWithChildren<{\n    isLoading: boolean\n    status?: ClientType.MetricStatus\n    title: string\n    metricValue: string\n    metricDetail: ReactNode\n    className?: string\n    info?: ReactNode\n    infoTooltipClassName?: string\n    headerRight?: ReactNode\n    onClick?: () => void\n}>\n\nexport function NetWorthInsightCard({\n    isLoading,\n    status,\n    title,\n    metricValue,\n    metricDetail,\n    className,\n    info,\n    infoTooltipClassName,\n    headerRight,\n    onClick,\n}: InsightCardProps) {\n    return (\n        <div\n            className={classNames(\n                'flex flex-col bg-gray-800 rounded-lg w-full p-4',\n                onClick && 'hover:bg-gray-700 cursor-pointer',\n                className\n            )}\n            onClick={onClick}\n        >\n            <div className=\"grow flex items-center justify-between space-x-1.5\">\n                <div className=\"flex items-center\">\n                    <p className=\"text-base text-gray-100\">{title}</p>\n                    {info && (\n                        <Tooltip\n                            content={<div className=\"text-base text-gray-50\">{info}</div>}\n                            className={classNames(infoTooltipClassName, 'max-w-[350px]')}\n                        >\n                            <span>\n                                <RiQuestionLine className=\"w-5 h-5 text-gray-50 mx-1.5\" />\n                            </span>\n                        </Tooltip>\n                    )}\n                </div>\n                <div className=\"whitespace-nowrap\">\n                    {status === 'under-construction' && (\n                        <Badge children=\"Unavailable\" variant=\"gray\" />\n                    )}\n                    {status === 'coming-soon' && <Badge children=\"Soon\" variant=\"gray\" />}\n                    {status === 'active' && headerRight}\n                </div>\n            </div>\n\n            <div className=\"mt-4\">\n                {!status || status === 'under-construction' ? (\n                    <p className=\"text-gray-100 text-base\">\n                        We're currently fixing this to make sure we show you accurate figures.\n                    </p>\n                ) : (\n                    <LoadingPlaceholder isLoading={isLoading}>\n                        <h3 className=\"whitespace-nowrap\">{metricValue}</h3>\n                        <div className=\"mt-1 text-base text-gray-100\">{metricDetail}</div>\n                    </LoadingPlaceholder>\n                )}\n                {status === 'coming-soon' && (\n                    <div className=\"absolute -inset-1 bg-gray-800 bg-opacity-70 backdrop-blur-sm\" />\n                )}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/NetWorthInsightDetail.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport type { UseQueryResult } from '@tanstack/react-query'\nimport { type SharedType, NumberUtil } from '@maybe-finance/shared'\nimport {\n    type ClientType,\n    useAccountApi,\n    InsightPopout,\n    usePopoutContext,\n} from '@maybe-finance/client/shared'\nimport { NetWorthBreakdownSlider } from './breakdown-slider'\nimport { NetWorthBreakdownTable } from './breakdown-table'\nimport { NetWorthInsightCard } from './NetWorthInsightCard'\nimport { DateTime } from 'luxon'\nimport { Explainers } from '../insights'\n\ntype NetWorthInsightDetailProps = {\n    query: UseQueryResult<SharedType.UserInsights, unknown>\n}\n\nexport function NetWorthInsightDetail({ query }: NetWorthInsightDetailProps) {\n    const { open: openPopout } = usePopoutContext()\n\n    const { useAccountRollup } = useAccountApi()\n\n    const accountRollupQuery = useAccountRollup(\n        {\n            start: DateTime.now().toISODate(),\n            end: DateTime.now().toISODate(),\n        },\n        { enabled: true }\n    )\n\n    const hasAssets = !!accountRollupQuery.data?.find(({ key }) => key === 'asset')\n    const hasDebts = !!accountRollupQuery.data?.find(({ key }) => key === 'liability')\n\n    return (\n        <div className=\"flex flex-col\">\n            <h5 className=\"uppercase\">Assets</h5>\n            <BreakdownTabPanel classification=\"asset\" query={accountRollupQuery} status=\"active\">\n                <NetWorthInsightCard\n                    isLoading={query.isLoading}\n                    status=\"active\"\n                    title=\"Assets that work for you\"\n                    metricValue={NumberUtil.format(\n                        query.data?.assetSummary.yielding.amount,\n                        'currency',\n                        {\n                            minimumFractionDigits: 0,\n                            maximumFractionDigits: 0,\n                        }\n                    )}\n                    metricDetail={`${NumberUtil.format(\n                        query.data?.assetSummary.yielding.percentage,\n                        'percent',\n                        { signDisplay: 'auto' }\n                    )} of assets are adding to your net worth`}\n                    info=\"These are assets that you own that provide some sort of return in the form of interest or recurring payments.\"\n                    onClick={() =>\n                        openPopout(\n                            <InsightPopout>\n                                <Explainers.YieldingAssets />\n                            </InsightPopout>\n                        )\n                    }\n                />\n                <NetWorthInsightCard\n                    isLoading={query.isLoading}\n                    status=\"active\"\n                    title=\"Easy to cash in assets\"\n                    metricValue={NumberUtil.format(\n                        query.data?.assetSummary.liquid.amount,\n                        'currency',\n                        { minimumFractionDigits: 0, maximumFractionDigits: 0 }\n                    )}\n                    metricDetail={`${NumberUtil.format(\n                        query.data?.assetSummary.liquid.percentage,\n                        'percent',\n                        { signDisplay: 'auto' }\n                    )} of all assets`}\n                    info=\"These are assets you own that can easily be converted into spendable cash within a short period of time.\"\n                    onClick={() =>\n                        openPopout(\n                            <InsightPopout>\n                                <Explainers.LiquidAssets />\n                            </InsightPopout>\n                        )\n                    }\n                />\n                <NetWorthInsightCard\n                    isLoading={query.isLoading}\n                    status=\"active\"\n                    title=\"Hard to cash in assets\"\n                    metricValue={NumberUtil.format(\n                        query.data?.assetSummary.illiquid.amount,\n                        'currency',\n                        { minimumFractionDigits: 0, maximumFractionDigits: 0 }\n                    )}\n                    metricDetail={`${NumberUtil.format(\n                        query.data?.assetSummary.illiquid.percentage,\n                        'percent',\n                        { signDisplay: 'auto' }\n                    )} of all assets`}\n                    info=\"These are assets you own that will take a significant amount of time to convert into spendable cash.\"\n                    onClick={() =>\n                        openPopout(\n                            <InsightPopout>\n                                <Explainers.IlliquidAssets />\n                            </InsightPopout>\n                        )\n                    }\n                />\n            </BreakdownTabPanel>\n\n            <h5 className=\"mt-9 uppercase\">Debts</h5>\n            <BreakdownTabPanel\n                classification=\"liability\"\n                query={accountRollupQuery}\n                status=\"active\"\n            >\n                <NetWorthInsightCard\n                    isLoading={query.isLoading}\n                    status=\"active\"\n                    title=\"Total debt ratio\"\n                    metricValue={NumberUtil.format(query.data?.debtAsset.ratio, 'percent', {\n                        signDisplay: 'auto',\n                        maximumFractionDigits: 2,\n                    })}\n                    metricDetail=\"debt as a % of total assets\"\n                    info=\"This is the measure of your borrowing ability in relation to your assets.\"\n                    onClick={() =>\n                        openPopout(\n                            <InsightPopout>\n                                <Explainers.TotalDebtRatio />\n                            </InsightPopout>\n                        )\n                    }\n                />\n                <NetWorthInsightCard\n                    isLoading={query.isLoading}\n                    status=\"active\"\n                    title=\"Good debt\"\n                    metricValue={NumberUtil.format(\n                        query.data?.debtSummary.good.percentage,\n                        'percent',\n                        { signDisplay: 'auto', maximumFractionDigits: 2 }\n                    )}\n                    metricDetail=\"of debt is building wealth\"\n                    info=\"This is debt that grows future wealth such as a student loan, or builds equity in a productive asset such as a mortgage.\"\n                    onClick={() =>\n                        openPopout(\n                            <InsightPopout>\n                                <Explainers.GoodDebt />\n                            </InsightPopout>\n                        )\n                    }\n                />\n                <NetWorthInsightCard\n                    isLoading={query.isLoading}\n                    status=\"active\"\n                    title=\"Bad debt\"\n                    metricValue={NumberUtil.format(\n                        query.data?.debtSummary.bad.percentage,\n                        'percent',\n                        { signDisplay: 'auto', maximumFractionDigits: 2 }\n                    )}\n                    metricDetail=\"of debt is not building wealth\"\n                    info=\"Bad debt is debt that provides no future value to you such as a personal loan or credit card debt.\"\n                    onClick={() =>\n                        openPopout(\n                            <InsightPopout>\n                                <Explainers.BadDebt />\n                            </InsightPopout>\n                        )\n                    }\n                />\n            </BreakdownTabPanel>\n        </div>\n    )\n}\n\nfunction BreakdownTabPanel({\n    query,\n    classification,\n    status,\n    children,\n}: PropsWithChildren<{\n    classification: SharedType.AccountClassification\n    query: UseQueryResult<SharedType.AccountRollup>\n    status?: ClientType.MetricStatus\n}>) {\n    const rollup = query.data?.find(({ key }) => key === classification)\n    if (rollup) {\n        rollup.items.sort((a, b) =>\n            b.balances.data[b.balances.data.length - 1].balance\n                .minus(a.balances.data[a.balances.data.length - 1].balance)\n                .toNumber()\n        )\n    }\n\n    return (\n        <div className=\"space-y-4\">\n            <div>\n                <ComingSoonBlur metricStatus={status}>\n                    {query.isError ? (\n                        <p className=\"text-gray-100\">\n                            Failed to load {classification === 'asset' ? 'asset' : 'debt'} breakdown\n                        </p>\n                    ) : !query.isLoading && !rollup ? (\n                        <p className=\"text-gray-100\">\n                            No {classification === 'asset' ? 'assets' : 'debts'} found\n                        </p>\n                    ) : (\n                        <div>\n                            <NetWorthBreakdownSlider isLoading={query.isLoading} rollup={rollup} />\n                            <div className=\"mt-4 mb-7 grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3\">\n                                {children}\n                            </div>\n                            <NetWorthBreakdownTable isLoading={query.isLoading} rollup={rollup} />\n                        </div>\n                    )}\n                </ComingSoonBlur>\n            </div>\n        </div>\n    )\n}\n\nfunction ComingSoonBlur({\n    children,\n    metricStatus,\n}: PropsWithChildren<{ metricStatus?: ClientType.MetricStatus }>) {\n    if (metricStatus === 'active') return children as JSX.Element\n\n    return (\n        <div className=\"relative\">\n            {children}\n            <div className=\"flex items-center justify-center absolute top-0 left-0 w-full h-full bg-black bg-opacity-70 backdrop-blur-sm\">\n                <div className=\"text-center w-2/3 -translate-y-12\">\n                    {metricStatus === 'coming-soon' && (\n                        <>\n                            <h4>Coming soon!</h4>\n                            <p className=\"text-gray-50 text-base\">\n                                We're still working on this section, but instead of just telling\n                                you, here's a blurred out teaser of what's coming soon.\n                            </p>\n                        </>\n                    )}\n\n                    {!metricStatus ||\n                        (metricStatus === 'under-construction' && (\n                            <>\n                                <h4>Unavailable</h4>\n                                <p className=\"text-gray-50 text-base\">\n                                    We're currently fixing this to make sure we show you accurate\n                                    figures.\n                                </p>\n                            </>\n                        ))}\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/NetWorthInsightStateAxis.tsx",
    "content": "import classNames from 'classnames'\nimport { Fragment } from 'react'\nimport type { InsightState } from '../insights'\nimport { InsightStateNames, InsightStateColorClasses } from '../insights'\n\nexport type NetWorthInsightStateAxisProps = {\n    className?: string\n    steps: (InsightState | string)[]\n}\n\nexport function NetWorthInsightStateAxis({ className, steps }: NetWorthInsightStateAxisProps) {\n    return (\n        <div className={classNames('flex justify-center', className)}>\n            <div\n                className={classNames(\n                    'inline-flex items-center gap-1.5 py-1 px-3 font-medium whitespace-nowrap',\n                    'bg-gradient-to-r from-transparent via-gray-600 to-transparent'\n                )}\n            >\n                {steps.map((step, index) => (\n                    <Fragment key={index}>\n                        {Object.keys(InsightStateNames).includes(step) ? (\n                            <span className={InsightStateColorClasses[step as InsightState]}>\n                                {InsightStateNames[step as InsightState]}\n                            </span>\n                        ) : (\n                            <span className=\"text-gray-100\">{step}</span>\n                        )}\n                        {index < steps.length - 1 && (\n                            <>\n                                <span className=\"w-0.5 h-0.5 rounded-full bg-gray-300\">&nbsp;</span>\n                                <span className=\"w-0.5 h-0.5 rounded-full bg-gray-300\">&nbsp;</span>\n                            </>\n                        )}\n                    </Fragment>\n                ))}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/NetWorthPrimaryCardGroup.tsx",
    "content": "import { InsightPopout, usePopoutContext } from '@maybe-finance/client/shared'\nimport { type SharedType, NumberUtil } from '@maybe-finance/shared'\nimport type { UseQueryResult } from '@tanstack/react-query'\nimport { RiArrowLeftDownLine, RiArrowRightUpLine } from 'react-icons/ri'\nimport { NetWorthInsightCard } from './NetWorthInsightCard'\nimport { Listbox } from '@maybe-finance/design-system'\nimport { useMemo, useState } from 'react'\nimport classNames from 'classnames'\nimport Decimal from 'decimal.js'\nimport { IncomeDebtDialog } from './income-debt'\nimport NetWorthInsightBadge from './NetWorthInsightBadge'\nimport { NetWorthInsightStateAxis } from '.'\nimport { incomePayingDebtState, safetyNetState, Explainers } from '../insights'\n\ntype NetWorthPrimaryCardGroupProps = {\n    query: UseQueryResult<SharedType.UserInsights, unknown>\n}\n\nexport function NetWorthPrimaryCardGroup({ query }: NetWorthPrimaryCardGroupProps) {\n    const { open: openPopout } = usePopoutContext()\n\n    const [incomeDebtModalOpen, setIncomeDebtModalOpen] = useState(false)\n    const [trendPeriod, setTrendPeriod] = useState<'Yearly' | 'Monthly' | 'Weekly'>('Yearly')\n\n    const trendValue = useMemo(() => {\n        const formatResponse = (data: SharedType.Trend, period: string) => ({\n            amount: NumberUtil.format(data.amount, 'short-currency'),\n            percent: NumberUtil.format(data.percentage, 'percent', { maximumFractionDigits: 2 }),\n            label: ` ${data.direction === 'down' ? 'lost' : 'added'} in the past ${period}`,\n            textClass: data.direction === 'down' ? 'text-red' : 'text-teal',\n        })\n\n        if (query.error || !query.data) {\n            return { amount: '', percent: '', label: '', textClass: 'text-teal' }\n        }\n\n        const { yearly, monthly, weekly } = query.data.netWorth\n\n        switch (trendPeriod) {\n            case 'Yearly':\n                return formatResponse(yearly, 'year')\n            case 'Monthly':\n                return formatResponse(monthly, 'month')\n            case 'Weekly':\n                return formatResponse(weekly, 'week')\n            default:\n                throw new Error('Invalid period specified')\n        }\n    }, [trendPeriod, query])\n\n    const safetyNet = useMemo<SharedType.UserInsights['safetyNet'] | null>(() => {\n        if (query.error || !query.data) {\n            return null\n        }\n\n        return {\n            months: query.data.safetyNet.months.round(),\n            spending: query.data.transactionSummary.expenses,\n        }\n    }, [query])\n\n    const debtIncome = useMemo<SharedType.UserInsights['debtIncome'] | null>(() => {\n        if (query.error || !query.data) {\n            return null\n        }\n\n        return query.data.debtIncome\n    }, [query])\n\n    const safetyNetMonths = safetyNet?.months ?? new Decimal(0)\n\n    return (\n        <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3\">\n            <NetWorthInsightCard\n                isLoading={query.isLoading}\n                status={query.error ? 'under-construction' : 'active'}\n                title=\"Net worth trend\"\n                metricValue={trendValue.percent}\n                metricDetail={\n                    <>\n                        <span className={classNames('font-semibold', trendValue.textClass)}>\n                            {trendValue.amount}\n                        </span>\n                        {trendValue.label}\n                    </>\n                }\n                info=\"This is an indicator of how your net worth is changing over time.\"\n                headerRight={\n                    <Listbox\n                        onChange={setTrendPeriod}\n                        value={trendPeriod}\n                        onClick={(e) => e.stopPropagation()}\n                    >\n                        <Listbox.Button size=\"small\" onClick={(e) => e.stopPropagation()}>\n                            {trendPeriod}\n                        </Listbox.Button>\n                        <Listbox.Options>\n                            <Listbox.Option value=\"Yearly\">Yearly</Listbox.Option>\n                            <Listbox.Option value=\"Monthly\">Monthly</Listbox.Option>\n                            <Listbox.Option value=\"Weekly\">Weekly</Listbox.Option>\n                        </Listbox.Options>\n                    </Listbox>\n                }\n                onClick={() =>\n                    openPopout(\n                        <InsightPopout>\n                            <Explainers.NetWorthTrend />\n                        </InsightPopout>\n                    )\n                }\n            />\n            <NetWorthInsightCard\n                isLoading={query.isLoading}\n                status=\"active\"\n                title=\"Safety net\"\n                metricValue={`${\n                    safetyNetMonths.lt(24)\n                        ? safetyNetMonths.toFixed() + ' month'\n                        : safetyNetMonths.lte(120)\n                        ? safetyNetMonths.divToInt(12).toFixed() + ' year'\n                        : 'Over 10 year'\n                }${safetyNetMonths.equals(1) ? '' : 's'}`}\n                metricDetail={`Spending ${NumberUtil.format(safetyNet?.spending, 'short-currency', {\n                    signDisplay: 'auto',\n                })} monthly`}\n                info={\n                    <>\n                        This is the number of months you could pay expenses if you were to only rely\n                        on your emergency funds.\n                        <NetWorthInsightStateAxis\n                            className=\"mt-3 mb-2\"\n                            steps={['at-risk', '3M', 'review', '6M', 'healthy', '12M', 'excessive']}\n                        />\n                    </>\n                }\n                infoTooltipClassName=\"!max-w-[450px]\"\n                headerRight={\n                    safetyNet && <NetWorthInsightBadge variant={safetyNetState(safetyNet.months)} />\n                }\n                onClick={() =>\n                    openPopout(\n                        <InsightPopout>\n                            <Explainers.SafetyNet\n                                defaultState={\n                                    safetyNet ? safetyNetState(safetyNet.months) : 'healthy'\n                                }\n                            />\n                        </InsightPopout>\n                    )\n                }\n            />\n            <NetWorthInsightCard\n                isLoading={query.isLoading}\n                status=\"active\"\n                title=\"Income paying debt\"\n                metricValue={NumberUtil.format(debtIncome?.ratio, 'percent', {\n                    signDisplay: 'auto',\n                    maximumFractionDigits: 2,\n                })}\n                metricDetail={\n                    <div className=\"flex items-center space-x-2\">\n                        <RiArrowRightUpLine className=\"w-5 h-5 text-teal\" />\n                        <span>{`${NumberUtil.format(\n                            debtIncome?.income,\n                            'short-currency'\n                        )}/mo`}</span>\n                        <RiArrowLeftDownLine className=\"w-5 h-5 text-red\" />\n                        <span>{`${NumberUtil.format(debtIncome?.debt, 'short-currency')}/mo`}</span>\n                        <span\n                            role=\"button\"\n                            className=\"ml-1 underline cursor-pointer\"\n                            onClick={(e) => {\n                                e.stopPropagation()\n                                setIncomeDebtModalOpen(true)\n                            }}\n                        >\n                            Edit\n                        </span>\n                        {debtIncome && (\n                            <IncomeDebtDialog\n                                isOpen={incomeDebtModalOpen}\n                                onClose={() => setIncomeDebtModalOpen(false)}\n                                data={debtIncome}\n                            />\n                        )}\n                    </div>\n                }\n                info={\n                    <>\n                        This is how much of your income is going towards paying debt.\n                        <NetWorthInsightStateAxis\n                            className=\"mx-auto w-auto mt-3 mb-2\"\n                            steps={['28%', 'review', '36%', 'at-risk']}\n                        />\n                    </>\n                }\n                infoTooltipClassName=\"!max-w-[300px]\"\n                headerRight={\n                    debtIncome &&\n                    incomePayingDebtState(debtIncome.ratio) !== 'healthy' && (\n                        <NetWorthInsightBadge variant={incomePayingDebtState(debtIncome.ratio)} />\n                    )\n                }\n                onClick={() =>\n                    openPopout(\n                        <InsightPopout>\n                            <Explainers.IncomePayingDebt\n                                defaultState={\n                                    debtIncome ? incomePayingDebtState(debtIncome.ratio) : 'healthy'\n                                }\n                            />\n                        </InsightPopout>\n                    )\n                }\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/breakdown-slider/NetWorthBreakdownSlider.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { useMemo, useState } from 'react'\nimport classNames from 'classnames'\nimport { BrowserUtil } from '@maybe-finance/client/shared'\nimport { LoadingPlaceholder } from '@maybe-finance/design-system'\nimport { Prisma } from '@prisma/client'\n\ninterface SliderProps {\n    isLoading: boolean\n    rollup?: SharedType.AccountRollup[0]\n}\n\nconst placeholderBalances: SharedType.AccountRollupTimeSeries = {\n    interval: 'days',\n    start: '',\n    end: '',\n    data: [\n        {\n            date: '',\n            balance: new Prisma.Decimal(1),\n            rollupPct: new Prisma.Decimal(1),\n            totalPct: new Prisma.Decimal(1),\n        },\n    ],\n}\n\nexport function NetWorthBreakdownSlider({ isLoading, rollup }: SliderProps) {\n    const [hoveredItemIndex, setHoveredItemIndex] = useState<number | null>(null)\n\n    const data: SharedType.AccountRollup[0] = useMemo(\n        () =>\n            isLoading || !rollup\n                ? {\n                      key: 'asset',\n                      title: '',\n                      balances: placeholderBalances,\n                      items: [\n                          {\n                              key: 'cash',\n                              title: '',\n                              items: [],\n                              balances: placeholderBalances,\n                          },\n                      ],\n                  }\n                : rollup,\n        [isLoading, rollup]\n    )\n\n    return (\n        <LoadingPlaceholder isLoading={isLoading} className=\"!block\">\n            <div className=\"flex flex-col gap-2\">\n                <div className=\"bg-gray-800 w-full p-2 rounded-lg flex-nowrap flex\">\n                    {data.items.map((item, index) => (\n                        <div\n                            key={`${item.title}-${index}`}\n                            className={classNames(\n                                'inline-block h-3 first:rounded-l last:rounded-r bg-current transition-opacity',\n                                BrowserUtil.getCategoryColorClassName(item.key),\n                                hoveredItemIndex != null &&\n                                    hoveredItemIndex !== index &&\n                                    'opacity-30'\n                            )}\n                            style={{\n                                width: `${item.balances.data[\n                                    item.balances.data.length - 1\n                                ].rollupPct\n                                    .times(100)\n                                    .toNumber()}%`,\n                            }}\n                        />\n                    ))}\n                </div>\n                <div className=\"flex flex-wrap\">\n                    {data.items.map((item, index) => (\n                        <div\n                            key={`${item.title}-${index}`}\n                            className={classNames(\n                                'flex items-center justify-between rounded-lg py-1 px-2 cursor-default transition',\n                                hoveredItemIndex === index && 'bg-gray-800'\n                            )}\n                            onMouseEnter={() => setHoveredItemIndex(index)}\n                            onMouseLeave={() => setHoveredItemIndex(null)}\n                        >\n                            <span\n                                className={classNames(\n                                    'w-[10px] h-[10px] rounded-full mr-2 bg-current',\n                                    BrowserUtil.getCategoryColorClassName(item.key)\n                                )}\n                            ></span>\n                            <span className=\"text-sm mr-2\">{item.title}</span>\n                            <span className=\"text-sm text-gray-100\">\n                                {NumberUtil.format(\n                                    item.balances.data[item.balances.data.length - 1].rollupPct,\n                                    'percent',\n                                    {\n                                        signDisplay: 'auto',\n                                        maximumFractionDigits: 2,\n                                    }\n                                )}\n                            </span>\n                        </div>\n                    ))}\n                </div>\n            </div>\n        </LoadingPlaceholder>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/breakdown-slider/index.ts",
    "content": "export * from './NetWorthBreakdownSlider'\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/breakdown-table/BreakdownTableIcon.tsx",
    "content": "import type { IconType } from 'react-icons'\nimport classNames from 'classnames'\n\nexport function BreakdownTableIcon({ className, Icon }: { className: string; Icon: IconType }) {\n    return (\n        <div\n            className={classNames(\n                'relative flex items-center justify-center rounded-xl w-12 h-12 overflow-hidden',\n                className\n            )}\n        >\n            {/* Use absolute element for background because we can't use bg-opacity with bg-current */}\n            <div className=\"absolute w-full h-full bg-current opacity-10\"></div>\n\n            <Icon className=\"w-6 h-6\" />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/breakdown-table/NetWorthBreakdownTable.tsx",
    "content": "import type { ColumnDef, Row, ExpandedState } from '@tanstack/react-table'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { Fragment, useMemo, useState } from 'react'\nimport { Prisma } from '@prisma/client'\nimport cn from 'classnames'\nimport { RiArrowDownSLine } from 'react-icons/ri'\nimport {\n    useReactTable,\n    getCoreRowModel,\n    getExpandedRowModel,\n    flexRender,\n} from '@tanstack/react-table'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { LoadingPlaceholder } from '@maybe-finance/design-system'\nimport { BreakdownTableIcon } from './BreakdownTableIcon'\nimport { BrowserUtil } from '@maybe-finance/client/shared'\n\ninterface NetWorthBreakdownTableProps {\n    isLoading: boolean\n    rollup?: SharedType.AccountRollup[0]\n}\n\ntype RowData =\n    | SharedType.AccountRollup[0]['items'][0]\n    | SharedType.AccountRollup[0]['items'][0]['items'][0]\n\nexport function NetWorthBreakdownTable({ isLoading, rollup }: NetWorthBreakdownTableProps) {\n    const getLastBalance = (row: Row<RowData>) =>\n        row.original?.balances.data[row.original.balances.data.length - 1]\n\n    const getItems = (row: Row<RowData>) =>\n        row.original && 'items' in row.original ? row.original.items : null\n\n    const columns = useMemo<ColumnDef<RowData>[]>(\n        () => [\n            {\n                id: 'type',\n                header: 'Type',\n                cell: ({ row }) => {\n                    const items = getItems(row)\n                    return (\n                        <div className=\"w-full h-full flex gap-4 text-base\">\n                            {'key' in row.original! && (\n                                <BreakdownTableIcon\n                                    className={BrowserUtil.getCategoryColorClassName(\n                                        row.original.key\n                                    )}\n                                    Icon={BrowserUtil.getCategoryIcon(row.original.key)}\n                                />\n                            )}\n                            <div>\n                                <p>\n                                    {'name' in row.original!\n                                        ? row.original.name\n                                        : row.original!.title}\n                                </p>\n\n                                <p className=\"text-gray-100 text-base\">\n                                    {items &&\n                                        items.length &&\n                                        `${items.length} account${items.length !== 1 ? 's' : ''}`}\n                                    {'connection' in row.original! && row.original.connection && (\n                                        <>\n                                            {row.original.connection.name}\n                                            {row.original.mask != null && (\n                                                <>\n                                                    &nbsp;&#183;&#183;&#183;&#183;{' '}\n                                                    {row.original.mask}\n                                                </>\n                                            )}\n                                        </>\n                                    )}\n                                </p>\n                            </div>\n                        </div>\n                    )\n                },\n            },\n            {\n                id: 'allocation',\n                header: 'Allocation',\n                cell: ({ row }) => {\n                    const lastBalance = getLastBalance(row)!\n                    return (\n                        <div className=\"text-base\">\n                            <p className=\"font-medium\">\n                                {NumberUtil.format(lastBalance.rollupPct, 'percent', {\n                                    signDisplay: 'auto',\n                                    maximumFractionDigits: 2,\n                                })}\n                            </p>\n                            {'name' in row.original! && (\n                                <p className=\"font-normal text-gray-100\">\n                                    {NumberUtil.format(lastBalance.totalPct, 'percent', {\n                                        signDisplay: 'auto',\n                                        maximumFractionDigits: 2,\n                                    })}{' '}\n                                    of total\n                                </p>\n                            )}\n                        </div>\n                    )\n                },\n            },\n            {\n                id: 'amount',\n                header: 'Amount',\n                cell: ({ row }) => (\n                    <p className=\"text-base font-medium\">\n                        {NumberUtil.format(getLastBalance(row)!.balance, 'currency', {\n                            minimumFractionDigits: 0,\n                            maximumFractionDigits: 0,\n                        })}\n                    </p>\n                ),\n            },\n            {\n                id: 'actions',\n                header: '',\n                cell: ({ row }) =>\n                    row.getCanExpand() && (\n                        <div className=\"flex items-center justify-end\">\n                            <RiArrowDownSLine\n                                className={cn(\n                                    'w-6 h-6 ml-2 mr-1 transition-transform',\n                                    row.getIsExpanded() && 'transform scale-y-[-1]'\n                                )}\n                            />\n                        </div>\n                    ),\n            },\n        ],\n        []\n    )\n\n    const data = useMemo<RowData[]>(\n        () =>\n            isLoading\n                ? [\n                      {\n                          key: 'cash',\n                          title: '',\n                          items: [],\n                          balances: {\n                              interval: 'days',\n                              start: '',\n                              end: '',\n                              data: [\n                                  {\n                                      date: '',\n                                      balance: new Prisma.Decimal(1),\n                                      rollupPct: new Prisma.Decimal(1),\n                                      totalPct: new Prisma.Decimal(1),\n                                  },\n                              ],\n                          },\n                      },\n                  ]\n                : rollup?.items ?? [],\n        [isLoading, rollup]\n    )\n\n    const [expanded, setExpanded] = useState<ExpandedState>({})\n\n    const table = useReactTable({\n        data,\n        columns,\n        state: {\n            expanded,\n        },\n        onExpandedChange: setExpanded,\n        getCoreRowModel: getCoreRowModel(),\n        getExpandedRowModel: getExpandedRowModel(),\n        getSubRows: (d) => ('items' in d ? d.items : []),\n    })\n\n    const rows = table.getRowModel().rows\n\n    return (\n        <LoadingPlaceholder isLoading={isLoading}>\n            <table\n                className={cn(\n                    'min-w-full border-collapse grid',\n                    'grid-cols-[2fr,1fr,1fr,1fr]', // horizontal overflow\n                    'custom-gray-scroll'\n                )}\n            >\n                <thead className=\"contents\">\n                    {table.getHeaderGroups().map((headerGroup) => (\n                        <tr key={headerGroup.id} className=\"contents\">\n                            {headerGroup.headers.map((header) => (\n                                <th\n                                    key={header.id}\n                                    colSpan={header.colSpan}\n                                    className=\"text-sm font-medium text-gray-100 self-center first:text-left text-right pb-3 first:pl-4 last:pr-4\"\n                                >\n                                    {!header.isPlaceholder &&\n                                        flexRender(\n                                            header.column.columnDef.header,\n                                            header.getContext()\n                                        )}\n                                </th>\n                            ))}\n                        </tr>\n                    ))}\n                </thead>\n                <tbody className=\"contents\">\n                    {rows.map((row, rowIndex) => {\n                        const isLastChildRow =\n                            row.depth > 0 &&\n                            (rowIndex === rows.length - 1 || rows[rowIndex + 1].depth === 0)\n\n                        const isParentRowNext =\n                            rowIndex < rows.length - 1 && rows[rowIndex + 1].depth === 0\n\n                        return (\n                            <Fragment key={row.id}>\n                                <tr\n                                    className=\"contents group transition\"\n                                    role={row.getCanExpand() ? 'button' : undefined}\n                                    onClick={\n                                        row.getCanExpand()\n                                            ? row.getToggleExpandedHandler()\n                                            : undefined\n                                    }\n                                >\n                                    {row.getVisibleCells().map((cell) => (\n                                        <td\n                                            key={cell.id}\n                                            className={cn(\n                                                'group truncate p-0 font-normal text-right first:text-left first:pl-4 last:pr-4',\n                                                'group-hover:bg-gray-800 transition',\n                                                row.getIsExpanded()\n                                                    ? 'bg-gray-800 first:rounded-tl-lg last:rounded-tr-lg'\n                                                    : row.depth === 0 &&\n                                                          'first:rounded-l-lg last:rounded-r-lg',\n                                                row.depth > 0\n                                                    ? 'first:pl-4 py-0 bg-gray-800'\n                                                    : 'py-4'\n                                            )}\n                                        >\n                                            <div\n                                                className={cn(\n                                                    'w-full h-full',\n                                                    row.depth > 0 && [\n                                                        'py-4 bg-gray-700 group-first:pl-4 group-last:pr-4',\n                                                        row.index === 0 &&\n                                                            'group-first:rounded-tl-lg group-last:rounded-tr-lg',\n                                                        isLastChildRow &&\n                                                            'group-first:rounded-bl-lg group-last:rounded-br-lg',\n                                                    ]\n                                                )}\n                                            >\n                                                {flexRender(\n                                                    cell.column.columnDef.cell,\n                                                    cell.getContext()\n                                                )}\n                                            </div>\n                                        </td>\n                                    ))}\n                                </tr>\n\n                                {/* Fake parent padding below last child row */}\n                                {isLastChildRow && (\n                                    <tr className=\"contents\" role=\"presentation\">\n                                        <td className=\"col-span-4 h-4 bg-gray-800 rounded-b-lg\"></td>\n                                    </tr>\n                                )}\n\n                                {/* Gap between parent rows */}\n                                {isParentRowNext && (\n                                    <tr className=\"contents\" role=\"presentation\">\n                                        <td className=\"col-span-4 h-2\"></td>\n                                    </tr>\n                                )}\n                            </Fragment>\n                        )\n                    })}\n                </tbody>\n            </table>\n        </LoadingPlaceholder>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/breakdown-table/index.ts",
    "content": "export * from './NetWorthBreakdownTable'\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/income-debt/IncomeDebtBlock.tsx",
    "content": "import { Button, InputCurrency, Tooltip } from '@maybe-finance/design-system'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { Prisma } from '@prisma/client'\nimport classNames from 'classnames'\nimport { useCallback, useMemo, useState } from 'react'\nimport {\n    RiArrowGoBackLine,\n    RiArrowLeftDownLine,\n    RiArrowRightUpLine,\n    RiCheckFill,\n    RiCloseFill,\n    RiPencilLine,\n    RiQuestionLine,\n} from 'react-icons/ri'\n\nexport interface IncomeDebtBlock {\n    variant: 'Income' | 'Debt'\n    value: Prisma.Decimal\n    calculatedValue: Prisma.Decimal\n    onChange: (value: Prisma.Decimal) => void\n}\nexport function IncomeDebtBlock({\n    variant,\n    value: valueProp,\n    calculatedValue,\n    onChange,\n}: IncomeDebtBlock) {\n    const [value, setValue] = useState(valueProp)\n    const [isEditing, setIsEditing] = useState(false)\n\n    const formattedValue = useMemo(() => NumberUtil.format(value, 'currency', {}), [value])\n\n    const confirmEdit = useCallback(() => {\n        onChange(value)\n        setIsEditing(false)\n    }, [onChange, value])\n\n    const revertManual = useCallback(() => {\n        setValue(calculatedValue)\n        onChange(calculatedValue)\n    }, [calculatedValue, onChange])\n\n    return (\n        <div\n            className={classNames(\n                'my-3 p-3 rounded-lg bg-gray-600 text-gray-100',\n                variant === 'Income' && 'from-teal-500/5 to-gray-800/5 bg-gradient-to-t',\n                variant === 'Debt' && 'from-red-500/5 to-gray-800/5 bg-gradient-to-t'\n            )}\n        >\n            <div className=\"flex items-center\">\n                {variant === 'Income' && (\n                    <>\n                        <RiArrowRightUpLine className=\"w-5 h-5 text-teal mr-1\" />\n                        <span>Income</span>\n                        <Tooltip content=\"Your monthly income\" className=\"max-w-[350px]\">\n                            <span>\n                                <RiQuestionLine className=\"w-4 h-4 text-gray-100 mx-1.5\" />\n                            </span>\n                        </Tooltip>\n                    </>\n                )}\n\n                {variant === 'Debt' && (\n                    <>\n                        <RiArrowLeftDownLine className=\"w-5 h-5 text-red mr-1\" />\n                        <span className=\"text-gray-100\">Debt payments</span>\n                        <Tooltip content=\"Your monthly debt payments\" className=\"max-w-[350px]\">\n                            <span>\n                                <RiQuestionLine className=\"w-4 h-4 text-gray-100 mx-1.5\" />\n                            </span>\n                        </Tooltip>\n                    </>\n                )}\n\n                <div className=\"grow flex justify-end space-x-1\">\n                    {isEditing ? (\n                        <>\n                            <Tooltip content=\"Cancel\">\n                                <Button\n                                    variant=\"icon\"\n                                    onClick={() => {\n                                        setValue(valueProp)\n                                        setIsEditing(false)\n                                    }}\n                                >\n                                    <RiCloseFill className=\"w-4 h-4\" />\n                                </Button>\n                            </Tooltip>\n                            <Tooltip content=\"Confirm or press Enter ⏎\">\n                                <Button variant=\"icon\" onClick={confirmEdit}>\n                                    <RiCheckFill className=\"w-4 h-4\" />\n                                </Button>\n                            </Tooltip>\n                        </>\n                    ) : (\n                        <>\n                            {!valueProp.equals(calculatedValue) && (\n                                <Tooltip content=\"Revert to non-manual value\">\n                                    <Button variant=\"icon\" onClick={revertManual}>\n                                        <RiArrowGoBackLine className=\"w-4 h-4\" />\n                                    </Button>\n                                </Tooltip>\n                            )}\n                            <Tooltip content={`Edit ${variant === 'Income' ? 'income' : 'debt'}`}>\n                                <Button variant=\"icon\" onClick={() => setIsEditing(true)}>\n                                    <RiPencilLine className=\"w-4 h-4\" />\n                                </Button>\n                            </Tooltip>\n                        </>\n                    )}\n                </div>\n            </div>\n\n            <div\n                className={classNames(\n                    'h-12 flex items-end',\n                    variant === 'Income' && 'text-teal',\n                    variant === 'Debt' && 'text-red'\n                )}\n            >\n                {isEditing ? (\n                    <InputCurrency\n                        variant={variant === 'Income' ? 'positive' : 'negative'}\n                        autoFocus\n                        value={+value.toFixed(2)}\n                        onChange={(v) => setValue(new Prisma.Decimal(v ?? 0))}\n                        onKeyUp={(e) => e.key === 'Enter' && confirmEdit()}\n                    />\n                ) : (\n                    <>\n                        <h3>{formattedValue.split('.')[0]}</h3>\n                        <h5 className=\"mb-0.5\">\n                            {formattedValue.split('.')[1] ? `.${formattedValue.split('.')[1]}` : ''}\n                        </h5>\n                    </>\n                )}\n            </div>\n\n            <p className=\"text-base\">every month</p>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/income-debt/IncomeDebtDialog.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { IncomeDebtBlock } from './IncomeDebtBlock'\nimport { Button, Dialog } from '@maybe-finance/design-system'\nimport { useMemo, useState } from 'react'\nimport { useUserApi } from '@maybe-finance/client/shared'\n\nexport interface IncomeDebtDialogProps {\n    isOpen: boolean\n    onClose: () => void\n    data: SharedType.UserInsights['debtIncome']\n}\n\nexport function IncomeDebtDialog({ isOpen, onClose, data }: IncomeDebtDialogProps) {\n    const [income, setIncome] = useState(data.income)\n    const [debt, setDebt] = useState(data.debt)\n\n    const isDirty = useMemo(\n        () => !income.equals(data.income) || !debt.equals(data.debt),\n        [income, debt, data]\n    )\n\n    const { useUpdateProfile } = useUserApi()\n    const updateUser = useUpdateProfile()\n\n    return (\n        <Dialog isOpen={isOpen} onClose={onClose} size=\"md\">\n            <Dialog.Title>Edit income and debt</Dialog.Title>\n            <Dialog.Content>\n                <p className=\"text-base text-gray-50\">\n                    Here&apos;s your monthly income and debt repayments. We use these to see how\n                    much of your income is contributing to paying off any debt.\n                </p>\n\n                <IncomeDebtBlock\n                    variant=\"Income\"\n                    value={income}\n                    calculatedValue={data.calculated.income}\n                    onChange={setIncome}\n                />\n                <IncomeDebtBlock\n                    variant=\"Debt\"\n                    value={debt}\n                    calculatedValue={data.calculated.debt}\n                    onChange={setDebt}\n                />\n                <Button\n                    className=\"mt-1\"\n                    fullWidth\n                    variant={isDirty ? 'primary' : 'secondary'}\n                    disabled={!isDirty || updateUser.isLoading}\n                    onClick={async () => {\n                        await updateUser.mutateAsync({\n                            monthlyIncomeUser: income.equals(data.calculated.income)\n                                ? null\n                                : income.toNumber(),\n                            monthlyDebtUser: debt.equals(data.calculated.debt)\n                                ? null\n                                : debt.toNumber(),\n                        })\n                        onClose()\n                    }}\n                >\n                    Update\n                </Button>\n            </Dialog.Content>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/income-debt/index.ts",
    "content": "export * from './IncomeDebtDialog'\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/index.ts",
    "content": "export * from './NetWorthInsightDetail'\nexport * from './NetWorthInsightStateAxis'\nexport * from './NetWorthPrimaryCardGroup'\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/safety-net/SafetyNetDialog.tsx",
    "content": "import type { Prisma } from '@prisma/client'\nimport { Dialog } from '@maybe-finance/design-system'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { useState } from 'react'\nimport { RiCloseFill } from 'react-icons/ri'\nimport SafetyNetOpportunityCost from './SafetyNetOpportunityCost'\nimport SliderBlock from './SliderBlock'\n\ntype SafetyNetDialogProps = {\n    isOpen: boolean\n    onClose: () => void\n    monthValue: Prisma.Decimal\n    liquidAssets: Prisma.Decimal\n}\n\nexport default function SafetyNetDialog({\n    isOpen,\n    onClose,\n    monthValue,\n    liquidAssets,\n}: SafetyNetDialogProps) {\n    const [safetyNetMonths, setSafetyNetMonths] = useState(monthValue.toNumber())\n    const [potentialReturns, setPotentialReturns] = useState(8)\n    const [costLiving, setCostLiving] = useState(3)\n\n    return (\n        <Dialog isOpen={isOpen} onClose={onClose} showCloseButton={false} size=\"lg\">\n            <Dialog.Content>\n                <div className=\"flex items-center justify-between mb-2\">\n                    <h6 className=\"uppercase text-gray-100\">tip</h6>\n                    <span className=\"cursor-pointer\" onClick={onClose}>\n                        <RiCloseFill className=\"w-5 h-5 text-gray-50 hover:opacity-80\" />\n                    </span>\n                </div>\n\n                <h4>Make that extra cash in hand work for you</h4>\n                <p className=\"text-base text-gray-50\">\n                    While{' '}\n                    <span className=\"text-white font-medium\">{monthValue.toFixed()} months</span> of\n                    emergency funds is very healthy, too much cash on hand can be costly since it's\n                    not working for you and may be potentially losing its value in an environment\n                    where cost of living is rising.\n                </p>\n\n                <SliderBlock\n                    value={safetyNetMonths}\n                    onChange={setSafetyNetMonths}\n                    minValue={1}\n                    maxValue={48}\n                    formatFn={(months: number, minMonths?: number, maxMonths?: number) => {\n                        if (maxMonths && months >= maxMonths) {\n                            return `${months}+ months`\n                        }\n\n                        return `${months} month${months !== 1 ? 's' : ''}`\n                    }}\n                    title=\"Safety net\"\n                    info=\"This represents how many months you can survive on no additional income based on how much you spend right now.\"\n                />\n\n                <div className=\"flex flex-col sm:flex-row items-center gap-3\">\n                    <SliderBlock\n                        className=\"basis-1/2\"\n                        value={potentialReturns}\n                        onChange={setPotentialReturns}\n                        minValue={0}\n                        maxValue={50}\n                        formatFn={(percent: number) => NumberUtil.format(percent / 100, 'percent')}\n                        title=\"Potential returns\"\n                        info=\"This is the rate of return that you believe you could get with your extra money (for example, investing in US Treasury bonds)\"\n                    />\n\n                    <SliderBlock\n                        className=\"basis-1/2\"\n                        value={costLiving}\n                        onChange={setCostLiving}\n                        minValue={0}\n                        maxValue={50}\n                        formatFn={(percent: number) => NumberUtil.format(percent / 100, 'percent')}\n                        title=\"Cost of living increase\"\n                        info=\"This represents the rate in which you believe your cost of living will increase annually.  Generally, you would use the US inflation rate, which the Federal Reserve targets around 2% each year.\"\n                    />\n                </div>\n\n                <SafetyNetOpportunityCost\n                    lostEarnings={liquidAssets\n                        .times(safetyNetMonths)\n                        .dividedBy(monthValue)\n                        .times(costLiving / 100)}\n                    potentialEarnings={liquidAssets\n                        .times(safetyNetMonths)\n                        .dividedBy(monthValue)\n                        .times(potentialReturns / 100)}\n                />\n            </Dialog.Content>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/safety-net/SafetyNetOpportunityCost.tsx",
    "content": "import type { Prisma } from '@prisma/client'\nimport { NumberUtil } from '@maybe-finance/shared'\n\nexport default function SafetyNetOpportunityCost({\n    lostEarnings,\n    potentialEarnings,\n}: {\n    lostEarnings: Prisma.Decimal\n    potentialEarnings: Prisma.Decimal\n}) {\n    return (\n        <div className=\"text-base text-gray-100 mt-4\">\n            <p>Based on the figures above and the value of your assets:</p>\n            <div className=\"flex flex-col sm:flex-row items-center gap-3 mt-2\">\n                <div className=\"bg-gray-600 rounded-lg p-3 w-full basis-1/2\">\n                    <p>You're currently losing</p>\n                    <h3 className=\"text-red\">\n                        {NumberUtil.format(lostEarnings, 'short-currency', {\n                            minimumFractionDigits: 2,\n                            maximumFractionDigits: 2,\n                        })}\n                    </h3>\n                    <p>every year</p>\n                </div>\n                <div className=\"bg-gray-600 rounded-lg p-3 w-full basis-1/2\">\n                    <p>You could be making</p>\n                    <h3 className=\"text-teal\">\n                        {NumberUtil.format(potentialEarnings, 'short-currency', {\n                            minimumFractionDigits: 2,\n                            maximumFractionDigits: 2,\n                        })}\n                    </h3>\n                    <p>every year</p>\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/safety-net/SliderBlock.tsx",
    "content": "import { Slider, Tooltip } from '@maybe-finance/design-system'\nimport classNames from 'classnames'\nimport { RiQuestionLine } from 'react-icons/ri'\n\ntype SliderBlock = {\n    title: string\n    info: string\n    value: number\n    onChange: (value: number) => void\n    className?: string\n    maxValue?: number\n    minValue?: number\n    formatFn?: (value: number, minValue?: number, maxValue?: number) => string\n}\n\nexport default function SliderBlock({\n    title,\n    info,\n    value,\n    minValue,\n    maxValue,\n    className,\n    onChange,\n    formatFn,\n}: SliderBlock) {\n    return (\n        <div className={classNames('bg-gray-600 rounded-lg mt-4 p-3 w-full', className)}>\n            <div className=\"flex items-center text-base text-gray-100 mb-4\">\n                <span>{title}</span>\n                <Tooltip content={info}>\n                    <span className=\"ml-1.5\">\n                        <RiQuestionLine className=\"w-4 h-4\" />\n                    </span>\n                </Tooltip>\n\n                {/* Did not implement first pass */}\n                {/* <span className=\"ml-auto\">\n                    <RiPencilLine className=\"w-4 h-4 text-gray-50\" />\n                </span> */}\n            </div>\n            <h4>{formatFn ? formatFn(value, minValue, maxValue) : value}</h4>\n            <Slider\n                className=\"mt-2\"\n                initialValue={[value]}\n                updateOnDrag={true}\n                onChange={(v: number[]) => onChange(v[0])}\n                rangerOptions={{\n                    max: maxValue,\n                    min: minValue,\n                }}\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/net-worth-insights/safety-net/index.ts",
    "content": "export * from './SafetyNetDialog'\n"
  },
  {
    "path": "libs/client/features/src/onboarding/ExampleApp.tsx",
    "content": "import { useAccountApi } from '@maybe-finance/client/shared'\nimport {\n    AccordionRow,\n    type AccordionRowProps,\n    LoadingPlaceholder,\n    TrendLine,\n} from '@maybe-finance/design-system'\nimport { NumberUtil, type SharedType } from '@maybe-finance/shared'\nimport classNames from 'classnames'\nimport { DateTime } from 'luxon'\nimport {\n    RiArrowLeftLine,\n    RiCheckLine,\n    RiFlagLine,\n    RiFolderOpenLine,\n    RiPieChart2Line,\n} from 'react-icons/ri'\nimport type DecimalJS from 'decimal.js'\n\nconst now = DateTime.now()\n\nexport function ExampleApp({ checklist }: { checklist?: string[] }) {\n    const { useAccountRollup } = useAccountApi()\n\n    const { data } = useAccountRollup({\n        start: now.minus({ months: 1 }).toISO(),\n        end: now.toISO(),\n    })\n\n    return (\n        <div\n            className=\"flex flex-col min-h-[700px] p-4 rounded-[32px] border border-gray-600 border-opacity-60 backdrop-blur-xl\"\n            style={{\n                background:\n                    'linear-gradient(180deg, rgba(35, 36, 40, 0.2) 0%, rgba(68, 71, 76, 0.2) 100%)',\n            }}\n        >\n            <div className=\"grow flex rounded-[20px] border border-gray-600 border-opacity-60 overflow-hidden\">\n                <div className=\"flex bg-white bg-opacity-[0.02]\">\n                    <div className=\"flex flex-col items-center w-[88px] pt-8 pb-6 border-r border-gray-700\">\n                        <img\n                            src=\"/assets/maybe.svg\"\n                            alt=\"Maybe Finance Logo\"\n                            className=\"mb-6\"\n                            height={36}\n                            width={36}\n                        />\n                        <div className=\"flex flex-col items-center gap-5 mt-4\">\n                            {Object.entries({\n                                'Net worth': RiPieChart2Line,\n                                Accounts: RiFolderOpenLine,\n                                Planning: RiFlagLine,\n                            }).map(([label, Icon], idx) => (\n                                <div\n                                    key={idx}\n                                    className={classNames(\n                                        'relative flex flex-col items-center w-[88px] h-12 px-1 rounded-lg text-gray-100',\n                                        idx === 0 && 'text-gray-50'\n                                    )}\n                                >\n                                    {idx === 0 && (\n                                        <div className=\"absolute left-0 top-1/2 -translate-y-1/2 w-1 h-5 bg-white rounded-r-lg\"></div>\n                                    )}\n                                    <Icon\n                                        className={classNames(\n                                            'shrink-0 w-6 h-6',\n                                            idx === 0 && 'text-gray-25'\n                                        )}\n                                    />\n                                    <span className=\"shrink-0 mt-1.5 text-sm font-medium text-center\">\n                                        {label}\n                                    </span>\n                                </div>\n                            ))}\n                        </div>\n                    </div>\n                    <div className=\"px-4 pt-8 w-[330px]\">\n                        {checklist ? (\n                            <>\n                                <div className=\"flex space-x-1.5\">\n                                    <RiArrowLeftLine className=\"w-4 h-4 text-gray-50\" />\n                                    <span className=\"grow text-sm text-white\">Getting started</span>\n                                </div>\n                                <div className=\"mt-4 text-sm text-cyan\">\n                                    1 of {checklist.length + 1} done\n                                </div>\n                                <div className=\"mt-2.5 w-full h-1.5 bg-gray-700 rounded-sm\">\n                                    <div\n                                        className=\"h-full bg-cyan rounded-sm transition-all\"\n                                        style={{ width: `${100 / (checklist.length + 1)}%` }}\n                                    ></div>\n                                </div>\n                                <ul className=\"mt-6 space-y-6\">\n                                    {['bank account', ...checklist].map((name, idx) => (\n                                        <li key={idx} className=\"flex items-center space-x-2.5\">\n                                            <div\n                                                className={classNames(\n                                                    'flex items-center justify-center w-6 h-6 text-sm rounded-full transition-colors',\n                                                    idx === 0\n                                                        ? 'text-cyan bg-cyan bg-opacity-10'\n                                                        : 'text-white bg-gray-600'\n                                                )}\n                                            >\n                                                {idx === 0 ? (\n                                                    <RiCheckLine className=\"w-4 h-4\" />\n                                                ) : (\n                                                    idx + 1\n                                                )}\n                                            </div>\n                                            <span\n                                                className={classNames(\n                                                    'text-sm',\n                                                    idx === 0\n                                                        ? 'text-gray-100 line-through'\n                                                        : 'text-white'\n                                                )}\n                                            >\n                                                {[\n                                                    'cash',\n                                                    'crypto',\n                                                    'vehicle',\n                                                    'property',\n                                                    'valuables',\n                                                ].includes(name)\n                                                    ? 'Manually add'\n                                                    : 'Connect'}{' '}\n                                                {name}\n                                            </span>\n                                        </li>\n                                    ))}\n                                </ul>\n                            </>\n                        ) : (\n                            <>\n                                <h5 className=\"uppercase\">Assets &amp; Debts</h5>\n                                <div className=\"mt-8\">\n                                    {data?.map(\n                                        ({ key: classification, title, balances, items }) => (\n                                            <AccountsSidebarRow\n                                                key={classification}\n                                                label={title}\n                                                balances={balances.data}\n                                                inverted={classification === 'liability'}\n                                                syncing={items.some(({ items }) =>\n                                                    items.some((a) => a.syncing)\n                                                )}\n                                            >\n                                                {items.map(\n                                                    ({ key: category, title, balances, items }) => (\n                                                        <AccountsSidebarRow\n                                                            key={category}\n                                                            label={title}\n                                                            balances={balances.data}\n                                                            inverted={\n                                                                classification === 'liability'\n                                                            }\n                                                            level={1}\n                                                            syncing={items.some((a) => a.syncing)}\n                                                        >\n                                                            {items.map(\n                                                                ({\n                                                                    id,\n                                                                    name,\n                                                                    mask,\n                                                                    connection,\n                                                                    balances,\n                                                                    syncing,\n                                                                }) => (\n                                                                    <AccountsSidebarRow\n                                                                        key={id}\n                                                                        label={name}\n                                                                        institutionName={\n                                                                            connection?.name\n                                                                        }\n                                                                        accountMask={mask}\n                                                                        balances={balances.data}\n                                                                        inverted={\n                                                                            classification ===\n                                                                            'liability'\n                                                                        }\n                                                                        level={2}\n                                                                        syncing={syncing}\n                                                                    />\n                                                                )\n                                                            )}\n                                                        </AccountsSidebarRow>\n                                                    )\n                                                )}\n                                            </AccountsSidebarRow>\n                                        )\n                                    ) ?? <div></div>}\n                                </div>\n                            </>\n                        )}\n                    </div>\n                </div>\n                <div className=\"w-[800px]\"></div>\n            </div>\n        </div>\n    )\n}\n\nfunction AccountsSidebarRow({\n    label,\n    level = 0,\n    balances,\n    institutionName,\n    accountMask,\n    inverted = false,\n    syncing = false,\n    ...rest\n}: AccordionRowProps & {\n    label: string\n    balances: { date: string; balance: SharedType.Decimal }[]\n    institutionName?: string | null\n    accountMask?: string | null\n    inverted?: boolean\n    syncing?: boolean\n}) {\n    const startBalance = balances[0].balance\n    const endBalance = balances[balances.length - 1].balance\n\n    const percentChange = NumberUtil.calculatePercentChange(startBalance, endBalance)\n\n    let isPositive = balances.length > 1 && (endBalance as DecimalJS).gt(startBalance as DecimalJS)\n    if (inverted) isPositive = !isPositive\n\n    const overlayClassName = ['!bg-gray-600', '!bg-gray-600', '!bg-gray-700'][level]\n\n    // Hide flat lines or inifite\n    const hasValidValue = !percentChange.isZero() && percentChange.isFinite()\n\n    return (\n        <AccordionRow\n            {...rest}\n            collapsible={level < 2}\n            expanded={true}\n            level={level}\n            className={classNames(\n                'pointer-events-none',\n                ['!bg-gray-500 !bg-opacity-50', '!bg-gray-500 !bg-opacity-50', '!bg-transparent'][\n                    level\n                ]\n            )}\n            data-testid=\"account-accordion-row\"\n            label={\n                <div\n                    className=\"flex items-center space-x-1\"\n                    data-testid=\"account-accordion-row-name\"\n                >\n                    <div className=\"flex-1 min-w-0\">\n                        <p className=\"text-base font-normal line-clamp-2\">{label}</p>\n\n                        {(institutionName || accountMask) && (\n                            <div className=\"mt-0.5 flex flex-wrap items-center space-x-1 text-sm text-gray-100\">\n                                {institutionName && (\n                                    <span className=\"line-clamp-2\">{institutionName}</span>\n                                )}\n                                {accountMask && (\n                                    <span className=\"shrink-0\">\n                                        &nbsp;&#183;&#183;&#183;&#183; {accountMask}\n                                    </span>\n                                )}\n                            </div>\n                        )}\n                    </div>\n\n                    {balances.length && (\n                        <div className=\"shrink-0 flex flex-col justify-center items-end font-semibold tabular-nums h-9\">\n                            <LoadingPlaceholder\n                                isLoading={syncing}\n                                overlayClassName={overlayClassName}\n                            >\n                                <div data-testid=\"account-accordion-row-balance\" className=\"pb-1\">\n                                    {syncing\n                                        ? '$X,XXX,XXX'\n                                        : NumberUtil.format(endBalance, 'currency', {\n                                              minimumFractionDigits: 0,\n                                              maximumFractionDigits: 0,\n                                          })}\n                                </div>\n                            </LoadingPlaceholder>\n                            {(balances.length > 1 || syncing) && hasValidValue && (\n                                <div className=\"mt-1\">\n                                    <LoadingPlaceholder\n                                        isLoading={syncing}\n                                        overlayClassName={overlayClassName}\n                                        className=\"!inline-flex\"\n                                    >\n                                        {!syncing && (\n                                            <div className=\"inline-block w-8 h-3\">\n                                                <TrendLine\n                                                    inverted={inverted}\n                                                    data={balances.map(({ date, balance }) => ({\n                                                        key: date,\n                                                        value: balance.toNumber(),\n                                                    }))}\n                                                />\n                                            </div>\n                                        )}\n                                        <span\n                                            className={classNames(\n                                                'ml-1',\n                                                percentChange.isZero()\n                                                    ? 'text-gray-200'\n                                                    : isPositive\n                                                    ? 'text-teal'\n                                                    : 'text-red'\n                                            )}\n                                        >\n                                            {syncing\n                                                ? '+XXX%'\n                                                : NumberUtil.format(percentChange, 'percent')}\n                                        </span>\n                                    </LoadingPlaceholder>\n                                </div>\n                            )}\n                        </div>\n                    )}\n                </div>\n            }\n        />\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/OnboardingBackground.tsx",
    "content": "export function OnboardingBackground({ className }: { className: string }) {\n    return (\n        <svg\n            className={className}\n            width=\"2601\"\n            height=\"900\"\n            viewBox=\"0 0 2601 900\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n        >\n            <g clipPath=\"url(#clip0_153_991)\">\n                <path\n                    d=\"M1073.5 675.559L1043.5 730.059H1142L1162 675.059L1073.5 675.559Z\"\n                    fill=\"url(#paint0_linear_153_991)\"\n                />\n                <path\n                    d=\"M1515 728.059L1539.5 794.059H1649L1612.5 727.059L1515 728.059Z\"\n                    fill=\"url(#paint1_linear_153_991)\"\n                />\n                <path\n                    d=\"M1684.5 598.559L1718 636.059L1799.5 635.559L1758.5 598.059L1684.5 598.559Z\"\n                    fill=\"url(#paint2_linear_153_991)\"\n                />\n                <path\n                    d=\"M806.5 793.059L742.5 862.059H850L903.5 792.93L806.5 793.059Z\"\n                    fill=\"url(#paint3_linear_153_991)\"\n                />\n                <path\n                    d=\"M771.5 462.498L1329 210.002M1329 210.002L771.5 589.132M1329 210.002L1886.5 462.498M1329 210.002L1886.5 589.132M771.888 485.277L1329 210.002M771.5 512.687L1329 210M1329 210L771.5 546.663M1329 210L771.5 643.181M1329 210L771.5 715.765M1329 210L771.5 817.691M1329 210L842.539 874.059M1329 210L1333.63 874.059M1329 210L1886.5 512.687M1329 210L1886.5 546.663M1329 210L1886.5 643.181M1329 210L1886.5 715.765M1329 210L1886.5 817.691M966.085 874.059L1329 210M1329 210L1088.86 874.059M1329 210L1446.37 874.059M1211.63 874.059L1329 210M1329 210L1815.46 874.059M1329 210L1691.91 874.059M1329 210L1569.14 874.059M1886.11 485.277L1329 210.002M771.5 792.981H1883.41M771.5 728.892H1883.41M771.5 676.386H1883.41M771.5 634.688H1883.41M771.5 599.169H1883.41M771.5 569.826H1883.41M771.5 544.347H1883.41M771.5 521.953H1883.41M771.5 501.878H1883.41M771.5 484.89H1883.41M771.5 470.22H1883.41M771.5 456.321H1883.41\"\n                    stroke=\"url(#paint4_radial_153_991)\"\n                />\n                <path\n                    d=\"M850.5 862.059L1303.8 243.264\"\n                    stroke=\"url(#paint5_linear_153_991)\"\n                    strokeWidth=\"2.15\"\n                />\n                <path\n                    d=\"M1806.8 862.059L1353.5 243.264\"\n                    stroke=\"url(#paint6_linear_153_991)\"\n                    strokeWidth=\"2.15\"\n                />\n                <path\n                    d=\"M803.5 728.059H1839.62\"\n                    stroke=\"url(#paint7_linear_153_991)\"\n                    strokeWidth=\"2.15\"\n                />\n                <path\n                    d=\"M803.5 794.059H1839.62\"\n                    stroke=\"url(#paint8_linear_153_991)\"\n                    strokeWidth=\"2.15\"\n                />\n                <g filter=\"url(#filter0_f_153_991)\">\n                    <path\n                        d=\"M903 615.409C1047.91 615.409 1211.4 611.116 1346.85 630.343C1442.34 643.9 1507.34 672.678 1512.1 705.49C1513.02 711.79 1505.99 718.201 1507.83 724.445C1508.59 727.045 1515.33 729.373 1520.2 731.241C1537.34 737.822 1556.16 743.954 1573.5 750.483C1595.81 758.882 1618.5 767.305 1628.18 777.957C1637.78 788.517 1650.41 798.098 1668.81 807.538C1696.67 821.832 1737.2 832.42 1780 842\"\n                        stroke=\"url(#paint9_linear_153_991)\"\n                        strokeWidth=\"9\"\n                        strokeLinecap=\"round\"\n                    />\n                </g>\n                <g opacity=\"0.3\" filter=\"url(#filter1_f_153_991)\">\n                    <mask\n                        id=\"mask0_153_991\"\n                        style={{ maskType: 'alpha' }}\n                        maskUnits=\"userSpaceOnUse\"\n                        x=\"739\"\n                        y=\"192\"\n                        width=\"1153\"\n                        height=\"708\"\n                    >\n                        <rect\n                            x=\"739\"\n                            y=\"192\"\n                            width=\"1153\"\n                            height=\"708\"\n                            fill=\"url(#paint10_radial_153_991)\"\n                        />\n                    </mask>\n                    <g mask=\"url(#mask0_153_991)\">\n                        <g opacity=\"0.9\">\n                            <g filter=\"url(#filter2_bf_153_991)\">\n                                <path\n                                    fillRule=\"evenodd\"\n                                    clipRule=\"evenodd\"\n                                    d=\"M1544.73 1038.87C1327.55 1044.86 1131.48 1002.64 1027.2 915.492C943.335 845.409 934.115 759.044 986.891 674.83C986.891 674.83 815.551 172.242 1492 592.509C2168.46 1012.78 1788.69 355.057 1788.69 355.057C2011.54 346.768 2213.96 388.915 2320.52 477.969C2445.31 582.249 2404.83 722.581 2240.76 838.233L1892.75 938.549L1544.73 1038.87Z\"\n                                    fill=\"url(#paint11_linear_153_991)\"\n                                />\n                                <path\n                                    fillRule=\"evenodd\"\n                                    clipRule=\"evenodd\"\n                                    d=\"M1544.73 1038.87C1327.55 1044.86 1131.48 1002.64 1027.2 915.492C943.335 845.409 934.115 759.044 986.891 674.83C986.891 674.83 815.551 172.242 1492 592.509C2168.46 1012.78 1788.69 355.057 1788.69 355.057C2011.54 346.768 2213.96 388.915 2320.52 477.969C2445.31 582.249 2404.83 722.581 2240.76 838.233L1892.75 938.549L1544.73 1038.87Z\"\n                                    fill=\"#F72585\"\n                                />\n                                <path\n                                    d=\"M1358.22 432.954C1172.89 495.65 1043.82 583.988 986.891 674.83C986.891 674.83 815.551 172.242 1492 592.509C2168.46 1012.78 1788.69 355.057 1788.69 355.057C1647 360.328 1497.04 385.991 1358.22 432.954Z\"\n                                    fill=\"url(#paint12_linear_153_991)\"\n                                />\n                                <path\n                                    d=\"M1358.22 432.954C1172.89 495.65 1043.82 583.988 986.891 674.83C986.891 674.83 815.551 172.242 1492 592.509C2168.46 1012.78 1788.69 355.057 1788.69 355.057C1647 360.328 1497.04 385.991 1358.22 432.954Z\"\n                                    fill=\"#F72585\"\n                                />\n                                <path\n                                    d=\"M1544.73 1038.87C1690.61 1034.84 1846 1009.05 1989.5 960.507C2091 926.172 2175.62 884.147 2240.76 838.233L1892.75 938.549L1544.73 1038.87Z\"\n                                    fill=\"url(#paint13_linear_153_991)\"\n                                />\n                                <path\n                                    d=\"M1544.73 1038.87C1690.61 1034.84 1846 1009.05 1989.5 960.507C2091 926.172 2175.62 884.147 2240.76 838.233L1892.75 938.549L1544.73 1038.87Z\"\n                                    fill=\"#F72585\"\n                                />\n                            </g>\n                            <g filter=\"url(#filter3_bf_153_991)\">\n                                <path\n                                    d=\"M1452.41 596.028C1535.36 540.454 1524.31 707.234 1510.06 635.865C1501.9 595.019 1254.92 552.35 1227.03 379.489C1167.05 346.541 1224.66 438.381 1180.39 418.098C1076.88 370.674 1089.76 378.469 954.629 381.396C819.493 384.323 692.939 414.797 601.428 466.446C509.916 518.095 460.532 586.92 463.6 658.532C466.669 730.143 521.953 798.996 617.893 850.695C713.833 902.394 843.001 932.936 978.391 935.935C1113.78 938.934 1244.91 914.158 1344.36 866.788L988.218 658.671L1452.41 596.028Z\"\n                                    fill=\"#4361EE\"\n                                />\n                            </g>\n                            <g style={{ mixBlendMode: 'screen' }} filter=\"url(#filter4_bf_153_991)\">\n                                <path\n                                    d=\"M1544.05 524.805C1418.53 126.606 -11.0502 805.716 1107.31 112.908C1228.93 -69.1175 1565.57 -218.664 2011.81 -134.368C2458.06 -50.0719 2721.22 165.825 2599.59 347.85C2413.84 239.546 1990.3 609.102 1544.05 524.805Z\"\n                                    fill=\"url(#paint14_linear_153_991)\"\n                                />\n                                <path\n                                    d=\"M1544.05 524.805C1418.53 126.606 -11.0502 805.716 1107.31 112.908C1228.93 -69.1175 1565.57 -218.664 2011.81 -134.368C2458.06 -50.0719 2721.22 165.825 2599.59 347.85C2413.84 239.546 1990.3 609.102 1544.05 524.805Z\"\n                                    fill=\"#4361EE\"\n                                />\n                            </g>\n                            <g\n                                style={{ mixBlendMode: 'color-dodge' }}\n                                opacity=\"0.6\"\n                                filter=\"url(#filter5_bf_153_991)\"\n                            >\n                                <path\n                                    d=\"M1245.68 205.074C1245.68 141.347 1198.72 80.0874 1114.6 34.1029C1030.49 -11.8816 915.745 -39.0304 794.356 -41.6673C672.967 -44.3042 554.333 -22.2249 463.259 19.9541C372.186 62.1331 315.724 121.146 305.678 184.655C295.632 248.163 332.781 311.25 409.357 360.725C485.933 410.2 596.007 442.233 716.565 450.125C837.123 458.018 958.831 441.16 1056.24 403.075C1153.65 364.99 1219.22 308.628 1239.25 245.773L774.875 205.074H1245.68Z\"\n                                    fill=\"#4361EE\"\n                                />\n                            </g>\n                        </g>\n                    </g>\n                </g>\n            </g>\n            <defs>\n                <filter\n                    id=\"filter0_f_153_991\"\n                    x=\"810.5\"\n                    y=\"522.499\"\n                    width=\"1062\"\n                    height=\"412.002\"\n                    filterUnits=\"userSpaceOnUse\"\n                    colorInterpolationFilters=\"sRGB\"\n                >\n                    <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                    <feBlend\n                        mode=\"normal\"\n                        in=\"SourceGraphic\"\n                        in2=\"BackgroundImageFix\"\n                        result=\"shape\"\n                    />\n                    <feGaussianBlur stdDeviation=\"44\" result=\"effect1_foregroundBlur_153_991\" />\n                </filter>\n                <filter\n                    id=\"filter1_f_153_991\"\n                    x=\"519\"\n                    y=\"-28\"\n                    width=\"1593\"\n                    height=\"1148\"\n                    filterUnits=\"userSpaceOnUse\"\n                    colorInterpolationFilters=\"sRGB\"\n                >\n                    <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                    <feBlend\n                        mode=\"normal\"\n                        in=\"SourceGraphic\"\n                        in2=\"BackgroundImageFix\"\n                        result=\"shape\"\n                    />\n                    <feGaussianBlur stdDeviation=\"110\" result=\"effect1_foregroundBlur_153_991\" />\n                </filter>\n                <filter\n                    id=\"filter2_bf_153_991\"\n                    x=\"816.381\"\n                    y=\"216.302\"\n                    width=\"1714.96\"\n                    height=\"960.856\"\n                    filterUnits=\"userSpaceOnUse\"\n                    colorInterpolationFilters=\"sRGB\"\n                >\n                    <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                    <feGaussianBlur in=\"BackgroundImageFix\" stdDeviation=\"68.8657\" />\n                    <feComposite\n                        in2=\"SourceAlpha\"\n                        operator=\"in\"\n                        result=\"effect1_backgroundBlur_153_991\"\n                    />\n                    <feBlend\n                        mode=\"normal\"\n                        in=\"SourceGraphic\"\n                        in2=\"effect1_backgroundBlur_153_991\"\n                        result=\"shape\"\n                    />\n                    <feGaussianBlur\n                        stdDeviation=\"68.8657\"\n                        result=\"effect2_foregroundBlur_153_991\"\n                    />\n                </filter>\n                <filter\n                    id=\"filter3_bf_153_991\"\n                    x=\"325.734\"\n                    y=\"234.549\"\n                    width=\"1331.97\"\n                    height=\"839.361\"\n                    filterUnits=\"userSpaceOnUse\"\n                    colorInterpolationFilters=\"sRGB\"\n                >\n                    <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                    <feGaussianBlur in=\"BackgroundImageFix\" stdDeviation=\"68.8658\" />\n                    <feComposite\n                        in2=\"SourceAlpha\"\n                        operator=\"in\"\n                        result=\"effect1_backgroundBlur_153_991\"\n                    />\n                    <feBlend\n                        mode=\"normal\"\n                        in=\"SourceGraphic\"\n                        in2=\"effect1_backgroundBlur_153_991\"\n                        result=\"shape\"\n                    />\n                    <feGaussianBlur\n                        stdDeviation=\"68.8658\"\n                        result=\"effect2_foregroundBlur_153_991\"\n                    />\n                </filter>\n                <filter\n                    id=\"filter4_bf_153_991\"\n                    x=\"468.581\"\n                    y=\"-372.501\"\n                    width=\"2374.69\"\n                    height=\"1123.93\"\n                    filterUnits=\"userSpaceOnUse\"\n                    colorInterpolationFilters=\"sRGB\"\n                >\n                    <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                    <feGaussianBlur in=\"BackgroundImageFix\" stdDeviation=\"68.8657\" />\n                    <feComposite\n                        in2=\"SourceAlpha\"\n                        operator=\"in\"\n                        result=\"effect1_backgroundBlur_153_991\"\n                    />\n                    <feBlend\n                        mode=\"normal\"\n                        in=\"SourceGraphic\"\n                        in2=\"effect1_backgroundBlur_153_991\"\n                        result=\"shape\"\n                    />\n                    <feGaussianBlur stdDeviation=\"107\" result=\"effect2_foregroundBlur_153_991\" />\n                </filter>\n                <filter\n                    id=\"filter5_bf_153_991\"\n                    x=\"166.333\"\n                    y=\"-179.611\"\n                    width=\"1217.08\"\n                    height=\"769.37\"\n                    filterUnits=\"userSpaceOnUse\"\n                    colorInterpolationFilters=\"sRGB\"\n                >\n                    <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                    <feGaussianBlur in=\"BackgroundImageFix\" stdDeviation=\"68.8657\" />\n                    <feComposite\n                        in2=\"SourceAlpha\"\n                        operator=\"in\"\n                        result=\"effect1_backgroundBlur_153_991\"\n                    />\n                    <feBlend\n                        mode=\"normal\"\n                        in=\"SourceGraphic\"\n                        in2=\"effect1_backgroundBlur_153_991\"\n                        result=\"shape\"\n                    />\n                    <feGaussianBlur\n                        stdDeviation=\"68.8657\"\n                        result=\"effect2_foregroundBlur_153_991\"\n                    />\n                </filter>\n                <linearGradient\n                    id=\"paint0_linear_153_991\"\n                    x1=\"1098.5\"\n                    y1=\"733.059\"\n                    x2=\"1103\"\n                    y2=\"676.059\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#1C1C20\" />\n                    <stop offset=\"1\" stopColor=\"#1C1C20\" stopOpacity=\"0\" />\n                    <stop offset=\"1\" stopColor=\"#1C1C20\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint1_linear_153_991\"\n                    x1=\"1594.5\"\n                    y1=\"797.059\"\n                    x2=\"1599\"\n                    y2=\"740.059\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#1C1C20\" />\n                    <stop offset=\"1\" stopColor=\"#1C1C20\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint2_linear_153_991\"\n                    x1=\"1773\"\n                    y1=\"639.059\"\n                    x2=\"1777.5\"\n                    y2=\"582.059\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#1C1C20\" />\n                    <stop offset=\"1\" stopColor=\"#1C1C20\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint3_linear_153_991\"\n                    x1=\"899\"\n                    y1=\"748.93\"\n                    x2=\"828.5\"\n                    y2=\"862.43\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#1C1C20\" />\n                    <stop offset=\"1\" stopColor=\"#1C1C20\" stopOpacity=\"0\" />\n                </linearGradient>\n                <radialGradient\n                    id=\"paint4_radial_153_991\"\n                    cx=\"0\"\n                    cy=\"0\"\n                    r=\"1\"\n                    gradientUnits=\"userSpaceOnUse\"\n                    gradientTransform=\"translate(1329 665.488) rotate(-90) scale(247.988 614.206)\"\n                >\n                    <stop stopColor=\"#1C1C20\" />\n                    <stop offset=\"1\" stopColor=\"#34363C\" stopOpacity=\"0\" />\n                </radialGradient>\n                <linearGradient\n                    id=\"paint5_linear_153_991\"\n                    x1=\"1042.35\"\n                    y1=\"601.443\"\n                    x2=\"912.699\"\n                    y2=\"772.212\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#3BC9DB\" stopOpacity=\"0\" />\n                    <stop offset=\"0.424496\" stopColor=\"#3BC9DB\" />\n                    <stop offset=\"1\" stopColor=\"#3BC9DB\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint6_linear_153_991\"\n                    x1=\"1567.5\"\n                    y1=\"531.061\"\n                    x2=\"1712.8\"\n                    y2=\"734.221\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#49136F\" stopOpacity=\"0\" />\n                    <stop offset=\"0.424496\" stopColor=\"#7D0FC5\" />\n                    <stop offset=\"1\" stopColor=\"#49136F\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint7_linear_153_991\"\n                    x1=\"1397.5\"\n                    y1=\"728.461\"\n                    x2=\"1671.23\"\n                    y2=\"728.461\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#F52685\" stopOpacity=\"0\" />\n                    <stop offset=\"0.424496\" stopColor=\"#F52685\" />\n                    <stop offset=\"1\" stopColor=\"#F52685\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint8_linear_153_991\"\n                    x1=\"925\"\n                    y1=\"794.059\"\n                    x2=\"1268\"\n                    y2=\"794.46\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#0673F2\" stopOpacity=\"0\" />\n                    <stop offset=\"0.424496\" stopColor=\"#0673F2\" />\n                    <stop offset=\"1\" stopColor=\"#0673F2\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint9_linear_153_991\"\n                    x1=\"959.64\"\n                    y1=\"635.574\"\n                    x2=\"1765.53\"\n                    y2=\"902.324\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#4CC9F0\" />\n                    <stop offset=\"0.28684\" stopColor=\"#4361EE\" />\n                    <stop offset=\"0.679646\" stopColor=\"#7209B7\" />\n                    <stop offset=\"0.83892\" stopColor=\"#F72585\" />\n                </linearGradient>\n                <radialGradient\n                    id=\"paint10_radial_153_991\"\n                    cx=\"0\"\n                    cy=\"0\"\n                    r=\"1\"\n                    gradientUnits=\"userSpaceOnUse\"\n                    gradientTransform=\"translate(1315.5 546) rotate(90) scale(317.813 540.003)\"\n                >\n                    <stop stopColor=\"#D9D9D9\" />\n                    <stop offset=\"1\" stopColor=\"#D9D9D9\" stopOpacity=\"0\" />\n                </radialGradient>\n                <linearGradient\n                    id=\"paint11_linear_153_991\"\n                    x1=\"2320.53\"\n                    y1=\"477.969\"\n                    x2=\"1573.43\"\n                    y2=\"1371.97\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"white\" />\n                    <stop offset=\"1\" stopColor=\"white\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint12_linear_153_991\"\n                    x1=\"2320.53\"\n                    y1=\"477.969\"\n                    x2=\"1573.43\"\n                    y2=\"1371.97\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"white\" />\n                    <stop offset=\"1\" stopColor=\"white\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint13_linear_153_991\"\n                    x1=\"2320.53\"\n                    y1=\"477.969\"\n                    x2=\"1573.43\"\n                    y2=\"1371.97\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"white\" />\n                    <stop offset=\"1\" stopColor=\"white\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint14_linear_153_991\"\n                    x1=\"2599.59\"\n                    y1=\"347.85\"\n                    x2=\"1341.38\"\n                    y2=\"-492.874\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"white\" />\n                    <stop offset=\"0.0001\" stopOpacity=\"0.864583\" />\n                    <stop offset=\"1\" stopColor=\"white\" stopOpacity=\"0\" />\n                </linearGradient>\n                <clipPath id=\"clip0_153_991\">\n                    <rect width=\"2601\" height=\"900\" fill=\"white\" />\n                </clipPath>\n            </defs>\n        </svg>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/OnboardingGuard.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport { MainContentOverlay, useUserApi } from '@maybe-finance/client/shared'\nimport { LoadingSpinner } from '@maybe-finance/design-system'\nimport { useRouter } from 'next/router'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { signOut } from 'next-auth/react'\n\nfunction shouldRedirect(pathname: string, data?: SharedType.OnboardingResponse) {\n    if (!data) return false\n    if (pathname === '/onboarding') return false\n    const isOnboardingComplete = data.isComplete || data.isMarkedComplete\n    return !isOnboardingComplete\n}\n\nexport function OnboardingGuard({ children }: PropsWithChildren) {\n    const router = useRouter()\n    const { useOnboarding } = useUserApi()\n    const onboarding = useOnboarding('main', {\n        onSuccess(data) {\n            if (shouldRedirect(router.pathname, data)) {\n                router.replace('/onboarding')\n            }\n        },\n    })\n\n    if (onboarding.isError) {\n        return (\n            <MainContentOverlay\n                title=\"Unable to load onboarding\"\n                actionText=\"Logout\"\n                onAction={() => signOut()}\n            >\n                <p>Contact us if this issue persists.</p>\n            </MainContentOverlay>\n        )\n    }\n\n    if (onboarding.isLoading || shouldRedirect(router.pathname, onboarding.data)) {\n        return (\n            <div className=\"absolute inset-0 flex items-center justify-center\">\n                <LoadingSpinner />\n            </div>\n        )\n    }\n\n    // eslint-disable-next-line react/jsx-no-useless-fragment\n    return <>{children}</>\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/OnboardingNavbar.tsx",
    "content": "import { signOut } from 'next-auth/react'\nimport { ProfileCircle } from '@maybe-finance/client/shared'\nimport { Button, Menu } from '@maybe-finance/design-system'\nimport type { SharedType } from '@maybe-finance/shared'\nimport classNames from 'classnames'\nimport uniqBy from 'lodash/uniqBy'\nimport upperFirst from 'lodash/upperFirst'\nimport { Fragment } from 'react'\nimport { RiArrowDownSLine, RiArrowLeftLine, RiCheckLine, RiShutDownLine } from 'react-icons/ri'\n\ntype Props = {\n    steps: SharedType.OnboardingStep[]\n    currentStep: SharedType.OnboardingStep\n    onBack(): void\n}\n\nexport function OnboardingNavbar({ steps, currentStep, onBack }: Props) {\n    const groups = uniqBy(steps, 'group')\n        .map((s) => s.group)\n        .filter((g): g is string => g != null)\n    const currentGroupSteps = steps.filter((step) => step.group === currentStep.group)\n    const currentGroupIdx = groups.findIndex((group) => group === currentStep?.group)\n    const isLastGroup = currentGroupIdx === groups.length - 1\n    const hasSubsteps = currentGroupSteps.length > 1\n    const substepIdx = currentGroupSteps.findIndex((substep) => substep.key === currentStep.key)\n\n    return (\n        <div className={classNames('mt-8 mx-4 md:mx-12', !hasSubsteps && 'mb-10 sm:mb-20')}>\n            <div className=\"relative flex items-center justify-between\">\n                <div className=\"shrink-0 md:w-[148px]\">\n                    {isLastGroup ? (\n                        <Button variant=\"icon\" onClick={onBack}>\n                            <RiArrowLeftLine size={24} />\n                        </Button>\n                    ) : (\n                        <img src=\"/assets/maybe-full.svg\" alt=\"Maybe\" className=\"h-6\" />\n                    )}\n                </div>\n                <div className=\"hidden sm:flex items-center justify-center space-x-4 w-[468px] mx-10\">\n                    {groups.map((group, idx) => (\n                        <Fragment key={idx}>\n                            {idx > 0 && <div className=\"grow h-px bg-gray-500\"></div>}\n                            <div\n                                className={classNames(\n                                    'flex items-center gap-3 text-base',\n                                    idx < currentGroupIdx\n                                        ? 'text-teal'\n                                        : idx === currentGroupIdx\n                                        ? 'text-cyan'\n                                        : 'text-white'\n                                )}\n                            >\n                                <div\n                                    className={classNames(\n                                        'flex items-center justify-center w-6 h-6 rounded-md',\n                                        idx < currentGroupIdx\n                                            ? 'bg-teal bg-opacity-10'\n                                            : idx === currentGroupIdx\n                                            ? 'bg-cyan bg-opacity-10'\n                                            : 'text-gray-100 bg-gray-700'\n                                    )}\n                                >\n                                    {idx < currentGroupIdx ? (\n                                        <RiCheckLine className=\"w-5 h-5\" />\n                                    ) : (\n                                        idx + 1\n                                    )}\n                                </div>\n                                {upperFirst(group)}\n                            </div>\n                        </Fragment>\n                    ))}\n                </div>\n                <div className=\"shrink-0 md:w-[148px] flex items-center justify-end space-x-2\">\n                    <div className=\"shrink-0\">\n                        <ProfileCircle interactive={false} className=\"!w-10 !h-10\" />\n                    </div>\n                    <Menu>\n                        <Menu.Button variant=\"icon\" type=\"button\">\n                            <RiArrowDownSLine className=\"w-6 h-6\" />\n                        </Menu.Button>\n                        <Menu.Items placement=\"bottom-end\">\n                            <Menu.Item\n                                icon={<RiShutDownLine />}\n                                destructive={true}\n                                onClick={() => signOut()}\n                            >\n                                Log out\n                            </Menu.Item>\n                        </Menu.Items>\n                    </Menu>\n                </div>\n            </div>\n            {currentGroupSteps.length > 1 && (\n                <div className=\"flex justify-center items-center space-x-2 my-12 sm:my-16\">\n                    {currentGroupSteps.map((_, idx) => (\n                        <div\n                            key={idx}\n                            className={classNames(\n                                'w-6 h-1 rounded-full transition-colors',\n                                idx === substepIdx ? 'bg-cyan' : 'bg-gray-500'\n                            )}\n                        ></div>\n                    ))}\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/index.ts",
    "content": "export * from './steps'\nexport * from './OnboardingNavbar'\nexport * from './OnboardingBackground'\nexport * from './OnboardingGuard'\nexport * from './sidebar'\n"
  },
  {
    "path": "libs/client/features/src/onboarding/sidebar/SidebarOnboarding.tsx",
    "content": "import { Disclosure, Transition } from '@headlessui/react'\nimport { useUserAccountContext, useUserApi } from '@maybe-finance/client/shared'\nimport { Button, LoadingSpinner, Menu } from '@maybe-finance/design-system'\nimport classNames from 'classnames'\nimport {\n    RiArrowDownSFill,\n    RiArrowLeftLine,\n    RiArrowRightSLine,\n    RiArrowUpSFill,\n    RiCheckFill,\n    RiCloseFill,\n    RiEyeOffLine,\n    RiMore2Fill,\n} from 'react-icons/ri'\nimport { HiOutlineSparkles } from 'react-icons/hi'\nimport Link from 'next/link'\nimport { AnimatePresence, motion } from 'framer-motion'\n\ntype DescriptionProps = {\n    summary: string\n    examples: string[]\n}\n\nfunction Description({ summary, examples }: DescriptionProps) {\n    return (\n        <div className=\"text-gray-50 text-base mt-2\">\n            <p className=\"mb-4\">{summary}</p>\n            <ul>\n                {examples.map((example) => (\n                    <li key={example} className=\"list-disc ml-6\">\n                        {example}\n                    </li>\n                ))}\n            </ul>\n        </div>\n    )\n}\n\nfunction getDescriptionComponent(key: string) {\n    switch (key) {\n        case 'connect-depository':\n            return (\n                <Description\n                    summary=\"This includes primary bank accounts used for direct deposits, emergency funds, etc.\"\n                    examples={['Checking account', 'Savings account']}\n                />\n            )\n\n        case 'connect-investment':\n            return (\n                <Description\n                    summary=\"This includes accounts you are using to grow your wealth over time.\"\n                    examples={[\n                        'Brokerage (e.g. Robinhood)',\n                        \"Retirement accounts (401k's, Roth IRA, Traditional IRA, etc.)\",\n                        'Savings accounts (HSA, ESA, etc.',\n                    ]}\n                />\n            )\n\n        case 'connect-liability':\n            return (\n                <Description\n                    summary=\"This includes accounts that you make debt payments to.\"\n                    examples={[\n                        'Credit cards',\n                        'Home loans (mortgage)',\n                        'Student loans',\n                        'Auto loans',\n                    ]}\n                />\n            )\n\n        case 'add-crypto':\n            return (\n                <Description\n                    summary=\"This includes the market value of any cryptocurrency you own.\"\n                    examples={['Bitcoin', 'Ethereum', 'Alt-Coins', 'NFTs']}\n                />\n            )\n\n        case 'add-property':\n            return (\n                <Description\n                    summary=\"This includes the market value of any property you own.\"\n                    examples={['House', 'Condo', 'Land']}\n                />\n            )\n\n        case 'add-vehicle':\n            return (\n                <Description\n                    summary=\"This includes the market value of the current vehicles you own\"\n                    examples={[]}\n                />\n            )\n\n        case 'add-other':\n            return (\n                <Description\n                    summary=\"This includes any other accounts that don't fall into a category\"\n                    examples={['Physical cash', 'Collectibles and art', 'Alternative investments']}\n                />\n            )\n\n        default:\n            throw new Error(`${key} is not a valid step key `)\n    }\n}\n\ntype Props = {\n    onClose(): void\n    onHide(): void\n}\n\nexport function SidebarOnboarding({ onClose, onHide }: Props) {\n    const { useOnboarding, useUpdateOnboarding } = useUserApi()\n    const onboarding = useOnboarding('sidebar')\n    const updateOnboarding = useUpdateOnboarding()\n\n    const { syncProgress } = useUserAccountContext()\n\n    if (onboarding.isLoading) {\n        return (\n            <div className=\"w-full flex justify-center items-center h-full\">\n                <LoadingSpinner />\n            </div>\n        )\n    }\n\n    if (onboarding.isError) {\n        return (\n            <div className=\"text-base\">\n                <div className=\"flex items-center gap-2 mb-2\">\n                    <Button variant=\"icon\" onClick={onClose}>\n                        <RiArrowLeftLine size={18} />\n                    </Button>\n                    Back\n                </div>\n\n                <p className=\"text-gray-50\">\n                    Unable to load onboarding progress. Please contact us so we can get this fixed!\n                </p>\n            </div>\n        )\n    }\n\n    const {\n        progress: { completed, total, percent },\n        steps,\n    } = onboarding.data\n\n    const minutesPerStep = 2\n\n    const accountSteps = steps.filter((step) => step.group === 'accounts')\n    const bonusSteps = steps.filter((step) => step.group === 'bonus')\n\n    return (\n        <>\n            <div className=\"flex items-center gap-3.5 text-base w-full mb-5\">\n                <Button variant=\"icon\" onClick={onClose}>\n                    <RiArrowLeftLine size={18} className=\"text-gray-50\" />\n                </Button>\n\n                <p className=\"mr-auto\">Getting started</p>\n\n                <Menu>\n                    <Menu.Button variant=\"icon\">\n                        <RiMore2Fill size={18} />\n                    </Menu.Button>\n                    <Menu.Items>\n                        <Menu.Item className=\"flex items-center gap-2\" onClick={onHide}>\n                            <RiEyeOffLine size={18} />\n                            Hide \"Getting started\" widget\n                        </Menu.Item>\n                    </Menu.Items>\n                </Menu>\n            </div>\n\n            <AnimatePresence>\n                {syncProgress && (\n                    <motion.div\n                        className=\"overflow-hidden text-base text-gray-100\"\n                        key=\"importing-message\"\n                        initial={{ height: 0 }}\n                        animate={{ height: 'auto' }}\n                        exit={{ height: 0 }}\n                    >\n                        {syncProgress.description}...\n                        <div className=\"my-4\">\n                            <div className=\"relative w-full h-[3px] rounded-full overflow-hidden bg-gray-200\">\n                                {syncProgress.progress ? (\n                                    <motion.div\n                                        key=\"progress-determinate\"\n                                        initial={{ width: 0 }}\n                                        animate={{ width: `${syncProgress.progress * 100}%` }}\n                                        transition={{ ease: 'easeOut', duration: 0.5 }}\n                                        className=\"h-full rounded-full bg-gray-100\"\n                                    ></motion.div>\n                                ) : (\n                                    <motion.div\n                                        key=\"progress-indeterminate\"\n                                        className=\"w-[40%] h-full rounded-full bg-gray-100\"\n                                        animate={{ translateX: ['-100%', '250%'] }}\n                                        transition={{ repeat: Infinity, duration: 1.8 }}\n                                    ></motion.div>\n                                )}\n                            </div>\n                        </div>\n                    </motion.div>\n                )}\n            </AnimatePresence>\n\n            <div className=\"text-base\">\n                <div className=\"flex items-center justify-between pb-3\">\n                    <p className=\"text-cyan\">{`${completed} of ${total} done`}</p>\n                    {completed === total ? (\n                        <RiCheckFill size={20} className=\"text-cyan\" />\n                    ) : (\n                        <p className=\"text-gray-100\">\n                            ~ {(total - completed) * minutesPerStep} mins\n                        </p>\n                    )}\n                </div>\n                <div className=\"relative h-2 bg-gray-600 rounded-sm\">\n                    <div\n                        className=\"absolute inset-0 bg-cyan h-2 rounded-sm\"\n                        style={{\n                            width: `${percent * 100}%`,\n                        }}\n                    ></div>\n                </div>\n            </div>\n\n            <div className=\"flex flex-col items-start gap-2 text-base pr-4 -mr-4 custom-gray-scroll\">\n                {accountSteps.map((step, idx) => {\n                    const description = getDescriptionComponent(step.key)\n\n                    return (\n                        <Disclosure key={idx}>\n                            {({ open }) => (\n                                <div\n                                    className={classNames(\n                                        'p-3 w-full rounded-lg',\n                                        open && 'bg-gray-700'\n                                    )}\n                                >\n                                    <Disclosure.Button\n                                        className={classNames(\n                                            'flex items-center gap-3 w-full',\n                                            open ? 'text-white' : 'text-gray-100'\n                                        )}\n                                    >\n                                        <div className=\"flex items-center text-left leading-5 gap-3 mr-auto\">\n                                            <div\n                                                className={classNames(\n                                                    'rounded-full w-[28px] h-[28px] flex items-center justify-center shrink-0',\n                                                    step.isComplete\n                                                        ? 'bg-cyan bg-opacity-10'\n                                                        : 'bg-gray-600'\n                                                )}\n                                            >\n                                                {step.isComplete || step.isMarkedComplete ? (\n                                                    <RiCheckFill size={20} className=\"text-cyan\" />\n                                                ) : (\n                                                    <span className=\"font-medium text-sm\">\n                                                        {idx + 1}\n                                                    </span>\n                                                )}\n                                            </div>\n                                            <span\n                                                className={\n                                                    step.isComplete || step.isMarkedComplete\n                                                        ? 'line-through'\n                                                        : ''\n                                                }\n                                            >\n                                                {step.title}\n                                            </span>\n                                        </div>\n                                        {open ? (\n                                            <RiArrowUpSFill\n                                                size={18}\n                                                className=\"text-gray-100 shrink-0\"\n                                            />\n                                        ) : (\n                                            <RiArrowDownSFill\n                                                size={18}\n                                                className=\"text-gray-100 shrink-0\"\n                                            />\n                                        )}\n                                    </Disclosure.Button>\n                                    <Transition\n                                        show={open}\n                                        enter=\"transition duration-100 ease-out\"\n                                        enterFrom=\"transform scale-95 opacity-0\"\n                                        enterTo=\"transform scale-100 opacity-100\"\n                                        leave=\"transition duration-75 ease-out\"\n                                        leaveFrom=\"transform scale-100 opacity-100\"\n                                        leaveTo=\"transform scale-95 opacity-0\"\n                                    >\n                                        <Disclosure.Panel static>\n                                            {description}\n\n                                            <div className=\"bg-gray-600 my-4 h-[1px]\"></div>\n\n                                            {step.isComplete ? (\n                                                <p className=\"text-gray-50 text-sm\">\n                                                    This step has been automatically marked complete\n                                                    since you've added at least 1 of these account\n                                                    types.\n                                                </p>\n                                            ) : (\n                                                <Button\n                                                    variant=\"link\"\n                                                    className=\"flex items-center gap-2 mx-auto\"\n                                                    fullWidth\n                                                    onClick={() => {\n                                                        updateOnboarding.mutate({\n                                                            flow: 'sidebar',\n                                                            updates: [\n                                                                {\n                                                                    key: step.key,\n                                                                    markedComplete:\n                                                                        !step.isMarkedComplete,\n                                                                },\n                                                            ],\n                                                        })\n                                                    }}\n                                                >\n                                                    {step.isMarkedComplete ? (\n                                                        <>\n                                                            Mark as incomplete\n                                                            <RiCloseFill size={18} />\n                                                        </>\n                                                    ) : (\n                                                        <>\n                                                            Mark as done\n                                                            <RiCheckFill size={18} />\n                                                        </>\n                                                    )}\n                                                </Button>\n                                            )}\n                                        </Disclosure.Panel>\n                                    </Transition>\n                                </div>\n                            )}\n                        </Disclosure>\n                    )\n                })}\n            </div>\n\n            <Disclosure defaultOpen>\n                {({ open }) => (\n                    <div className={classNames('p-3 rounded-lg bg-grape bg-opacity-10 text-base')}>\n                        <Disclosure.Button className=\"flex items-center gap-2 text-grape w-full font-medium\">\n                            <HiOutlineSparkles size={24} />\n                            <span className=\"mr-auto\">Bonus</span>\n                            {open ? <RiArrowUpSFill size={18} /> : <RiArrowDownSFill size={18} />}\n                        </Disclosure.Button>\n                        <Transition\n                            show={open}\n                            enter=\"transition duration-100 ease-out\"\n                            enterFrom=\"transform scale-95 opacity-0\"\n                            enterTo=\"transform scale-100 opacity-100\"\n                            leave=\"transition duration-75 ease-out\"\n                            leaveFrom=\"transform scale-100 opacity-100\"\n                            leaveTo=\"transform scale-95 opacity-0\"\n                        >\n                            <Disclosure.Panel className=\"mt-4 space-y-3\" static>\n                                {bonusSteps.map((step, idx) => {\n                                    return (\n                                        <Link href={step.ctaPath!} className=\"block\" key={step.key}>\n                                            <div className=\"flex items-center gap-4 cursor-pointer group\">\n                                                <div className=\"text-grape w-[28px] h-[28px] rounded-full bg-grape bg-opacity-10 flex items-center justify-center\">\n                                                    {idx + 1}\n                                                </div>\n                                                <span\n                                                    className={classNames(\n                                                        'group-hover:underline',\n                                                        step.isComplete && 'line-through'\n                                                    )}\n                                                >\n                                                    {step.title}\n                                                </span>\n                                                <RiArrowRightSLine\n                                                    size={24}\n                                                    className=\"ml-auto text-grape group-hover:opacity-90\"\n                                                />\n                                            </div>\n                                        </Link>\n                                    )\n                                })}\n                            </Disclosure.Panel>\n                        </Transition>\n                    </div>\n                )}\n            </Disclosure>\n\n            <p className=\"text-sm text-gray-100\">\n                If you have any issues with connecting accounts,{' '}\n                <a className=\"text-cyan underline\" href=\"mailto:hello@maybe.co\">\n                    please let us know\n                </a>\n                .\n            </p>\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/sidebar/index.ts",
    "content": "export * from './SidebarOnboarding'\n"
  },
  {
    "path": "libs/client/features/src/onboarding/steps/Intro.tsx",
    "content": "import { RiArrowRightLine } from 'react-icons/ri'\nimport { Button } from '@maybe-finance/design-system'\nimport type { StepProps } from './StepProps'\nimport { useState } from 'react'\nimport { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'\n\nexport function Intro({ onNext, title }: StepProps) {\n    const [isSubmitting, setIsSubmitting] = useState(false)\n\n    return (\n        <div className=\"flex flex-col h-full\">\n            <div className=\"relative z-10 flex flex-col justify-center w-full max-w-md px-6 mx-auto text-center sm:mt-16 sm:px-0 grow\">\n                <div className=\"flex justify-center\">\n                    <Wave />\n                </div>\n                <h2 className=\"mt-6 text-pretty\">{title}</h2>\n                <p className=\"mt-2 text-base text-gray-50\">\n                    We&rsquo;re super excited you&rsquo;re here. In the next step we&rsquo;ll ask\n                    you a few questions to complete your profile and get you all set up.\n                </p>\n                <Button\n                    onClick={async () => {\n                        setIsSubmitting(true)\n                        await onNext()\n                        setIsSubmitting(false)\n                    }}\n                    className=\"self-center mt-6\"\n                >\n                    Continue{' '}\n                    {isSubmitting ? (\n                        <LoadingIcon className=\"w-5 h-5 ml-2 animate-spin\" />\n                    ) : (\n                        <RiArrowRightLine className=\"w-5 h-5 ml-2\" />\n                    )}\n                </Button>\n            </div>\n        </div>\n    )\n}\n\nfunction Wave() {\n    return (\n        <svg\n            className=\"animate-wave\"\n            width=\"56\"\n            height=\"56\"\n            viewBox=\"0 0 56 56\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n        >\n            <path\n                fillRule=\"evenodd\"\n                clipRule=\"evenodd\"\n                d=\"M37.8543 6.56842C37.9048 5.84965 38.5288 5.30311 39.248 5.3477C40.4892 5.42466 42.7133 5.97862 44.7997 7.20763C46.904 8.4472 49.0234 10.4702 49.7179 13.5554C49.8758 14.2567 49.4335 14.9578 48.7301 15.1212C48.0267 15.2847 47.3284 14.8486 47.1705 14.1473C46.6849 11.9901 45.1898 10.4758 43.4599 9.45683C41.7121 8.42726 39.8824 8.00129 39.065 7.95061C38.3459 7.90602 37.8038 7.28719 37.8543 6.56842Z\"\n                fill=\"#44474C\"\n            />\n            <path\n                fillRule=\"evenodd\"\n                clipRule=\"evenodd\"\n                d=\"M41.3484 2.16822C41.4193 1.45269 42.0585 0.938201 42.776 1.01907C45.0236 1.27239 47.6072 2.41968 49.7509 4.41179C50.2825 4.90569 50.3184 5.73087 49.8312 6.25487C49.344 6.77887 48.5182 6.80326 47.9867 6.30935C46.2256 4.67282 44.1607 3.79526 42.519 3.61022C41.8015 3.52936 41.2774 2.88375 41.3484 2.16822Z\"\n                fill=\"#44474C\"\n            />\n            <path\n                fillRule=\"evenodd\"\n                clipRule=\"evenodd\"\n                d=\"M16.1996 54.1992C15.939 54.8547 15.196 55.1168 14.54 54.7846C13.3265 54.17 12.1982 53.5192 11.1065 52.5006C10.0151 51.4822 9.02713 50.1599 7.98676 48.2716C7.62241 47.6103 7.81712 46.8142 8.42166 46.4936C9.0262 46.173 9.81164 46.4492 10.176 47.1105C11.1142 48.8135 11.9203 49.8513 12.7138 50.5917C13.5071 51.332 14.3547 51.8388 15.4836 52.4106C16.1396 52.7428 16.4601 53.5436 16.1996 54.1992Z\"\n                fill=\"#44474C\"\n            />\n            <path\n                fillRule=\"evenodd\"\n                clipRule=\"evenodd\"\n                d=\"M19.841 50.0374C19.6195 50.7133 18.9003 51.0191 18.2345 50.7204C16.7897 50.0722 14.9933 48.9859 13.2421 46.993C11.4939 45.0034 9.84424 42.1711 8.61065 38.101C8.39216 37.3801 8.75552 36.664 9.42225 36.5015C10.089 36.339 10.8066 36.7917 11.0251 37.5125C12.1533 41.2349 13.6106 43.6634 15.0298 45.2785C16.4461 46.8903 17.8776 47.7527 19.0365 48.2727C19.7023 48.5713 20.0624 49.3614 19.841 50.0374Z\"\n                fill=\"#44474C\"\n            />\n            <path\n                d=\"M48.7299 35.7364C47.678 39.1689 45.6321 42.2126 42.8508 44.4826C40.0694 46.7527 36.6776 48.1472 33.1039 48.4899C29.5302 48.8327 25.9351 48.1082 22.773 46.4082C19.6109 44.7082 17.0238 42.1088 15.3387 38.9388L7.20903 23.6353C6.75157 22.7748 6.51933 21.8125 6.53396 20.838C6.54859 19.8636 6.8096 18.9087 7.29269 18.0623C7.77577 17.2159 8.46522 16.5055 9.2968 15.9974C10.1284 15.4892 11.0751 15.1998 12.0486 15.1561L11.3996 13.934C10.7547 12.7186 10.5647 11.3128 10.8639 9.9698C11.1631 8.62675 11.9318 7.43459 13.0317 6.60785C14.1316 5.78111 15.4905 5.37408 16.8638 5.46001C18.2371 5.54595 19.5346 6.1192 20.5229 7.07661C21.0756 6.24204 21.8359 5.56551 22.729 5.11337C23.622 4.66122 24.6173 4.44901 25.6172 4.49754C26.617 4.54607 27.5871 4.85368 28.4322 5.39019C29.2773 5.92669 29.9684 6.67368 30.4378 7.55785L33.7948 13.8773C34.377 13.0959 35.1473 12.4745 36.0342 12.0707C36.9211 11.6669 37.8956 11.4939 38.8673 11.5678C39.8389 11.6417 40.7761 11.9601 41.5917 12.4934C42.4073 13.0267 43.0747 13.7575 43.5321 14.618L47.4034 21.9052C48.5281 24.0084 49.2245 26.3138 49.4522 28.6879C49.6798 31.062 49.4343 33.4578 48.7299 35.7364ZM44.4885 23.4537L40.6172 16.1665C40.4647 15.8794 40.2571 15.6251 40.0063 15.4182C39.7556 15.2113 39.4665 15.0558 39.1556 14.9607C38.8448 14.8655 38.5182 14.8325 38.1946 14.8635C37.8709 14.8945 37.5566 14.989 37.2695 15.1415C36.9824 15.294 36.7281 15.5016 36.5212 15.7524C36.3143 16.0032 36.1588 16.2922 36.0636 16.6031C35.9685 16.914 35.9354 17.2405 35.9665 17.5642C35.9975 17.8878 36.092 18.2021 36.2445 18.4892L38.1801 22.1328C38.206 22.1815 38.2294 22.2313 38.2502 22.2823C38.2654 22.3194 38.2777 22.357 38.29 22.3945C38.2945 22.4081 38.3 22.4213 38.3041 22.4349C38.3179 22.481 38.3289 22.5274 38.3384 22.574C38.3396 22.5798 38.3415 22.5854 38.3427 22.5911C38.352 22.6389 38.3585 22.687 38.3637 22.7351C38.3641 22.7402 38.3653 22.7454 38.3658 22.7506C38.3701 22.7966 38.3716 22.8425 38.3723 22.8881C38.3722 22.8964 38.3732 22.9042 38.3731 22.9123C38.373 22.9544 38.3701 22.9962 38.3666 23.038C38.3657 23.0501 38.3657 23.0621 38.3647 23.074C38.3606 23.1131 38.3539 23.1521 38.3471 23.1911C38.3446 23.2052 38.3434 23.2199 38.3405 23.234C38.3308 23.2804 38.3192 23.3264 38.3057 23.3719C38.3038 23.378 38.3028 23.3842 38.3009 23.3903C38.299 23.3964 38.2963 23.4023 38.2945 23.4087C38.2801 23.4536 38.2645 23.4984 38.2462 23.542C38.241 23.5542 38.2346 23.5655 38.2295 23.5775C38.2132 23.6151 38.1956 23.652 38.1766 23.6883C38.1718 23.697 38.1663 23.7051 38.1613 23.7136C38.1399 23.7521 38.1175 23.7905 38.0927 23.8274L38.0838 23.8397C38.0561 23.88 38.0273 23.9192 37.996 23.9571L37.9924 23.9611C37.9593 24.001 37.9244 24.0393 37.8876 24.0761L37.8842 24.0793C37.8473 24.116 37.8086 24.151 37.7684 24.184C37.7618 24.1893 37.7544 24.194 37.7476 24.1994C37.7124 24.2272 37.6765 24.2547 37.6384 24.2801C37.5928 24.3106 37.5456 24.3388 37.4972 24.3645C36.7315 24.7712 36.0535 25.3247 35.5018 25.9935C34.9501 26.6622 34.5355 27.433 34.2817 28.262C34.0279 29.0909 33.9399 29.9617 34.0226 30.8247C34.1054 31.6877 34.3573 32.5259 34.764 33.2915C34.8674 33.483 34.9319 33.6931 34.9537 33.9097C34.9756 34.1262 34.9544 34.345 34.8914 34.5533C34.8284 34.7616 34.7248 34.9554 34.5865 35.1235C34.4483 35.2917 34.2782 35.4308 34.086 35.5329C33.8938 35.635 33.6833 35.6981 33.4666 35.7185C33.2499 35.7389 33.0313 35.7163 32.8234 35.6519C32.6155 35.5875 32.4224 35.4826 32.2552 35.3433C32.088 35.2039 31.95 35.0329 31.8491 34.84C30.7516 32.7731 30.4213 30.3844 30.9165 28.0972C31.4118 25.8099 32.7007 23.7718 34.5549 22.344L33.3298 20.0378L33.3294 20.0367L27.5229 9.10657C27.2149 8.52674 26.6892 8.09301 26.0613 7.90081C25.4335 7.70861 24.7551 7.77367 24.1753 8.08169C23.5954 8.38971 23.1617 8.91545 22.9695 9.54325C22.7773 10.1711 22.8424 10.8495 23.1504 11.4293L28.1828 20.9029C28.3882 21.2894 28.4316 21.7417 28.3034 22.1603C28.1753 22.5788 27.8861 22.9293 27.4996 23.1347C27.113 23.34 26.6607 23.3834 26.2421 23.2552C25.8236 23.1271 25.4731 22.8379 25.2678 22.4514L20.2351 12.9778L20.2322 12.9717L18.6868 10.0628C18.5343 9.77565 18.3267 9.5214 18.0759 9.31452C17.8252 9.10763 17.5361 8.95217 17.2252 8.857C16.9144 8.76183 16.5878 8.72882 16.2642 8.75986C15.9406 8.79089 15.6263 8.88537 15.3392 9.03788C14.7593 9.3459 14.3256 9.87164 14.1334 10.4994C14.0382 10.8103 14.0052 11.1369 14.0363 11.4605C14.0673 11.7841 14.1618 12.0984 14.3143 12.3855L17.4107 18.2144L17.4113 18.2155L21.6697 26.2318C21.7805 26.4379 21.846 26.6652 21.8618 26.8986C21.8776 27.132 21.8433 27.3661 21.7613 27.5852C21.6793 27.8044 21.5514 28.0034 21.3862 28.1691C21.221 28.3347 21.0223 28.4632 20.8034 28.5458C20.7852 28.5528 20.767 28.5585 20.7491 28.5649C20.7028 28.5808 20.6564 28.5945 20.6097 28.6062C20.591 28.611 20.5726 28.6157 20.5539 28.6198C20.4963 28.6321 20.438 28.6414 20.3793 28.6475L20.3638 28.6498C20.2999 28.6554 20.2357 28.6573 20.1716 28.6554C20.1664 28.6552 20.1611 28.6559 20.1557 28.6557C20.145 28.6553 20.1343 28.6531 20.1234 28.6525C20.0734 28.6497 20.0238 28.6447 19.9745 28.6375C19.9568 28.6348 19.9393 28.6325 19.9217 28.6294C19.8627 28.619 19.8044 28.6054 19.7469 28.5886L19.7346 28.5857C19.673 28.5667 19.6126 28.5442 19.5537 28.5182C19.5384 28.5115 19.5238 28.5041 19.509 28.497C19.4642 28.476 19.4203 28.4529 19.3775 28.4278C19.3621 28.419 19.3467 28.4102 19.3316 28.4005C19.278 28.3672 19.2263 28.3307 19.1769 28.2915L19.1735 28.2889C19.1225 28.2471 19.0741 28.2024 19.0285 28.1549C19.0166 28.1425 19.0053 28.1293 18.9936 28.1165C18.9591 28.0787 18.9264 28.0392 18.8956 27.9979C18.8847 27.9835 18.8739 27.9695 18.8632 27.9546C18.8235 27.8988 18.7873 27.8406 18.7548 27.7803L14.4964 19.764C14.1859 19.1887 13.6605 18.7596 13.0348 18.5704C12.409 18.3811 11.7339 18.447 11.1566 18.7537C10.5793 19.0604 10.1467 19.583 9.95327 20.2074C9.75984 20.8318 9.82126 21.5074 10.1241 22.0868L18.2538 37.3903C20.1019 40.8692 23.2563 43.4715 27.0231 44.6247C30.7899 45.7778 34.8605 45.3874 38.3394 43.5393C41.8184 41.6912 44.4207 38.5368 45.5739 34.77C46.727 31.0032 46.3366 26.9326 44.4885 23.4537Z\"\n                fill=\"url(#paint0_linear_881_42604)\"\n            />\n            <path\n                d=\"M44.4885 23.4537L40.6172 16.1665C40.4647 15.8794 40.2571 15.6251 40.0063 15.4182C39.7556 15.2113 39.4665 15.0558 39.1556 14.9607C38.8448 14.8655 38.5182 14.8325 38.1946 14.8635C37.8709 14.8945 37.5566 14.989 37.2695 15.1415C36.9824 15.294 36.7281 15.5016 36.5212 15.7524C36.3143 16.0032 36.1588 16.2922 36.0636 16.6031C35.9685 16.914 35.9354 17.2405 35.9665 17.5642C35.9975 17.8878 36.092 18.2021 36.2445 18.4892L38.1801 22.1328C38.206 22.1815 38.2294 22.2313 38.2502 22.2823C38.2654 22.3194 38.2777 22.357 38.29 22.3945C38.2945 22.4081 38.3 22.4213 38.3041 22.4349C38.3179 22.481 38.3289 22.5274 38.3384 22.574C38.3396 22.5798 38.3415 22.5854 38.3427 22.5911C38.352 22.6389 38.3585 22.687 38.3637 22.7351C38.3641 22.7402 38.3653 22.7454 38.3658 22.7506C38.3701 22.7966 38.3716 22.8425 38.3723 22.8881C38.3723 22.8893 38.3723 22.8904 38.3723 22.8916C38.3724 22.8946 38.3725 22.8975 38.3727 22.9004C38.3727 22.9008 38.3727 22.9012 38.3728 22.9016C38.3729 22.9051 38.3731 22.9087 38.3731 22.9123C38.373 22.9544 38.3701 22.9962 38.3666 23.038C38.366 23.0469 38.3658 23.0556 38.3653 23.0644C38.3653 23.0654 38.3652 23.0663 38.3652 23.0673C38.365 23.0696 38.3649 23.0718 38.3647 23.074C38.3606 23.1131 38.3539 23.1521 38.3471 23.1911C38.3446 23.2052 38.3434 23.2199 38.3405 23.234C38.3308 23.2804 38.3192 23.3264 38.3057 23.3719C38.3038 23.378 38.3028 23.3842 38.3009 23.3903C38.299 23.3964 38.2963 23.4023 38.2945 23.4087C38.2801 23.4536 38.2645 23.4984 38.2462 23.542C38.2442 23.5467 38.242 23.5513 38.2398 23.5559C38.239 23.5574 38.2383 23.5588 38.2376 23.5603C38.2351 23.5654 38.2326 23.5704 38.2303 23.5756C38.23 23.5762 38.2297 23.5769 38.2295 23.5775C38.2132 23.6151 38.1956 23.652 38.1766 23.6883C38.1718 23.697 38.1663 23.7051 38.1613 23.7136C38.1399 23.7521 38.1175 23.7905 38.0927 23.8274L38.0838 23.8397C38.0561 23.88 38.0273 23.9192 37.996 23.9571L37.9924 23.9611C37.9593 24.001 37.9244 24.0393 37.8876 24.0761L37.8842 24.0793C37.8473 24.116 37.8086 24.151 37.7684 24.184C37.7618 24.1893 37.7544 24.194 37.7476 24.1994C37.7124 24.2272 37.6765 24.2547 37.6384 24.2801C37.5928 24.3106 37.5456 24.3388 37.4972 24.3645C36.7315 24.7712 36.0535 25.3247 35.5018 25.9935C34.9501 26.6622 34.5355 27.433 34.2817 28.262C34.0279 29.0909 33.9399 29.9617 34.0226 30.8247C34.1054 31.6877 34.3573 32.5259 34.764 33.2915C34.8674 33.483 34.9319 33.6931 34.9537 33.9097C34.9756 34.1262 34.9544 34.345 34.8914 34.5533C34.8284 34.7616 34.7248 34.9554 34.5865 35.1235C34.4483 35.2917 34.2782 35.4308 34.086 35.5329C33.8938 35.635 33.6833 35.6981 33.4666 35.7185C33.2499 35.7389 33.0313 35.7163 32.8234 35.6519C32.6155 35.5875 32.4224 35.4826 32.2552 35.3433C32.088 35.2039 31.95 35.0329 31.8491 34.84C30.7516 32.7731 30.4213 30.3844 30.9165 28.0972C31.4118 25.8099 32.7007 23.7718 34.5549 22.344L33.3298 20.0378L33.3294 20.0367L27.5229 9.10657C27.2149 8.52674 26.6892 8.09301 26.0613 7.90081C25.4335 7.70861 24.7551 7.77367 24.1753 8.08169C23.5954 8.38971 23.1617 8.91545 22.9695 9.54325C22.7773 10.1711 22.8424 10.8495 23.1504 11.4293L28.1828 20.9029C28.3882 21.2894 28.4316 21.7417 28.3034 22.1603C28.1753 22.5788 27.8861 22.9293 27.4996 23.1347C27.113 23.34 26.6607 23.3834 26.2421 23.2552C25.8236 23.1271 25.4731 22.8379 25.2678 22.4514L20.2351 12.9778L20.2322 12.9717L18.6868 10.0628C18.5343 9.77565 18.3267 9.5214 18.0759 9.31452C17.8252 9.10763 17.5361 8.95217 17.2252 8.857C16.9144 8.76183 16.5878 8.72882 16.2642 8.75986C15.9406 8.79089 15.6263 8.88537 15.3392 9.03788C14.7593 9.3459 14.3256 9.87164 14.1334 10.4994C14.0382 10.8103 14.0052 11.1369 14.0363 11.4605C14.0673 11.7841 14.1618 12.0984 14.3143 12.3855L17.4107 18.2144L17.4113 18.2155L21.6697 26.2318C21.7805 26.4379 21.846 26.6652 21.8618 26.8986C21.8776 27.132 21.8433 27.3661 21.7613 27.5852C21.6793 27.8044 21.5514 28.0034 21.3862 28.1691C21.221 28.3347 21.0223 28.4632 20.8034 28.5458C20.7985 28.5477 20.7936 28.5495 20.7887 28.5512C20.7841 28.5528 20.7795 28.5544 20.775 28.556C20.7728 28.5567 20.7707 28.5575 20.7685 28.5582C20.7666 28.5588 20.7647 28.5595 20.7628 28.5601C20.7582 28.5617 20.7536 28.5633 20.7491 28.5649C20.7028 28.5808 20.6564 28.5945 20.6097 28.6062L20.602 28.6082C20.5859 28.6122 20.5701 28.6162 20.5539 28.6198C20.4963 28.6321 20.438 28.6414 20.3793 28.6475L20.3638 28.6498C20.2999 28.6554 20.2357 28.6573 20.1716 28.6554C20.1664 28.6552 20.1611 28.6559 20.1557 28.6557C20.1548 28.6556 20.1538 28.6556 20.1529 28.6555C20.1482 28.6552 20.1435 28.6546 20.1388 28.654C20.1357 28.6536 20.1326 28.6533 20.1295 28.6529C20.1275 28.6527 20.1254 28.6526 20.1234 28.6525C20.0734 28.6497 20.0238 28.6447 19.9745 28.6375C19.9568 28.6348 19.9393 28.6325 19.9217 28.6294C19.8627 28.619 19.8044 28.6054 19.7469 28.5886L19.7346 28.5857C19.673 28.5667 19.6126 28.5442 19.5537 28.5182C19.5384 28.5115 19.5238 28.5041 19.509 28.497C19.4642 28.476 19.4203 28.4529 19.3775 28.4278C19.3621 28.419 19.3467 28.4102 19.3316 28.4005C19.278 28.3672 19.2263 28.3307 19.1769 28.2915L19.1735 28.2889C19.1225 28.2471 19.0741 28.2024 19.0285 28.1549C19.0166 28.1425 19.0053 28.1293 18.9936 28.1165C18.9591 28.0787 18.9264 28.0392 18.8956 27.9979C18.8847 27.9835 18.8739 27.9695 18.8632 27.9546C18.8235 27.8988 18.7873 27.8406 18.7548 27.7803L14.4964 19.764C14.1859 19.1887 13.6605 18.7596 13.0348 18.5704C12.409 18.3811 11.7339 18.447 11.1566 18.7537C10.5793 19.0604 10.1467 19.583 9.95327 20.2074C9.75984 20.8318 9.82126 21.5074 10.1241 22.0868L18.2538 37.3903C20.1019 40.8692 23.2563 43.4715 27.0231 44.6247C30.7899 45.7778 34.8605 45.3874 38.3394 43.5393C41.8184 41.6912 44.4207 38.5368 45.5739 34.77C46.727 31.0032 46.3366 26.9326 44.4885 23.4537Z\"\n                fill=\"#16161A\"\n            />\n            <path\n                fillRule=\"evenodd\"\n                clipRule=\"evenodd\"\n                d=\"M38.3727 22.9004C38.3725 22.8975 38.3724 22.8946 38.3723 22.8916C38.3724 22.895 38.3726 22.8983 38.3728 22.9016C38.3727 22.9012 38.3727 22.9008 38.3727 22.9004ZM38.3653 23.0644C38.3653 23.0654 38.3652 23.0663 38.3652 23.0673C38.365 23.0696 38.3649 23.0718 38.3647 23.074M38.2303 23.5756C38.2326 23.5704 38.2351 23.5654 38.2376 23.5603C38.2383 23.5588 38.239 23.5574 38.2398 23.5559C38.2365 23.5624 38.2333 23.5689 38.2303 23.5756ZM20.7628 28.5601C20.7647 28.5595 20.7666 28.5588 20.7685 28.5582C20.7707 28.5575 20.7728 28.5567 20.775 28.556C20.7709 28.5574 20.7669 28.5588 20.7628 28.5601Z\"\n                fill=\"#16161A\"\n            />\n            <defs>\n                <linearGradient\n                    id=\"paint0_linear_881_42604\"\n                    x1=\"30.2727\"\n                    y1=\"10.124\"\n                    x2=\"48.9925\"\n                    y2=\"42.668\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#4CC9F0\" />\n                    <stop offset=\"0.28684\" stopColor=\"#4361EE\" />\n                    <stop offset=\"0.524848\" stopColor=\"#7209B7\" />\n                    <stop offset=\"0.83892\" stopColor=\"#F72585\" />\n                </linearGradient>\n            </defs>\n        </svg>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/steps/Profile.tsx",
    "content": "import {\n    Button,\n    Checkbox,\n    DatePicker,\n    Listbox,\n    LoadingSpinner,\n    Tooltip,\n} from '@maybe-finance/design-system'\nimport classNames from 'classnames'\nimport { DateTime } from 'luxon'\nimport { type PropsWithChildren, type ReactNode, useState, useEffect, useCallback } from 'react'\nimport AnimateHeight from 'react-animate-height'\nimport { Controller, useForm, useFormState } from 'react-hook-form'\nimport type { IconType } from 'react-icons'\nimport {\n    RiArrowDownSLine,\n    RiArrowUpSLine,\n    RiCakeLine,\n    RiCheckLine,\n    RiFlagLine,\n    RiFocus2Line,\n    RiFolder2Line,\n    RiHome5Line,\n    RiMapPin2Line,\n    RiQuestionLine,\n} from 'react-icons/ri'\nimport { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'\nimport type { StepProps } from './StepProps'\nimport { Switch } from '@headlessui/react'\nimport { BrowserUtil, useUserApi } from '@maybe-finance/client/shared'\nimport type { Household, MaybeGoal } from '@prisma/client'\nimport { DateUtil, Geo } from '@maybe-finance/shared'\n\ntype FormValues = {\n    dob: string | null\n    household: Household | null\n    country: string\n    state: string\n    maybeGoals?: MaybeGoal[]\n    maybeGoalsDescription?: string\n}\n\nexport function Profile({ title, onNext }: StepProps) {\n    const { useUpdateProfile, useProfile } = useUserApi()\n    const updateProfile = useUpdateProfile({ onSuccess: undefined })\n\n    const profile = useProfile({})\n\n    if (profile.isError) {\n        return <p>Something went wrong</p>\n    }\n\n    if (profile.isLoading) {\n        return <LoadingSpinner />\n    }\n\n    const user = profile.data\n\n    return (\n        <ProfileForm\n            title={title}\n            defaultValues={{\n                dob: DateUtil.dateTransform(user.dob),\n                household: user.household,\n                country: user.country ?? '',\n                state: user.state ?? '',\n                maybeGoals: user.maybeGoals ?? [],\n                maybeGoalsDescription: user.maybeGoalsDescription ?? '',\n            }}\n            onSubmit={async (data) => {\n                await updateProfile.mutateAsync({\n                    dob: data.dob,\n                    household: data.household,\n                    country: data.country,\n                    state: null, // should always be null for now\n                    maybeGoals: data.maybeGoals,\n                    maybeGoalsDescription: data.maybeGoalsDescription,\n                })\n\n                await onNext()\n            }}\n        />\n    )\n}\n\ntype ProfileViewProps = {\n    title: string\n    onSubmit(data: FormValues): void\n    defaultValues: FormValues\n}\n\nfunction ProfileForm({ title, onSubmit, defaultValues }: ProfileViewProps) {\n    const [currentQuestion, setCurrentQuestion] = useState<\n        'birthday' | 'household' | 'residence' | 'goals'\n    >('birthday')\n\n    const {\n        control,\n        register,\n        handleSubmit,\n        trigger,\n        watch,\n        formState: { isValid, isSubmitting },\n    } = useForm<FormValues>({\n        mode: 'onChange',\n        defaultValues,\n    })\n\n    const country = watch('country')\n\n    useEffect(() => {\n        trigger()\n    }, [currentQuestion, trigger])\n\n    const { errors } = useFormState({ control })\n\n    return (\n        <div className=\"w-full max-w-md mx-auto\">\n            <h3 className=\"text-center text-pretty\">{title}</h3>\n            <p className=\"mt-4 text-base text-gray-50\">\n                We&rsquo;ll need a few things from you to offer a personal experience when it comes\n                to building plans or receiving advice from our advisors.\n            </p>\n            <div className=\"mt-5 space-y-3\">\n                <Question\n                    open={currentQuestion === 'birthday'}\n                    valid={!errors.dob}\n                    icon={RiCakeLine}\n                    label={<>When&rsquo;s your birthday?</>}\n                    onClick={() => setCurrentQuestion('birthday')}\n                    next={() => setCurrentQuestion('household')}\n                >\n                    <Controller\n                        control={control}\n                        name=\"dob\"\n                        rules={{\n                            validate: (d) =>\n                                BrowserUtil.validateFormDate(d, {\n                                    minDate: DateTime.now().minus({ years: 100 }).toISODate(),\n                                    required: true,\n                                }),\n                        }}\n                        render={({ field, fieldState: { error } }) => (\n                            <DatePicker\n                                popperPlacement=\"bottom\"\n                                className=\"mt-2\"\n                                minCalendarDate={DateTime.now().minus({ years: 100 }).toISODate()}\n                                error={error?.message}\n                                {...field}\n                            />\n                        )}\n                    />\n                    <Tooltip\n                        placement=\"bottom-start\"\n                        content={\n                            <>\n                                We use your age to personalize plans to your context instead of\n                                showing years when referring to future events. &ldquo;Retire at\n                                45&rdquo; sounds better than &ldquo;Retire in 2043&rdquo;.\n                            </>\n                        }\n                    >\n                        <div className=\"flex items-center mt-4 text-base text-gray-50 cursor-default\">\n                            <RiQuestionLine className=\"w-5 h-5 mr-2 text-gray-100\" />\n                            Why do we need your age?\n                        </div>\n                    </Tooltip>\n                </Question>\n\n                <Question\n                    open={currentQuestion === 'household'}\n                    valid={!errors.household}\n                    icon={RiHome5Line}\n                    label=\"Which best describes your household?\"\n                    onClick={() => setCurrentQuestion('household')}\n                    back={() => setCurrentQuestion('birthday')}\n                    next={() => setCurrentQuestion('residence')}\n                >\n                    <div className=\"space-y-2\">\n                        <Controller\n                            control={control}\n                            name=\"household\"\n                            rules={{ required: true }}\n                            render={({ field }) => (\n                                <>\n                                    {Object.entries({\n                                        single: 'Single income, no dependents',\n                                        singleWithDependents:\n                                            'Single income, at least one dependent',\n                                        dual: 'Dual income, no dependents',\n                                        dualWithDependents: 'Dual income, at least one dependent',\n                                        retired: 'Retired or financially independent',\n                                    }).map(([value, label]) => (\n                                        <Checkbox\n                                            key={value}\n                                            label={label}\n                                            checked={field.value === value}\n                                            onChange={(checked) => {\n                                                if (checked) field.onChange(value)\n                                            }}\n                                        />\n                                    ))}\n                                </>\n                            )}\n                        />\n                    </div>\n                </Question>\n\n                <Question\n                    open={currentQuestion === 'residence'}\n                    valid={!errors.country}\n                    icon={RiMapPin2Line}\n                    label=\"Where are you based?\"\n                    onClick={() => setCurrentQuestion('residence')}\n                    back={() => setCurrentQuestion('household')}\n                    next={() => setCurrentQuestion('goals')}\n                >\n                    <div className=\"space-y-2\">\n                        <Controller\n                            name=\"country\"\n                            rules={{ required: true }}\n                            defaultValue=\"US\"\n                            control={control}\n                            render={({ field }) => (\n                                <Listbox {...field}>\n                                    <Listbox.Button label=\"Country\">\n                                        {Geo.countries.find((c) => c.code === field.value)?.name ||\n                                            'Select'}\n                                    </Listbox.Button>\n                                    <Listbox.Options className=\"max-h-[300px] custom-gray-scroll\">\n                                        {Geo.countries.map((country) => (\n                                            <Listbox.Option key={country.code} value={country.code}>\n                                                {country.name}\n                                            </Listbox.Option>\n                                        ))}\n                                    </Listbox.Options>\n                                </Listbox>\n                            )}\n                        />\n                    </div>\n                    <Tooltip\n                        placement=\"bottom-start\"\n                        content={\n                            <>\n                                We use your location to provide accurate advice and ensure\n                                regulatory compliance.\n                            </>\n                        }\n                    >\n                        <div className=\"flex items-center mt-4 text-base text-gray-50 cursor-default\">\n                            <RiQuestionLine className=\"w-5 h-5 mr-2 text-gray-100\" />\n                            Why do we need your location?\n                        </div>\n                    </Tooltip>\n                </Question>\n\n                <Question\n                    open={currentQuestion === 'goals'}\n                    icon={RiFocus2Line}\n                    label=\"What do you hope to achieve with Maybe?\"\n                    onClick={() => setCurrentQuestion('goals')}\n                    back={() => setCurrentQuestion('residence')}\n                    submit={handleSubmit(onSubmit)}\n                    valid={isValid}\n                    loading={isSubmitting}\n                >\n                    <Controller\n                        control={control}\n                        name=\"maybeGoals\"\n                        render={({ field }) => (\n                            <div className=\"flex flex-col space-y-2\">\n                                {Object.entries({\n                                    aggregate: [RiFolder2Line, 'See all my accounts in one place'],\n                                    plan: [\n                                        RiFlagLine,\n                                        'Build plans for retirement and other milestones',\n                                    ],\n                                }).map(([value, [Icon, label]]) => {\n                                    const checked = field.value?.includes(value as MaybeGoal)\n                                    return (\n                                        <Switch\n                                            key={value}\n                                            className={classNames(\n                                                'flex items-center p-3 text-base rounded-xl border transition-colors duration-100',\n                                                checked\n                                                    ? 'bg-gray-600 bg-opacity-50 border-cyan'\n                                                    : 'backdrop-blur-sm border-gray-100 border-opacity-10'\n                                            )}\n                                            checked={checked}\n                                            onChange={(check: boolean) => {\n                                                if (check && !checked)\n                                                    field.onChange([\n                                                        ...(field.value ? field.value : []),\n                                                        value,\n                                                    ])\n                                                else if (!check && checked)\n                                                    field.onChange(\n                                                        field.value!.filter((v) => v !== value)\n                                                    )\n                                            }}\n                                        >\n                                            <>\n                                                <Icon\n                                                    className={classNames(\n                                                        'w-6 h-6 mr-3',\n                                                        checked ? 'text-cyan' : 'text-gray-100'\n                                                    )}\n                                                />\n                                                {label}\n                                                {checked && (\n                                                    <div className=\"grow flex justify-end\">\n                                                        <div className=\"p-0.5 bg-cyan rounded-full\">\n                                                            <RiCheckLine className=\"w-3 h-3 text-black\" />\n                                                        </div>\n                                                    </div>\n                                                )}\n                                            </>\n                                        </Switch>\n                                    )\n                                })}\n                            </div>\n                        )}\n                    />\n\n                    <label className=\"block mt-3\">\n                        <span className=\"text-base text-gray-50\">Other</span>\n                        <textarea\n                            rows={4}\n                            className=\"block w-full bg-gray-500 text-base placeholder:text-gray-100 rounded border-0 focus:ring-0 resize-none\"\n                            placeholder=\"Enter any other things you'd like to do with Maybe\"\n                            {...register('maybeGoalsDescription')}\n                            onKeyDown={(e) => e.key === 'Enter' && e.stopPropagation()}\n                        />\n                    </label>\n                </Question>\n            </div>\n        </div>\n    )\n}\n\nfunction Question({\n    open,\n    valid = false,\n    icon: Icon,\n    label,\n    onClick,\n    back,\n    next,\n    submit,\n    loading = false,\n    children,\n}: PropsWithChildren<{\n    open: boolean\n    valid?: boolean\n    icon: IconType\n    label: ReactNode\n    onClick: () => void\n    back?: () => void\n    next?: () => void\n    submit?: () => void\n    loading?: boolean\n}>) {\n    const keyDown = useCallback(\n        (e: KeyboardEvent) => {\n            if (!open) return\n\n            switch (e.key) {\n                case 'Enter':\n                    if (!valid) break\n                    if (next) next()\n                    else if (submit) submit()\n                    break\n                case '\\\\':\n                    if (back) back()\n                    break\n            }\n        },\n        [open, back, next, submit, valid]\n    )\n\n    useEffect(() => {\n        document.addEventListener('keydown', keyDown)\n        return () => document.removeEventListener('keydown', keyDown)\n    }, [keyDown])\n\n    return (\n        <div>\n            <button\n                className=\"w-full group flex items-center p-3 -mx-3 rounded-xl hover:bg-gray-100/5 transition-colors duration-75\"\n                type=\"button\"\n                onClick={onClick}\n            >\n                <Icon\n                    className={classNames(\n                        'w-5 h-5 transition-colors duration-75',\n                        open ? 'text-gray-100' : 'text-gray-300 group-hover:text-gray-200'\n                    )}\n                />\n                <span\n                    className={classNames(\n                        'ml-2 text-base transition-colors duration-75',\n                        open ? 'text-gray-25' : 'text-gray-200 group-hover:text-gray-100'\n                    )}\n                >\n                    {label}\n                </span>\n            </button>\n            <AnimateHeight height={open ? 'auto' : 0} duration={150}>\n                <div className=\"mt-3\">{children}</div>\n                {(next || back || submit) && (\n                    <div className=\"flex items-center justify-between mt-3\">\n                        <span className=\"invisible sm:visible flex items-center space-x-3 text-sm text-gray-100\">\n                            {back && (\n                                <div className=\"flex items-center\">\n                                    <span className=\"mr-2 py-0.5 px-1 bg-gray-700 rounded-sm text-white\">\n                                        \\\n                                    </span>\n                                    Back\n                                </div>\n                            )}\n                            {(next || submit) && valid && (\n                                <div className=\"flex items-center\">\n                                    <span className=\"mr-2 py-0.5 px-1 bg-gray-700 rounded-sm text-white\">\n                                        Enter &#x23CE;\n                                    </span>\n                                    {submit ? 'Finish' : 'Next'}\n                                </div>\n                            )}\n                        </span>\n                        <div className=\"flex items-center space-x-3\">\n                            {back && (\n                                <Button onClick={back} variant=\"secondary\">\n                                    {submit ? 'Back' : <RiArrowUpSLine className=\"w-5 h-5\" />}\n                                </Button>\n                            )}\n                            {next && (\n                                <Button onClick={next} disabled={!valid}>\n                                    <RiArrowDownSLine className=\"w-5 h-5\" />\n                                </Button>\n                            )}\n                            {submit && (\n                                <Button onClick={submit} disabled={!valid}>\n                                    {loading ? (\n                                        <LoadingIcon className=\"w-6 h-6 p-1 mx-2 text-gray animate-spin\" />\n                                    ) : (\n                                        'Finish'\n                                    )}\n                                </Button>\n                            )}\n                        </div>\n                    </div>\n                )}\n            </AnimateHeight>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/steps/StepProps.ts",
    "content": "export type StepProps = {\n    title: string\n    onNext(): Promise<void>\n    onPrev(): Promise<void>\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/steps/Welcome.tsx",
    "content": "import { useState, useEffect } from 'react'\nimport { useForm } from 'react-hook-form'\nimport classNames from 'classnames'\nimport {\n    RiAnticlockwise2Line,\n    RiArrowGoBackFill,\n    RiArrowRightLine,\n    RiDownloadLine,\n    RiShareForwardLine,\n} from 'react-icons/ri'\nimport { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'\nimport { Button, Tooltip } from '@maybe-finance/design-system'\nimport { Confetti, MaybeCard, MaybeCardShareModal, useUserApi } from '@maybe-finance/client/shared'\nimport type { StepProps } from './StepProps'\nimport { UserUtil } from '@maybe-finance/shared'\n\nexport function Welcome({ title: stepTitle, onNext }: StepProps) {\n    const { useMemberCardDetails, useUpdateProfile } = useUserApi()\n\n    const { data } = useMemberCardDetails()\n\n    const updateProfile = useUpdateProfile({ onSuccess: undefined })\n\n    const [isCardFlipped, setIsCardFlipped] = useState(false)\n    const [isShareModalOpen, setIsShareModalOpen] = useState(false)\n\n    const {\n        handleSubmit,\n        formState: { isSubmitting, isValid },\n        watch,\n        setValue,\n    } = useForm<{\n        title: string\n    }>({\n        mode: 'onChange',\n    })\n\n    const title = watch('title')\n\n    useEffect(() => {\n        if (data && !title)\n            setValue('title', data.title ?? UserUtil.randomUserTitle(), {\n                shouldValidate: true,\n            })\n    }, [title, data, setValue])\n\n    return (\n        <>\n            <form\n                className=\"flex items-center justify-center max-w-5xl mx-auto mt-16 md:mt-[20vh] gap-16 md:gap-32 flex-wrap md:flex-nowrap pb-24\"\n                onSubmit={handleSubmit(async (data) => {\n                    await updateProfile.mutateAsync(data)\n                    await onNext()\n                })}\n            >\n                <div className=\"max-w-md grow\">\n                    <img src=\"/assets/maybe.svg\" className=\"h-8\" alt=\"Maybe\" />\n                    <h3 className=\"mt-14 text-pretty\">{stepTitle}</h3>\n                    <p className=\"mt-2 text-base text-gray-50\">\n                        We made you a little something to celebrate you taking your first steps in\n                        Maybe. Feel free to share and don&rsquo;t forget to flip the card!\n                    </p>\n                    <Button type=\"submit\" className=\"mt-14\" disabled={!isValid}>\n                        Start exploring\n                        {isSubmitting ? (\n                            <LoadingIcon className=\"w-5 h-5 ml-2 animate-spin\" />\n                        ) : (\n                            <RiArrowRightLine className=\"w-5 h-5 ml-2\" />\n                        )}\n                    </Button>\n                </div>\n                <div className=\"relative shrink-0\">\n                    <fieldset className=\"flex items-center justify-center border border-gray-400 border-dashed rounded-3xl\">\n                        <legend className=\"mx-auto text-sm text-gray-100 px-7\">\n                            Your Maybe card\n                        </legend>\n                        <MaybeCard\n                            variant=\"onboarding\"\n                            flipped={isCardFlipped}\n                            details={data ? { ...data, title } : undefined}\n                        />\n                        <MaybeCardShareModal\n                            isOpen={isShareModalOpen}\n                            onClose={() => setIsShareModalOpen(false)}\n                            cardUrl={data?.cardUrl || ''}\n                            card={{ details: data }}\n                        />\n                    </fieldset>\n                    <div className=\"flex justify-center w-full gap-3 mt-6\">\n                        <Tooltip content=\"Share\" placement=\"bottom\">\n                            <div className=\"w-full\">\n                                <Button\n                                    fullWidth\n                                    type=\"button\"\n                                    variant=\"secondary\"\n                                    disabled={!data}\n                                    onClick={() => {\n                                        // Make sure title is persisted for sharing\n                                        updateProfile.mutateAsync({ title })\n\n                                        setIsShareModalOpen(true)\n                                    }}\n                                >\n                                    <RiShareForwardLine className=\"w-5 h-5 text-gray-50\" />\n                                </Button>\n                            </div>\n                        </Tooltip>\n                        <Tooltip content=\"Download\" placement=\"bottom\">\n                            <div className=\"w-full\">\n                                <Button\n                                    as=\"a\"\n                                    fullWidth\n                                    variant=\"secondary\"\n                                    className={classNames(\n                                        !data && 'opacity-50 pointer-events-none'\n                                    )}\n                                    href={data?.imageUrl}\n                                    download=\"/assets/maybe-card.png\"\n                                >\n                                    <RiDownloadLine className=\"w-5 h-5 text-gray-50\" />\n                                </Button>\n                            </div>\n                        </Tooltip>\n                        <Tooltip content=\"Randomize title\" placement=\"bottom\">\n                            <div className=\"w-full\">\n                                <Button\n                                    fullWidth\n                                    type=\"button\"\n                                    variant=\"secondary\"\n                                    onClick={() =>\n                                        setValue('title', UserUtil.randomUserTitle(title), {\n                                            shouldValidate: true,\n                                        })\n                                    }\n                                >\n                                    <RiArrowGoBackFill className=\"w-5 h-5 text-gray-50\" />\n                                </Button>\n                            </div>\n                        </Tooltip>\n                        <Tooltip content=\"Flip card\" placement=\"bottom\">\n                            <div className=\"w-full\">\n                                <Button\n                                    fullWidth\n                                    type=\"button\"\n                                    variant=\"secondary\"\n                                    onClick={() => setIsCardFlipped((flipped) => !flipped)}\n                                >\n                                    <RiAnticlockwise2Line className=\"w-5 h-5 text-gray-50\" />\n                                </Button>\n                            </div>\n                        </Tooltip>\n                    </div>\n                </div>\n            </form>\n            <Confetti respawn={false} gravity={0.02} />\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/steps/YourMaybe.tsx",
    "content": "import { SCREEN, useScreenSize, useUserApi } from '@maybe-finance/client/shared'\nimport { Button, FractionalCircle } from '@maybe-finance/design-system'\nimport { useForm } from 'react-hook-form'\nimport {\n    RiArrowRightLine,\n    RiArtboard2Line,\n    RiBankCardLine,\n    RiBarChart2Line,\n    RiBitCoinLine,\n    RiBriefcaseLine,\n    RiBuilding3Line,\n    RiBuilding4Line,\n    RiBuildingLine,\n    RiCarLine,\n    RiFlashlightLine,\n    RiFlightTakeoffLine,\n    RiHeartLine,\n    RiHome5Line,\n    RiHomeHeartLine,\n    RiHomeLine,\n    RiHomeSmile2Line,\n    RiLineChartLine,\n    RiOpenArmLine,\n    RiPieChartLine,\n    RiSailboatLine,\n    RiScales2Line,\n    RiStackLine,\n    RiTrophyLine,\n} from 'react-icons/ri'\nimport { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'\nimport type { StepProps } from './StepProps'\nimport { UserUtil } from '@maybe-finance/shared'\n\nlet suggestions = Object.entries({\n    'Build a large and diverse investment portfolio': RiPieChartLine,\n    'Start a coffee shop': RiBriefcaseLine,\n    'Turn my side hustle into my own full-time business': RiBuildingLine,\n    'Start a foundation to support charitable causes': RiStackLine,\n    'Retire at age 40': RiFlashlightLine,\n    'Start a gym': RiOpenArmLine,\n    'Start an NFT gallery': RiArtboard2Line,\n    'Buy my dream car': RiCarLine,\n    'Pay off my loans so I can be debt free': RiScales2Line,\n    \"Save for my kid's college tuition\": RiLineChartLine,\n    'Save for my dream vacation': RiFlightTakeoffLine,\n    'Start a museum': RiBuilding3Line,\n    'Invest in real estate': RiHomeSmile2Line,\n    'Buy a second home or vacation property': RiHomeHeartLine,\n    'Save for a down payment on my first house': RiHomeLine,\n    'Start a profitable agency': RiBuilding4Line,\n    'Reach $5m with my business and retire': RiBarChart2Line,\n    'Build an indie bootstrapped startup': RiBriefcaseLine,\n    'Pay off all my credit card debt': RiBankCardLine,\n    'Become an angel investor': RiTrophyLine,\n    'Buy a sail boat and travel the world': RiSailboatLine,\n    'Get married in Lake Como in Italy': RiHeartLine,\n    'Build a modern cabin in the middle of the woods': RiHome5Line,\n    'Build a crypto ETF': RiBitCoinLine,\n    'Save for a round-the-world trip': RiFlightTakeoffLine,\n})\n\n// Duplicate suggestions to fill space better\nsuggestions = [...suggestions, ...suggestions]\n\nconst randoms = suggestions.map(() => [Math.random(), Math.random(), Math.random()])\n\nconst clamp = (num: number, min: number, max: number) => Math.min(Math.max(num, min), max)\n\nexport function YourMaybe({ title, onNext }: StepProps) {\n    const screen = useScreenSize()\n\n    const { useUpdateProfile } = useUserApi()\n    const updateProfile = useUpdateProfile({ onSuccess: undefined })\n\n    const {\n        register,\n        handleSubmit,\n        formState: { isValid, isSubmitting },\n        watch,\n        setValue,\n    } = useForm<{\n        maybe: string\n    }>({\n        mode: 'onChange',\n    })\n\n    const maybe = watch('maybe')\n\n    return (\n        <>\n            {screen === SCREEN.DESKTOP && (\n                <div className=\"fixed top-0 left-0 w-screen h-screen flex items-center justify-center text-sm overflow-hidden\">\n                    {suggestions.map(([suggestion, Icon], idx) => {\n                        const r = ((Math.PI * 2) / suggestions.length) * idx\n\n                        return (\n                            <div\n                                key={idx}\n                                className=\"absolute cursor-pointer select-none\"\n                                onClick={() =>\n                                    setValue('maybe', suggestion, { shouldValidate: true })\n                                }\n                                style={{\n                                    transform: `translateX(calc(${\n                                        clamp(Math.sin(r), -0.5, 0.5) * 92\n                                    }vw + ${randoms[idx][0] * 50 - 25}px)) translateY(calc(${\n                                        Math.cos(r) * 47\n                                    }vh + ${randoms[idx][1] * 30 - 15}px)) rotate(${\n                                        randoms[idx][2] * 30 - 15\n                                    }deg)`,\n                                }}\n                            >\n                                <div\n                                    className=\"max-w-[200px] p-px rounded-xl bg-gradient-to-b from-gray-800 to-gray-600 animate-float\"\n                                    style={{\n                                        animationDelay: `-${randoms[idx][0] * 3}s`,\n                                    }}\n                                >\n                                    <div className=\"flex items-center gap-3 p-3 bg-gradient-to-b from-gray-600 to-gray-800 rounded-xl\">\n                                        <Icon className=\"w-5 h-5 text-gray-100\" />\n                                        {suggestion}\n                                    </div>\n                                </div>\n                            </div>\n                        )\n                    })}\n                </div>\n            )}\n            <div className=\"relative w-full max-w-lg mx-auto mt-16 md:mt-[25vh]\">\n                <div className=\"flex justify-center\">\n                    <Fire />\n                </div>\n                <h2 className=\"mt-6 text-center text-pretty\">{title}</h2>\n                <p className=\"mt-2 text-center text-base text-gray-50\">\n                    A maybe is a goal or dream you&rsquo;re considering, but have not yet fully\n                    committed to because you&rsquo;re not yet sure if it&rsquo;s financially\n                    feasible.\n                </p>\n                <form\n                    onSubmit={handleSubmit(async (data) => {\n                        await updateProfile.mutateAsync(data)\n                        await onNext()\n                    })}\n                >\n                    <div className=\"relative\">\n                        <textarea\n                            rows={5}\n                            className=\"mt-6 block w-full bg-gray-500 text-base placeholder:text-gray-100 rounded border-0 focus:ring-0 resize-none\"\n                            placeholder=\"What's your Maybe?\"\n                            {...register('maybe', { required: true })}\n                            onKeyDown={(e) => e.key === 'Enter' && e.stopPropagation()}\n                            maxLength={UserUtil.MAX_MAYBE_LENGTH}\n                        />\n                        <div className=\"absolute bottom-0 right-0 flex items-center gap-1 px-3 py-2\">\n                            <FractionalCircle\n                                radius={6}\n                                percent={((maybe?.length ?? 0) / UserUtil.MAX_MAYBE_LENGTH) * 100}\n                            />\n                            <span className=\"text-sm text-gray-50\">\n                                {240 - (maybe?.length ?? 0)}\n                            </span>\n                        </div>\n                    </div>\n                    <Button\n                        type=\"submit\"\n                        fullWidth\n                        className=\"mt-6\"\n                        disabled={!isValid || isSubmitting}\n                    >\n                        Submit my Maybe\n                        {isSubmitting ? (\n                            <LoadingIcon className=\"ml-2 w-5 h-5 animate-spin\" />\n                        ) : (\n                            <RiArrowRightLine className=\"ml-2 w-5 h-5\" />\n                        )}\n                    </Button>\n                </form>\n            </div>\n        </>\n    )\n}\n\nfunction Fire() {\n    return (\n        <svg\n            width=\"51\"\n            height=\"56\"\n            viewBox=\"0 0 51 56\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n        >\n            <path\n                fillRule=\"evenodd\"\n                clipRule=\"evenodd\"\n                d=\"M32.3887 6.5807C32.2064 6.21552 31.9484 5.89332 31.632 5.63551C31.3156 5.37771 30.9479 5.1902 30.5534 5.08546C30.1589 4.98071 29.7467 4.96113 29.344 5.02801C28.9414 5.09489 28.5576 5.24669 28.2181 5.47334C27.2258 6.13488 26.4521 7.07829 25.8538 8.00445C25.2383 8.95362 24.6947 10.0552 24.2144 11.2144C23.2537 13.5269 22.4483 16.2996 21.7983 19.0781C21.0122 22.4735 20.4235 25.9116 20.0352 29.3751C18.8649 28.6206 17.9246 27.5589 17.3171 26.3061C16.3737 24.3502 16.1723 21.8939 16.1723 18.6725C16.1722 18.1037 16.0035 17.5477 15.6874 17.0749C15.3713 16.602 14.9222 16.2334 14.3967 16.0158C13.8712 15.7981 13.293 15.7412 12.7351 15.8521C12.1773 15.963 11.6648 16.2369 11.2626 16.639C9.39059 18.5069 7.90604 20.7263 6.89417 23.1696C5.88231 25.6128 5.36305 28.2319 5.36623 30.8765C5.36646 34.1874 6.18322 37.4472 7.74414 40.3671C9.30506 43.2869 11.562 45.7768 14.315 47.6161C17.068 49.4555 20.2321 50.5875 23.5271 50.9119C26.8221 51.2364 30.1462 50.7432 33.2051 49.4762C36.264 48.2091 38.9632 46.2072 41.0636 43.6479C43.1641 41.0885 44.6009 38.0506 45.2469 34.8033C45.8929 31.556 45.7281 28.1996 44.7671 25.0312C43.8061 21.8628 42.0786 18.9803 39.7375 16.639C38.0348 14.9391 36.9188 13.8059 35.8603 12.4195C34.8163 11.0504 33.7779 9.36205 32.3887 6.5807ZM31.5977 42.7267C30.391 43.9319 28.8542 44.7525 27.1814 45.0848C25.5085 45.4171 23.7748 45.2463 22.199 44.5938C20.6232 43.9413 19.2761 42.8365 18.3279 41.4189C17.3797 40.0013 16.8728 38.3345 16.8713 36.629C16.8713 36.629 19.3995 38.0671 24.0619 38.0671C24.0619 35.1909 25.5001 26.5621 27.6572 25.124C29.0954 28.0002 29.918 28.843 31.6006 30.5284C32.4032 31.3286 33.0397 32.2794 33.4736 33.3264C33.9075 34.3733 34.1302 35.4957 34.1288 36.629C34.1302 37.7623 33.9075 38.8847 33.4736 39.9316C33.0397 40.9786 32.4032 41.9294 31.6006 42.7295L31.5977 42.7267Z\"\n                fill=\"#1E1D23\"\n            />\n            <path\n                fillRule=\"evenodd\"\n                clipRule=\"evenodd\"\n                d=\"M32.3886 6.58171C32.2063 6.21652 31.9484 5.89432 31.6319 5.63652C31.3155 5.37871 30.9478 5.19121 30.5533 5.08646C30.1588 4.98172 29.7466 4.96214 29.3439 5.02901C28.9413 5.09589 28.5575 5.2477 28.218 5.47435C27.2257 6.13589 26.452 7.0793 25.8537 8.00546C25.2382 8.95462 24.6946 10.0562 24.2143 11.2154C23.2536 13.5279 22.4482 16.3006 21.7982 19.0791C21.6069 19.9054 21.4273 20.7342 21.2594 21.5653C21.0509 22.5977 20.8604 23.6335 20.6882 24.6724C20.6877 24.6755 20.6872 24.6786 20.6867 24.6817C20.4284 26.2402 20.2111 27.8055 20.0351 29.3761C18.8648 28.6216 17.9245 27.56 17.317 26.3071C16.6431 24.9099 16.3478 23.2573 16.2341 21.2529C16.1887 20.4514 16.1722 19.5937 16.1722 18.6735C16.1721 18.1047 16.0034 17.5487 15.6873 17.0759C15.3713 16.603 14.9221 16.2344 14.3966 16.0168C13.8711 15.7991 13.2929 15.7422 12.735 15.8531C12.1772 15.964 11.6647 16.2379 11.2625 16.64C9.39049 18.5079 7.90595 20.7273 6.89408 23.1706C5.88221 25.6139 5.36296 28.233 5.36613 30.8775C5.36637 34.1884 6.18312 37.4482 7.74405 40.3681C9.30497 43.2879 11.5619 45.7778 14.3149 47.6172C17.0679 49.4565 20.232 50.5885 23.527 50.913C26.822 51.2374 30.1462 50.7442 33.205 49.4772C36.2639 48.2101 38.9631 46.2082 41.0636 43.6489C43.164 41.0895 44.6008 38.0516 45.2468 34.8043C45.8928 31.5571 45.728 28.2006 44.767 25.0322C43.806 21.8638 42.0785 18.9813 39.7374 16.64C38.0347 14.9401 36.9187 13.8069 35.8603 12.4205C34.8162 11.0514 33.7778 9.36305 32.3886 6.58171ZM24.6555 32.9527C24.2909 34.9219 24.0817 36.7853 24.0632 37.9101C24.0623 37.9646 24.0618 38.0173 24.0618 38.0681C23.3686 38.0681 22.7225 38.0363 22.1249 37.9822C20.9061 37.8718 19.8889 37.6684 19.0844 37.4524C17.6305 37.0619 16.8712 36.63 16.8712 36.63C16.8727 38.3355 17.3796 40.0023 18.3278 41.4199C18.6148 41.849 18.9383 42.2494 19.2936 42.6172C20.1124 43.4648 21.1 44.1398 22.1989 44.5948C23.7747 45.2473 25.5084 45.4181 27.1813 45.0858C28.8541 44.7535 30.3909 43.9329 31.5976 42.7277L31.6005 42.7306C31.6066 42.7244 31.6128 42.7183 31.6189 42.7122C31.6782 42.6527 31.7367 42.5923 31.7942 42.5311C32.5059 41.7741 33.0751 40.8939 33.4735 39.9326C33.7209 39.3357 33.8996 38.7142 34.0073 38.0802C34.0885 37.6023 34.1293 37.1171 34.1287 36.63C34.1301 35.4967 33.9074 34.3743 33.4735 33.3274C33.0396 32.2804 32.4031 31.3296 31.6005 30.5295C31.2584 30.1868 30.9519 29.879 30.6685 29.5818C29.558 28.4173 28.8029 27.4165 27.6572 25.125C26.6257 25.8126 25.7587 28.1441 25.1346 30.7048C24.9539 31.4463 24.7935 32.2071 24.6555 32.9527ZM29.0991 37.1726C29.1096 37.049 29.1219 36.9181 29.1359 36.7807C29.1307 36.9121 29.1185 37.0429 29.0991 37.1726ZM0.375991 30.8805C0.372419 27.5798 1.02071 24.3108 2.28367 21.2612C3.54683 18.2111 5.39997 15.4406 7.73671 13.1086C7.73704 13.1083 7.73737 13.108 7.73771 13.1076C8.83734 12.0096 10.2376 11.2619 11.7618 10.9588C13.2875 10.6554 14.8689 10.8112 16.3061 11.4064C17.0629 11.7199 17.7617 12.1477 18.3801 12.6714C18.7528 11.5083 19.1602 10.3743 19.6043 9.30502C19.6044 9.30465 19.6046 9.30428 19.6047 9.30391C20.1583 7.96811 20.8345 6.57438 21.6656 5.29236C22.4565 4.06888 23.67 2.50982 25.447 1.32427C25.4475 1.32393 25.4481 1.32359 25.4486 1.32325C25.449 1.32293 25.4495 1.32262 25.45 1.3223C26.3776 0.703522 27.4262 0.289026 28.5263 0.106314C29.6275 -0.0765912 30.755 -0.0230311 31.8339 0.263438C32.9128 0.549906 33.9184 1.06272 34.7838 1.7678C35.649 2.47266 36.3543 3.35353 36.8528 4.35189C36.853 4.35221 36.8532 4.35252 36.8533 4.35284C38.1587 6.96618 39.0376 8.35749 39.8265 9.39231M0.375991 30.8805C0.376729 35.0111 1.39589 39.0779 3.34327 42.7206C5.29107 46.3642 8.10736 49.4712 11.5427 51.7664C14.978 54.0616 18.9264 55.4742 23.038 55.8791C27.1497 56.2839 31.2977 55.6686 35.1147 54.0875C38.9317 52.5063 42.2999 50.0083 44.921 46.8146C47.542 43.6209 49.335 39.8301 50.1411 35.778C50.9472 31.7258 50.7415 27.5375 49.5424 23.5838C48.3432 19.6301 46.1875 16.0332 43.2662 13.1116L43.263 13.1084C41.535 11.3833 40.6521 10.4736 39.8268 9.39261\"\n                fill=\"url(#paint0_linear_881_33987)\"\n            />\n            <path\n                d=\"M24.6554 32.9167C24.2908 34.8859 24.0816 36.7494 24.0631 37.8742C24.0622 37.9286 24.0617 37.9813 24.0617 38.0322C23.3685 38.0322 22.7224 38.0004 22.1249 37.9462C20.906 37.8358 19.8888 37.6325 19.0843 37.4164C17.6305 37.026 16.8711 36.594 16.8711 36.594C16.8726 38.2995 17.3795 39.9664 18.3277 41.384C18.6147 41.813 18.9382 42.2134 19.2936 42.5812C20.1123 43.4289 21.0999 44.1038 22.1988 44.5588C23.7746 45.2113 25.5084 45.3822 27.1812 45.0498C28.854 44.7175 30.3908 43.8969 31.5975 42.6917L31.6004 42.6946C31.6066 42.6885 31.6127 42.6824 31.6188 42.6762C31.6782 42.6167 31.7366 42.5563 31.7942 42.4951C32.5059 41.7381 33.0751 40.8579 33.4734 39.8966C33.7208 39.2997 33.8995 38.6783 34.0072 38.0443C34.0884 37.5663 34.1292 37.0812 34.1287 36.594C34.13 35.4607 33.9073 34.3384 33.4734 33.2914C33.0395 32.2445 32.403 31.2936 31.6004 30.4935C31.2584 30.1508 30.9518 29.843 30.6684 29.5458C29.5579 28.3813 28.8028 27.3805 27.6571 25.089C26.6256 25.7766 25.7586 28.1082 25.1345 30.6688C24.9538 31.4103 24.7934 32.1711 24.6554 32.9167Z\"\n                fill=\"#F4F4F4\"\n            />\n            <defs>\n                <linearGradient\n                    id=\"paint0_linear_881_33987\"\n                    x1=\"28.7387\"\n                    y1=\"6.68036\"\n                    x2=\"52.5632\"\n                    y2=\"45.3738\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#4CC9F0\" />\n                    <stop offset=\"0.28684\" stopColor=\"#4361EE\" />\n                    <stop offset=\"0.524848\" stopColor=\"#7209B7\" />\n                    <stop offset=\"0.83892\" stopColor=\"#F72585\" />\n                </linearGradient>\n            </defs>\n        </svg>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/steps/index.ts",
    "content": "export * from './Intro'\nexport * from './Profile'\nexport * from './setup'\nexport * from './Welcome'\nexport * from './YourMaybe'\nexport * from './StepProps'\n"
  },
  {
    "path": "libs/client/features/src/onboarding/steps/setup/AddFirstAccount.tsx",
    "content": "import { Fragment, useState } from 'react'\nimport { Transition } from '@headlessui/react'\nimport { RiLoader4Fill, RiLockLine } from 'react-icons/ri'\nimport { motion } from 'framer-motion'\nimport { Button } from '@maybe-finance/design-system'\nimport { useAccountContext, useUserAccountContext } from '@maybe-finance/client/shared'\nimport { ExampleApp } from '../../ExampleApp'\nimport { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'\nimport type { StepProps } from '../StepProps'\n\nexport function AddFirstAccount({ title, onNext }: StepProps) {\n    const [isSubmitting, setIsSubmitting] = useState(false)\n    const { addAccount } = useAccountContext()\n    const { noAccounts, allAccountsDisabled, someAccountsSyncing } = useUserAccountContext()\n\n    const accountAdded = !noAccounts && !allAccountsDisabled && !someAccountsSyncing\n\n    return (\n        <div className=\"min-h-[700px] overflow-x-hidden\">\n            <div className=\"flex max-w-5xl mx-auto gap-32 justify-center sm:justify-start\">\n                <div className=\"relative grow max-w-md sm:mt-12 text-center sm:text-start\">\n                    <Transition\n                        show={!accountAdded}\n                        as={Fragment}\n                        enter=\"ease-in duration-100\"\n                        enterFrom=\"opacity-0 translate-y-8\"\n                        enterTo=\"opacity-100 translate-y-0\"\n                        leave=\"ease-in duration-100\"\n                        leaveFrom=\"opacity-100 translate-y-0\"\n                        leaveTo=\"opacity-0 translate-y-8\"\n                        unmount={false}\n                    >\n                        <div className=\"relative\">\n                            <h3 className=\"text-pretty\">{title}</h3>\n                            <div className=\"text-base text-gray-50\">\n                                <p className=\"mt-2\">\n                                    To get the most out of Maybe you need to add your financial\n                                    accounts. Doing this gives you better insights, contextual\n                                    financial planning, and relevant advice.\n                                </p>\n                                <p className=\"mt-6\">\n                                    You probably have quite a few assets and debts, so we&rsquo;ll\n                                    add the one thing most people have &ndash; a bank account.\n                                    You&rsquo;ll be able to add the rest of your accounts later.\n                                </p>\n                            </div>\n                            <div className=\"mt-7 flex items-center justify-between\">\n                                {[\n                                    ['Chase Bank', 'chase-bank'],\n                                    ['Wells Fargo', 'wells-fargo'],\n                                    ['Bank of America', 'bofa'],\n                                    ['Charles Schwab', 'charles-schwab'],\n                                    ['Capital One', 'capital-one'],\n                                    ['Citibank', 'citi'],\n                                ].map(([name, src]) => (\n                                    <div key={name} className=\"h-6\">\n                                        <img\n                                            src={`/assets/icons/financial-institutions/${src}.svg`}\n                                            alt={name}\n                                            className=\"h-full w-auto\"\n                                        />\n                                    </div>\n                                ))}\n                            </div>\n                            <p className=\"mt-4 text-center text-sm text-gray-50\">\n                                &amp; 10,000 other institutions available\n                            </p>\n                            <div className=\"mt-7\">\n                                {someAccountsSyncing ? (\n                                    <div className=\"mt-3 flex flex-col items-center space-y-3\">\n                                        <RiLoader4Fill className=\"w-6 h-6 animate-spin text-white\" />\n                                        <p className=\"text-sm text-gray-100\">\n                                            Your account data is syncing\n                                        </p>\n                                    </div>\n                                ) : (\n                                    <>\n                                        <Button fullWidth className=\"mt-7\" onClick={addAccount}>\n                                            Connect your primary bank account\n                                        </Button>\n                                        <button\n                                            className=\"mt-2 w-full p-2 text-center text-base text-white hover:text-gray-25 flex items-center justify-center\"\n                                            onClick={async () => {\n                                                setIsSubmitting(true)\n                                                await onNext()\n                                                setIsSubmitting(false)\n                                            }}\n                                        >\n                                            I&rsquo;m not ready to connect accounts yet{' '}\n                                            {isSubmitting && (\n                                                <LoadingIcon className=\"ml-2 w-5 h-5 animate-spin\" />\n                                            )}\n                                        </button>\n                                        <div className=\"mt-6 flex space-x-2 text-sm text-gray-100\">\n                                            <RiLockLine className=\"shrink-0 w-4 h-4\" />\n                                            <p className=\"grow\">\n                                                Adding your accounts is a big step. That&rsquo;s why\n                                                we take this seriously. No one can access your\n                                                accounts but you. Your information is always\n                                                protected and secure.\n                                            </p>\n                                        </div>\n                                    </>\n                                )}\n                            </div>\n                        </div>\n                    </Transition>\n                    <Transition\n                        show={accountAdded}\n                        as={Fragment}\n                        enter=\"ease-in duration-100\"\n                        enterFrom=\"opacity-0 translate-y-8\"\n                        enterTo=\"opacity-100 translate-y-0\"\n                        leave=\"ease-in duration-100\"\n                        leaveFrom=\"opacity-100 translate-y-0\"\n                        leaveTo=\"opacity-0 translate-y-8\"\n                    >\n                        <div className=\"absolute top-0\">\n                            <h3>Look at you go!</h3>\n                            <div className=\"text-base text-gray-50\">\n                                <p className=\"mt-2\">\n                                    Way to go on successfully connecting your first account! Thanks\n                                    for your trust in us to keep your financial information secure.\n                                </p>\n                                <p className=\"mt-6\">\n                                    If there are any data issues or something&rsquo;s looking wrong,\n                                    you can reach out to us to try and resolve the issue or continue\n                                    setting up your account and fix it later.\n                                </p>\n                            </div>\n                            <Button\n                                fullWidth\n                                className=\"mt-7\"\n                                onClick={async () => {\n                                    setIsSubmitting(true)\n                                    await onNext()\n                                    setIsSubmitting(false)\n                                }}\n                            >\n                                Continue{' '}\n                                {isSubmitting && (\n                                    <LoadingIcon className=\"ml-2 w-5 h-5 animate-spin\" />\n                                )}\n                            </Button>\n                        </div>\n                    </Transition>\n                </div>\n                <div className=\"relative grow hidden sm:block\">\n                    <motion.div\n                        initial={{ translateX: 60 }}\n                        animate={{ translateX: 0 }}\n                        className=\"absolute left-0 top-0\"\n                        style={{\n                            WebkitMaskImage:\n                                'radial-gradient(ellipse at 0 0, #FFFF 0%, #FFFF 30%, #0000 65%)',\n                        }}\n                    >\n                        <ExampleApp />\n                    </motion.div>\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/steps/setup/EmailVerification.tsx",
    "content": "import { useEffect, useRef, useState } from 'react'\nimport classNames from 'classnames'\nimport toast from 'react-hot-toast'\nimport { RiArrowRightLine, RiMailCheckLine, RiMailSendLine, RiQuestionLine } from 'react-icons/ri'\nimport { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'\nimport { Button, Tooltip } from '@maybe-finance/design-system'\nimport { useUserApi } from '@maybe-finance/client/shared'\nimport type { StepProps } from '../StepProps'\n\nexport function EmailVerification({ title, onNext }: StepProps) {\n    const { useAuthProfile, useResendEmailVerification } = useUserApi()\n\n    const emailVerified = useRef(false)\n\n    const profile = useAuthProfile({\n        refetchInterval: emailVerified.current ? false : 5_000,\n        onSuccess: (data) => {\n            if (data.emailVerified) {\n                emailVerified.current = true\n            }\n        },\n    })\n\n    const resendEmailVerification = useResendEmailVerification({\n        onSuccess: (data) => {\n            if (data && data.success) {\n                toast.success('Verification email sent!')\n            }\n        },\n    })\n\n    const [resendDisabled, setResendDisabled] = useState(false)\n\n    useEffect(() => {\n        if (resendEmailVerification.isSuccess) {\n            // Disable resend button for 10 seconds\n            setResendDisabled(true)\n            const timeout = setTimeout(() => {\n                setResendDisabled(false)\n            }, 10_000)\n            return () => clearTimeout(timeout)\n        }\n\n        return undefined\n    }, [resendEmailVerification.isSuccess])\n\n    if (profile.isLoading) {\n        // eslint-disable-next-line react/jsx-no-useless-fragment\n        return <></>\n    }\n\n    if (profile.isError) {\n        return (\n            <div className=\"flex justify-center text-gray-100\">\n                <p>Something went wrong. Please try again.</p>\n            </div>\n        )\n    }\n\n    return (\n        <div className=\"w-full max-w-md mx-auto\">\n            <div className=\"flex justify-center items-center\">\n                <div\n                    className={classNames(\n                        'flex items-center justify-center w-12 h-12 rounded-2xl border',\n                        'border-gray-600 text-white'\n                    )}\n                    style={{\n                        background:\n                            'linear-gradient(180deg, rgba(35, 36, 40, 0.2) 0%, rgba(68, 71, 76, 0.2) 100%)',\n                    }}\n                >\n                    {profile.data?.emailVerified ? (\n                        <RiMailCheckLine className=\"w-6 h-6\" />\n                    ) : (\n                        <RiMailSendLine className=\"w-6 h-6\" />\n                    )}\n                </div>\n            </div>\n            <h3 className=\"mt-12 text-center text-pretty\">\n                {profile.data?.emailVerified ? 'Email verified' : title}\n            </h3>\n            <div className=\"text-base text-center\">\n                {profile.data?.emailVerified ? (\n                    <p className=\"mt-4 text-gray-50\">\n                        You have successfully verified{' '}\n                        <span className=\"text-gray-25\">{profile.data?.email ?? 'your email'}</span>\n                    </p>\n                ) : (\n                    <>\n                        <p className=\"mt-4 text-gray-50\">\n                            Before we can start setting up your account and connecting to data\n                            providers, we&rsquo;ll need to verify your email.\n                        </p>\n                        <p className=\"mt-4 text-gray-50\">\n                            A magic link has been sent to{' '}\n                            <span className=\"text-gray-25\">\n                                {profile.data?.email ?? 'your email'}\n                            </span>\n                        </p>\n                        <button\n                            className=\"flex items-center justify-center w-full mt-4 text-white hover:text-gray-25 disabled:text-gray-50\"\n                            disabled={resendEmailVerification.isLoading || resendDisabled}\n                            onClick={() => resendEmailVerification.mutate(undefined)}\n                        >\n                            {resendEmailVerification.isLoading && (\n                                <LoadingIcon className=\"mr-2 w-3 h-3 animate-spin\" />\n                            )}\n                            Haven&rsquo;t received anything? Send it again\n                        </button>\n\n                        <Tooltip\n                            content={\n                                <>\n                                    Besides verifying that it&rsquo;s actually you signing up, this\n                                    helps us prevent spam registration and keep your account secure.\n                                    Verification is also helpful for account recovery in case of a\n                                    lost/forgotten password.\n                                </>\n                            }\n                            placement=\"bottom\"\n                        >\n                            <div className=\"mt-6 flex items-center justify-center space-x-2\">\n                                <RiQuestionLine className=\"w-5 h-5 text-gray-100\"></RiQuestionLine>\n                                <span className=\"text-gray-50\">\n                                    Why do you need to verify my email?\n                                </span>\n                            </div>\n                        </Tooltip>\n                    </>\n                )}\n            </div>\n            {profile.data?.emailVerified && (\n                <Button className=\"mt-5\" fullWidth onClick={onNext}>\n                    Continue setup <RiArrowRightLine className=\"ml-2 w-5 h-5\" />\n                </Button>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/steps/setup/OtherAccounts.tsx",
    "content": "import { useState } from 'react'\nimport classNames from 'classnames'\nimport { motion } from 'framer-motion'\nimport {\n    RiBankCard2Line,\n    RiBankLine,\n    RiBitCoinLine,\n    RiCarLine,\n    RiHandCoinLine,\n    RiHomeLine,\n    RiLineChartLine,\n    RiMoneyDollarBoxLine,\n    RiVipCrown2Line,\n} from 'react-icons/ri'\nimport { Button } from '@maybe-finance/design-system'\nimport { ExampleApp } from '../../ExampleApp'\nimport type { StepProps } from '../StepProps'\nimport { useUserApi } from '@maybe-finance/client/shared'\nimport uniqBy from 'lodash/uniqBy'\nimport { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'\n\nconst accountTypes = [\n    {\n        icon: RiMoneyDollarBoxLine,\n        label: 'Cash',\n        name: 'cash',\n        stepKey: 'add-other',\n    },\n    {\n        icon: RiBankLine,\n        label: 'Savings account',\n        name: 'savings account',\n        stepKey: 'connect-depository',\n    },\n    {\n        icon: RiBankLine,\n        label: 'Checking account',\n        name: 'checking account',\n        stepKey: 'connect-depository',\n    },\n    {\n        icon: RiBankCard2Line,\n        label: 'Credit card',\n        name: 'credit card',\n        stepKey: 'connect-liability',\n    },\n    {\n        icon: RiLineChartLine,\n        label: 'Brokerage (Robinhood, Fidelity, etc.)',\n        name: 'brokerage account',\n        stepKey: 'connect-investment',\n    },\n    {\n        icon: RiLineChartLine,\n        label: 'Retirement (401(k), IRA, etc.)',\n        name: 'retirement account',\n        stepKey: 'connect-investment',\n    },\n    {\n        icon: RiBitCoinLine,\n        label: 'Crypto',\n        name: 'crypto',\n        stepKey: 'add-crypto',\n    },\n    {\n        icon: RiLineChartLine,\n        label: 'Alternative investments',\n        name: 'investment account',\n        stepKey: 'add-other',\n    },\n    {\n        icon: RiCarLine,\n        label: 'Vehicle',\n        name: 'vehicle',\n        stepKey: 'add-vehicle',\n    },\n    {\n        icon: RiHomeLine,\n        label: 'Property (home, rental, etc.)',\n        name: 'property',\n        stepKey: 'add-property',\n    },\n    {\n        icon: RiVipCrown2Line,\n        label: 'Valuables (art, jewelry, etc.)',\n        name: 'valuables',\n        stepKey: 'add-other',\n    },\n    {\n        icon: RiHandCoinLine,\n        label: 'Loans (home, student, auto, etc.)',\n        name: 'loans',\n        stepKey: 'connect-liability',\n    },\n]\n\nexport function OtherAccounts({ title, onNext }: StepProps) {\n    const [selected, setSelected] = useState<string[]>([])\n    const [isSubmitting, setIsSubmitting] = useState(false)\n\n    const { useUpdateOnboarding } = useUserApi()\n    const updateOnboarding = useUpdateOnboarding()\n\n    return (\n        <div className=\"min-h-[700px] overflow-x-hidden\">\n            <div className=\"flex max-w-5xl mx-auto gap-32 justify-center sm:justify-start\">\n                <div className=\"grow max-w-md sm:mt-12 text-center sm:text-start\">\n                    <h3 className=\"text-pretty\">{title}</h3>\n                    <p className=\"mt-2 text-base text-gray-50\">\n                        You can select bank accounts if there are any other accounts you&rsquo;d\n                        like to add. Feel free to select more than one. We&rsquo;ll add these to\n                        your checklist to remind you around what to add.\n                    </p>\n                    <div className=\"mt-6 flex flex-wrap gap-2 justify-center sm:justify-start\">\n                        {accountTypes.map(({ icon: Icon, label, name }) => (\n                            <button\n                                key={name}\n                                className={classNames(\n                                    'flex items-center gap-2 py-2 px-3 text-base text-white rounded-xl border bg-cyan transition-colors duration-50',\n                                    selected.includes(name)\n                                        ? 'border-cyan bg-opacity-10'\n                                        : 'border-gray-500 bg-opacity-0'\n                                )}\n                                onClick={() =>\n                                    setSelected((selected) =>\n                                        selected.includes(name)\n                                            ? selected.filter((n) => n !== name)\n                                            : [...selected, name]\n                                    )\n                                }\n                            >\n                                <Icon className=\"w-5 h-5 text-gray-100\" />\n                                {label}\n                            </button>\n                        ))}\n                    </div>\n\n                    <Button\n                        className=\"mt-7 min-w-[50%]\"\n                        onClick={async () => {\n                            setIsSubmitting(true)\n\n                            const selections = accountTypes.filter(\n                                (at) =>\n                                    selected.includes(at.name) ||\n                                    at.stepKey === 'connect-depository' // By default, every user will have depository acct for checklist\n                            )\n                            const uniqueKeys = uniqBy(selections, 'stepKey').map(({ stepKey }) => ({\n                                stepKey,\n                            }))\n\n                            // Dynamically add steps based on user selections\n                            await updateOnboarding.mutateAsync({\n                                flow: 'sidebar',\n                                updates: uniqueKeys.map((v) => ({\n                                    key: v.stepKey,\n                                    markedComplete: false,\n                                })),\n                            })\n\n                            await onNext()\n\n                            setIsSubmitting(false)\n                        }}\n                    >\n                        Continue{' '}\n                        {isSubmitting && <LoadingIcon className=\"ml-2 w-5 h-5 animate-spin\" />}\n                    </Button>\n                </div>\n                <div className=\"relative grow hidden sm:block\">\n                    <motion.div\n                        initial={{ translateX: 60 }}\n                        animate={{ translateX: 0 }}\n                        className=\"absolute left-0 top-0\"\n                        style={{\n                            WebkitMaskImage:\n                                'radial-gradient(ellipse at 0 0, #FFFF 0%, #FFFF 30%, #0000 65%)',\n                        }}\n                    >\n                        <ExampleApp checklist={selected} />\n                    </motion.div>\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/onboarding/steps/setup/index.ts",
    "content": "export * from './AddFirstAccount'\nexport * from './EmailVerification'\nexport * from './OtherAccounts'\n"
  },
  {
    "path": "libs/client/features/src/plans/AddPlanScenario.tsx",
    "content": "import type { IconType } from 'react-icons'\nimport type { BoxIconProps } from '@maybe-finance/client/shared'\nimport type { ReactNode } from 'react'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type { PlanEventValues } from './PlanEventForm'\nimport type { O } from 'ts-toolbelt'\n\nimport { BoxIcon, usePopoutContext } from '@maybe-finance/client/shared'\nimport { useCallback, useMemo, useState } from 'react'\nimport { GiPalmTree } from 'react-icons/gi'\nimport { RiArrowRightDownLine, RiArrowRightUpLine, RiSearchLine } from 'react-icons/ri'\nimport { DialogV2 } from '@maybe-finance/design-system'\nimport groupBy from 'lodash/groupBy'\nimport { RetirementMilestoneForm } from './RetirementMilestoneForm'\nimport { PlanContext, PlanEventPopout, usePlanContext } from '..'\nimport { DateUtil, PlanUtil } from '@maybe-finance/shared'\nimport { PlanEventForm } from './PlanEventForm'\n\n// No typings available for this module\n// eslint-disable-next-line @typescript-eslint/no-var-requires\nconst fuzzysearch = require('fuzzysearch')\n\nconst scenarioOptions: ScenarioOption[] = [\n    { scenario: 'retirement', name: 'Retirement', icon: GiPalmTree, group: 'Milestones' },\n    { scenario: 'income', name: 'Custom Income', icon: RiArrowRightUpLine, group: 'What ifs' },\n    { scenario: 'expense', name: 'Custom Expense', icon: RiArrowRightDownLine, group: 'What ifs' },\n\n    // TODO - support additional options\n    // {\n    //     scenario: 'fi',\n    //     name: 'Financial independence',\n    //     icon: RiFlagLine,\n    //     group: 'Goals',\n    // },\n    // { scenario: 'debt-free', name: 'Debt free', icon: RiCopperCoinLine, group: 'Goals' },\n    // { scenario: 'buy-house', name: 'Buy new house', icon: RiHomeLine, group: 'What ifs' },\n    // {\n    //     scenario: 'start-business',\n    //     name: 'Start a business',\n    //     icon: RiBriefcase2Line,\n    //     group: 'What ifs',\n    // },\n    // {\n    //     scenario: 'reallocate',\n    //     name: 'Portfolio reallocation',\n    //     icon: RiEqualizerLine,\n    //     group: 'What ifs',\n    // },\n]\n\n// Different form shown based on scenario chosen (only retirement implemented)\ntype PlanScenario =\n    | 'retirement'\n    | 'income'\n    | 'expense'\n    | 'fi'\n    | 'debt-free'\n    | 'buy-house'\n    | 'start-business'\n    | 'reallocate'\n\ntype ScenarioOption = {\n    group: string\n    scenario: PlanScenario\n    name: string\n    icon: IconType\n}\n\ntype ScenarioData =\n    | {\n          scenario: 'retirement'\n          data: {\n              year: number\n              monthlySpending: number\n          }\n      }\n    | { scenario: 'income'; data: O.Nullable<PlanEventValues, 'initialValue'> }\n    | { scenario: 'expense'; data: O.Nullable<PlanEventValues, 'initialValue'> }\n    | { scenario: 'fi' }\n    | { scenario: 'debt-free' }\n    | { scenario: 'buy-house' }\n    | { scenario: 'start-business' }\n    | { scenario: 'reallocate' }\n\ntype Props = {\n    plan: SharedType.Plan\n    isOpen: boolean\n    scenarioYear: number\n    onClose(): void\n    onSubmit(data: ScenarioData): void\n}\n\nexport function AddPlanScenario({ plan, isOpen, scenarioYear, onClose, onSubmit }: Props) {\n    const [scenario, setScenario] = useState<PlanScenario | undefined>()\n    const [error, setError] = useState('')\n\n    const { open: openPopout } = usePopoutContext()\n\n    const planContext = usePlanContext()\n\n    // Opens popout within a PlanContext.Provider (needed because popouts are rendered outside of this tree)\n    const openPopoutWithContext = useCallback(\n        (children: ReactNode) =>\n            openPopout(<PlanContext.Provider value={planContext}>{children}</PlanContext.Provider>),\n        [openPopout, planContext]\n    )\n\n    const handleClose = useCallback(() => {\n        setError('')\n        setScenario(undefined)\n        onClose()\n    }, [onClose])\n\n    const scenarioUI = useMemo(() => {\n        switch (scenario) {\n            case 'retirement':\n                if (\n                    plan.milestones.find(\n                        (m) => m.category === PlanUtil.PlanMilestoneCategory.Retirement\n                    )\n                ) {\n                    setError(\n                        'We could not add a retirement milestone because one already exists.  Please delete that milestone first.'\n                    )\n                    return\n                }\n\n                return {\n                    title: 'Retirement',\n                    component: (\n                        <RetirementMilestoneForm\n                            mode=\"create\"\n                            defaultValues={\n                                planContext.userAge\n                                    ? {\n                                          age: DateUtil.yearToAge(\n                                              scenarioYear,\n                                              planContext.userAge\n                                          ),\n                                          monthlySpending: 5000,\n                                      }\n                                    : { year: scenarioYear, monthlySpending: 5000 }\n                            }\n                            onSubmit={(data) => {\n                                onSubmit({ scenario: 'retirement', data })\n                                setScenario(undefined)\n                            }}\n                        />\n                    ),\n                }\n            case 'income':\n                openPopoutWithContext(\n                    <PlanEventPopout key=\"new-income-event\">\n                        <PlanEventForm\n                            mode=\"create\"\n                            flow=\"income\"\n                            onSubmit={(data) => onSubmit({ scenario: 'income', data })}\n                            initialValues={{ startYear: scenarioYear, name: 'Income event' }}\n                        />\n                    </PlanEventPopout>\n                )\n\n                handleClose()\n\n                return null\n            case 'expense':\n                openPopoutWithContext(\n                    <PlanEventPopout key=\"new-expense-event\">\n                        <PlanEventForm\n                            mode=\"create\"\n                            flow=\"expense\"\n                            onSubmit={(data) =>\n                                onSubmit({\n                                    scenario: 'expense',\n                                    data: {\n                                        ...data,\n                                        initialValue: data.initialValue\n                                            ? data.initialValue.negated()\n                                            : undefined,\n                                    },\n                                })\n                            }\n                            initialValues={{ startYear: scenarioYear, name: 'Expense event' }}\n                        />\n                    </PlanEventPopout>\n                )\n\n                handleClose()\n\n                return null\n            default:\n                return null\n        }\n    }, [\n        scenario,\n        onSubmit,\n        scenarioYear,\n        planContext.userAge,\n        plan.milestones,\n        openPopoutWithContext,\n        handleClose,\n    ])\n\n    if (error) {\n        return <DialogV2 open={isOpen} title=\"Oops!\" description={error} onClose={handleClose} />\n    }\n\n    return scenarioUI ? (\n        <DialogV2 open={isOpen} onClose={handleClose} title={scenarioUI.title}>\n            {scenarioUI.component}\n        </DialogV2>\n    ) : (\n        <DialogV2 open={isOpen} onClose={handleClose} disablePadding size=\"xl\">\n            <Search onSelect={setScenario} />\n        </DialogV2>\n    )\n}\n\nfunction Option({\n    name,\n    icon,\n    onClick,\n    variant,\n}: {\n    name: string\n    icon: IconType\n    onClick: () => void\n    variant: BoxIconProps['variant']\n}) {\n    return (\n        <button\n            className=\"mx-2 p-2 hover:bg-gray-600 rounded-xl flex gap-3 items-center outline-none\"\n            onClick={onClick}\n        >\n            <BoxIcon icon={icon} size=\"md\" variant={variant} />\n            <span className=\"text-base\">{name}</span>\n        </button>\n    )\n}\n\nfunction Search({ onSelect }: { onSelect(scenario: PlanScenario): void }) {\n    const [search, setSearch] = useState<string | undefined>()\n\n    const options = useMemo(() => {\n        const filtered = scenarioOptions.filter((option) =>\n            search ? fuzzysearch(search.toLowerCase(), option.name.toLowerCase()) : true\n        )\n\n        return groupBy(filtered, 'group')\n    }, [search])\n\n    return (\n        <>\n            <div className=\"flex items-center text-base gap-2 border-b border-b-gray-600 h-[56px] p-4 text-gray-100\">\n                <RiSearchLine className=\"w-5 h-5\" />\n                <input\n                    className=\"w-full bg-transparent border-none outline-none focus:ring-0 focus-within:ring-0 placeholder:text-gray-100 text-base text-white p-0\"\n                    type=\"text\"\n                    placeholder=\"What's next in your plan?\"\n                    value={search}\n                    onChange={(event) => setSearch(event.target.value)}\n                />\n            </div>\n\n            {/* Milestones  */}\n            <div className=\"relative pb-8 h-[268px] custom-gray-scroll\">\n                {Object.entries(options).length === 0 ? (\n                    <p className=\"text-sm text-gray-50 p-4\">No scenarios found</p>\n                ) : (\n                    Object.entries(options).map(([group, scenarios]) => (\n                        <div key={group} className=\"mt-4\">\n                            <span className=\"inline-block pl-4 mb-2 text-gray-100 text-sm font-medium\">\n                                {group}\n                            </span>\n                            {scenarios.map((s) => (\n                                <div key={s.name} className=\"flex flex-col gap-1\">\n                                    <Option\n                                        key={s.name}\n                                        name={s.name}\n                                        icon={s.icon}\n                                        variant={\n                                            s.scenario === 'income'\n                                                ? 'teal'\n                                                : s.scenario === 'expense'\n                                                ? 'red'\n                                                : 'cyan'\n                                        }\n                                        onClick={() => {\n                                            onSelect(s.scenario)\n                                        }}\n                                    />\n                                </div>\n                            ))}\n                        </div>\n                    ))\n                )}\n            </div>\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/NewPlanForm.tsx",
    "content": "import { Controller, useForm } from 'react-hook-form'\nimport { Button, Input } from '@maybe-finance/design-system'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { NumericFormat } from 'react-number-format'\n\nexport type NewPlanValues = {\n    name: string\n    lifeExpectancy: number\n}\n\ntype Props = {\n    initialValues?: Partial<NewPlanValues>\n    onSubmit(data: NewPlanValues): void\n}\n\nexport function NewPlanForm({ initialValues, onSubmit }: Props) {\n    const { control, handleSubmit, formState, register } = useForm<NewPlanValues>({\n        mode: 'onChange',\n        defaultValues: {\n            ...initialValues,\n        },\n    })\n\n    const { isSubmitting, isValid } = formState\n\n    return (\n        <form onSubmit={handleSubmit(onSubmit)}>\n            <Input\n                type=\"text\"\n                label=\"Plan name\"\n                placeholder={initialValues?.name ?? 'Retirement'}\n                {...register('name', { required: true })}\n            />\n\n            <Controller\n                control={control}\n                name=\"lifeExpectancy\"\n                rules={{ required: true }}\n                render={({ field, fieldState: { error } }) => (\n                    <NumericFormat\n                        label=\"Life expectancy\"\n                        customInput={Input}\n                        placeholder={NumberUtil.format(\n                            initialValues?.lifeExpectancy ?? 85,\n                            'decimal'\n                        )}\n                        className=\"mt-4\"\n                        error={error && 'Life expectancy is required'}\n                        fixedRightOverride={<span className=\"text-gray-100 text-base\">years</span>}\n                        allowNegative={false}\n                        value={field.value}\n                        onValueChange={(value) =>\n                            field.onChange(value.value ? parseFloat(value.value) : value.value)\n                        }\n                    />\n                )}\n            />\n\n            <div className=\"flex justify-end mt-4\">\n                <Button type=\"submit\" disabled={isSubmitting || !isValid}>\n                    Create Plan\n                </Button>\n            </div>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/PlanContext.ts",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { createContext, useContext } from 'react'\n\nexport type PlanContext = {\n    userAge: number\n    planStartYear: number\n    planEndYear: number\n    milestones: SharedType.PlanMilestone[]\n}\n\nexport const PlanContext = createContext<PlanContext | undefined>(undefined)\n\nexport function usePlanContext() {\n    const ctx = useContext(PlanContext)\n\n    if (!ctx) throw new Error('Must use usePlanContext inside PlanContext.Provider')\n\n    return ctx\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/PlanEventCard.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { PlanUtil } from '@maybe-finance/shared'\nimport classNames from 'classnames'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { RiAddLine, RiSubtractLine } from 'react-icons/ri'\nimport { DateTime } from 'luxon'\nimport { BoxIcon } from '@maybe-finance/client/shared'\n\ntype PlanEventCardProps = PropsWithChildren<{\n    event: SharedType.PlanEvent\n    projection?: SharedType.PlanProjectionResponse['projection']\n    onClick: () => void\n    className?: string\n}>\n\nfunction toYearRange(\n    event: Pick<\n        SharedType.PlanEvent,\n        'startYear' | 'startMilestoneId' | 'endYear' | 'endMilestoneId'\n    >,\n    projection?: SharedType.PlanProjectionResponse['projection']\n) {\n    const start =\n        event.startYear ??\n        (projection ? PlanUtil.resolveMilestoneYear(projection, event.startMilestoneId!) : null)\n\n    const end =\n        event.endYear ??\n        (projection ? PlanUtil.resolveMilestoneYear(projection, event.endMilestoneId!) : null)\n\n    if (start && end) {\n        return `Years ${start} - ${end}`\n    }\n\n    if (start && !end) {\n        return `Years ${start} - end of plan`\n    }\n\n    if (!start && end) {\n        return `Years ${DateTime.now().year} - ${end}`\n    }\n\n    return `All years`\n}\n\nexport function PlanEventCard({ event, projection, onClick, className }: PlanEventCardProps) {\n    const isPositive = event.initialValue.isPositive()\n    const Icon = isPositive ? RiAddLine : RiSubtractLine\n\n    return (\n        <div\n            className={classNames(\n                'flex items-start space-x-4 bg-gray-800 rounded-xl w-full p-4 text-base',\n                'cursor-pointer transition-colors duration-50 hover:bg-gray-700',\n                className\n            )}\n            role=\"button\"\n            onClick={onClick}\n        >\n            <BoxIcon variant={isPositive ? 'teal' : 'red'} icon={Icon} />\n\n            <div className=\"grow\">\n                <div className=\"text-white\">{event.name}</div>\n                <div className=\"text-gray-100\">{toYearRange(event, projection)}</div>\n            </div>\n\n            <div className=\"text-right\">\n                <div className=\"text-white\">\n                    {NumberUtil.format(event.initialValue, 'currency')}\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/PlanEventForm.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { Controller, useForm } from 'react-hook-form'\nimport { Button, Input, InputCurrency, Listbox } from '@maybe-finance/design-system'\nimport { RiArrowDownLine, RiArrowUpLine, RiRepeatLine } from 'react-icons/ri'\nimport upperFirst from 'lodash/upperFirst'\nimport { PlanRangeInput, usePlanContext } from '.'\nimport type { O } from 'ts-toolbelt'\nimport { NumericFormat } from 'react-number-format'\nimport Decimal from 'decimal.js'\n\nexport type PlanEventValues = Pick<\n    SharedType.PlanEvent,\n    | 'name'\n    | 'frequency'\n    | 'initialValue'\n    | 'initialValueRef'\n    | 'rate'\n    | 'startYear'\n    | 'startMilestoneId'\n    | 'endYear'\n    | 'endMilestoneId'\n>\n\nexport type PlanEventFormProps = {\n    mode: 'create' | 'update'\n    flow: 'income' | 'expense'\n    initialValues?: Partial<PlanEventValues>\n    onSubmit: (data: O.Nullable<PlanEventValues, 'initialValue'>) => void\n    onDelete?: () => void\n}\n\nexport function PlanEventForm({\n    mode,\n    flow,\n    initialValues,\n    onSubmit,\n    onDelete,\n}: PlanEventFormProps) {\n    const { planStartYear } = usePlanContext()\n\n    const {\n        control,\n        handleSubmit,\n        formState: { isSubmitting, isValid, touchedFields },\n        register,\n        watch,\n        setValue,\n        setError,\n        clearErrors,\n        getFieldState,\n    } = useForm<PlanEventValues>({\n        mode: 'onChange',\n        defaultValues: {\n            frequency: 'yearly',\n            startYear: planStartYear,\n            initialValue: new Decimal(0),\n            initialValueRef: null,\n            rate: new Decimal(0),\n            ...initialValues,\n        },\n    })\n\n    const [frequency, startYear, startMilestoneId, endYear, endMilestoneId] = watch([\n        'frequency',\n        'startYear',\n        'startMilestoneId',\n        'endYear',\n        'endMilestoneId',\n    ])\n\n    return (\n        <form\n            className=\"flex flex-col grow\"\n            onSubmit={handleSubmit(({ initialValue, initialValueRef, ...data }) => {\n                // if user hasn't modified initialValue, then preserve the ref\n                if (initialValueRef && !touchedFields.initialValue) {\n                    onSubmit({ ...data, initialValue: null, initialValueRef })\n                } else {\n                    onSubmit({ ...data, initialValue, initialValueRef: null })\n                }\n            })}\n        >\n            <h4>{flow === 'income' ? 'Income' : 'Expense'} Event</h4>\n            <h6 className=\"mt-4 uppercase\">Overview</h6>\n\n            <Input\n                type=\"text\"\n                label=\"Name\"\n                placeholder={initialValues?.name ?? 'Event'}\n                className=\"mt-2\"\n                {...register('name', { required: true })}\n            />\n\n            <Controller\n                control={control}\n                name=\"initialValue\"\n                rules={{\n                    required: 'Amount is required',\n                    validate: (val) => (val && val.lte(0) ? 'Amount must be positive' : true),\n                }}\n                render={({ field, fieldState: { error } }) => {\n                    return (\n                        <InputCurrency\n                            {...field}\n                            value={field.value?.toNumber() ?? null}\n                            onChange={(val) => field.onChange(val != null ? new Decimal(val) : val)}\n                            label=\"Amount\"\n                            error={error?.message}\n                            className=\"mt-2\"\n                        />\n                    )\n                }}\n            />\n\n            <h6 className=\"mt-4 uppercase\">Time</h6>\n\n            <Controller\n                control={control}\n                name=\"frequency\"\n                render={({ field }) => (\n                    <Listbox value={field.value} onChange={field.onChange} className=\"mt-2\">\n                        <Listbox.Button label=\"Frequency\" icon={RiRepeatLine}>\n                            {frequency ? upperFirst(frequency) : 'Once'}\n                        </Listbox.Button>\n                        <Listbox.Options>\n                            <Listbox.Option value=\"yearly\">Yearly</Listbox.Option>\n                            <Listbox.Option value=\"monthly\">Monthly</Listbox.Option>\n                            <Listbox.Option value={undefined}>Once</Listbox.Option>\n                        </Listbox.Options>\n                    </Listbox>\n                )}\n            />\n\n            <div className=\"flex space-x-3 mt-4\">\n                <PlanRangeInput\n                    type=\"start\"\n                    value={{ year: startYear, milestoneId: startMilestoneId }}\n                    onChange={(value) => {\n                        setValue('startYear', value.year)\n                        setValue('startMilestoneId', value.milestoneId)\n                    }}\n                    className=\"w-1/2\"\n                />\n\n                <PlanRangeInput\n                    type=\"end\"\n                    value={{ year: endYear, milestoneId: endMilestoneId }}\n                    onChange={(value) => {\n                        setValue('endYear', value.year)\n                        setValue('endMilestoneId', value.milestoneId)\n\n                        if (value.year && startYear && value.year < startYear)\n                            setError('endYear', { type: 'custom', message: 'Must be after start' })\n                        else clearErrors('endYear')\n                    }}\n                    className=\"w-1/2\"\n                    error={getFieldState('endYear').error?.message}\n                />\n            </div>\n\n            <h6 className=\"mt-4 uppercase\">Change</h6>\n\n            <Controller\n                control={control}\n                name=\"rate\"\n                rules={{ required: true }}\n                render={({ field, fieldState: { error } }) => {\n                    const sign: '+' | '-' = field.value.isPositive() ? '+' : '-'\n\n                    return (\n                        <div className=\"flex space-x-3 mt-2\">\n                            <Listbox\n                                value={sign}\n                                onChange={() => field.onChange(field.value.negated())}\n                                className=\"w-1/2\"\n                            >\n                                <Listbox.Button\n                                    label=\"Yearly change\"\n                                    icon={sign === '+' ? RiArrowUpLine : RiArrowDownLine}\n                                >\n                                    {sign === '+' ? 'Increase' : 'Decrease'}\n                                </Listbox.Button>\n                                <Listbox.Options>\n                                    <Listbox.Option value=\"+\">Increase</Listbox.Option>\n                                    <Listbox.Option value=\"-\">Decrease</Listbox.Option>\n                                </Listbox.Options>\n                            </Listbox>\n\n                            <NumericFormat\n                                label=\"Value\"\n                                customInput={Input}\n                                className=\"w-1/2\"\n                                inputClassName=\"w-full\"\n                                fixedRightOverride={\n                                    <span className=\"text-gray-100 text-base\">%</span>\n                                }\n                                decimalScale={2}\n                                allowLeadingZeros\n                                fixedDecimalScale\n                                error={error && 'Change value is required'}\n                                allowNegative={false}\n                                value={field.value.times(100).toString()}\n                                onValueChange={({ value }) => {\n                                    if (value) field.onChange(new Decimal(value).div(100))\n                                }}\n                            />\n                        </div>\n                    )\n                }}\n            />\n\n            <div className=\"flex flex-col grow justify-end mt-4 mb-20 space-y-3\">\n                {mode === 'create' && (\n                    <Button type=\"submit\" disabled={isSubmitting || !isValid}>\n                        Create\n                    </Button>\n                )}\n                {mode === 'update' && (\n                    <>\n                        <Button type=\"submit\" disabled={isSubmitting || !isValid}>\n                            Update\n                        </Button>\n                        <Button variant=\"warn\" disabled={isSubmitting} onClick={onDelete}>\n                            Delete\n                        </Button>\n                    </>\n                )}\n            </div>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/PlanEventList.tsx",
    "content": "import type { PropsWithChildren, ReactNode } from 'react'\nimport { useCallback } from 'react'\nimport type { SharedType } from '@maybe-finance/shared'\nimport classNames from 'classnames'\nimport { RiAddLine, RiArrowLeftDownLine, RiArrowRightUpLine } from 'react-icons/ri'\nimport { Button } from '@maybe-finance/design-system'\nimport { usePopoutContext } from '@maybe-finance/client/shared'\nimport { PlanEventCard } from './PlanEventCard'\nimport { PlanEventPopout } from './PlanEventPopout'\nimport { PlanEventForm } from './PlanEventForm'\nimport { PlanContext, usePlanContext } from './PlanContext'\n\ntype PlanCreateInput = Record<string, any>\ntype PlanUpdateInput = PlanCreateInput\n\ntype PlanEventListProps = PropsWithChildren<{\n    events: SharedType.PlanEvent[]\n    projection?: SharedType.PlanProjectionResponse['projection']\n    isLoading: boolean\n    className?: string\n\n    onCreate(data: PlanCreateInput): void\n    onUpdate(id: SharedType.PlanEvent['id'], data: PlanUpdateInput): void\n    onDelete(id: SharedType.PlanEvent['id']): void\n}>\n\nexport function PlanEventList({\n    events,\n    projection,\n    isLoading,\n    className,\n    onCreate,\n    onUpdate,\n    onDelete,\n}: PlanEventListProps) {\n    const { open: openPopout, close: closePopout } = usePopoutContext()\n\n    const planContext = usePlanContext()\n\n    // Opens popout within a PlanContext.Provider (needed because popouts are rendered outside of this tree)\n    const openPopoutWithContext = useCallback(\n        (children: ReactNode) =>\n            openPopout(<PlanContext.Provider value={planContext}>{children}</PlanContext.Provider>),\n        [openPopout, planContext]\n    )\n\n    const incomeEvents = events.filter((event) => event.initialValue.gte(0))\n    const expenseEvents = events.filter((event) => event.initialValue.lt(0))\n\n    const createEvent = useCallback(\n        (data: PlanCreateInput) => {\n            onCreate(data)\n            closePopout()\n        },\n        [onCreate, closePopout]\n    )\n\n    const updateEvent = useCallback(\n        (id: SharedType.PlanEvent['id'], data: PlanUpdateInput) => {\n            onUpdate(id, data)\n            closePopout()\n        },\n        [onUpdate, closePopout]\n    )\n\n    const deleteEvent = useCallback(\n        (id: SharedType.PlanEvent['id']) => {\n            onDelete(id)\n            closePopout()\n        },\n        [onDelete, closePopout]\n    )\n\n    return (\n        <div\n            className={classNames(\n                'flex flex-wrap md:flex-nowrap space-y-8 md:space-y-0 md:space-x-8',\n                className\n            )}\n        >\n            <div className=\"w-full md:w-1/2\">\n                <div className=\"flex justify-between\">\n                    <h5 className=\"flex items-center uppercase\">\n                        <RiArrowLeftDownLine className=\"w-6 h-6 mr-1 text-teal\" />\n                        Income\n                    </h5>\n                    <Button\n                        variant=\"icon\"\n                        disabled={isLoading}\n                        onClick={() =>\n                            openPopoutWithContext(\n                                <PlanEventPopout key=\"new-income-event\">\n                                    <PlanEventForm\n                                        mode=\"create\"\n                                        flow=\"income\"\n                                        onSubmit={(data) => createEvent(data)}\n                                    />\n                                </PlanEventPopout>\n                            )\n                        }\n                        data-testid=\"income-events-add-button\"\n                    >\n                        <RiAddLine className=\"w-6 h-6 text-gray-50\" />\n                    </Button>\n                </div>\n                <div className=\"mt-4\">\n                    {!isLoading && incomeEvents.length ? (\n                        <div className=\"space-y-3\">\n                            {incomeEvents.map((event) => (\n                                <PlanEventCard\n                                    key={event.id}\n                                    event={event}\n                                    projection={projection}\n                                    onClick={() =>\n                                        openPopoutWithContext(\n                                            <PlanEventPopout key={event.id}>\n                                                <PlanEventForm\n                                                    mode=\"update\"\n                                                    flow=\"income\"\n                                                    initialValues={event}\n                                                    onSubmit={(data) => updateEvent(event.id, data)}\n                                                    onDelete={() => deleteEvent(event.id)}\n                                                />\n                                            </PlanEventPopout>\n                                        )\n                                    }\n                                />\n                            ))}\n                        </div>\n                    ) : (\n                        <EmptyStateCards\n                            isLoading={isLoading}\n                            message=\"No income events added yet\"\n                        />\n                    )}\n                </div>\n            </div>\n            <div className=\"w-full md:w-1/2\">\n                <div className=\"flex justify-between\">\n                    <h5 className=\"flex items-center uppercase\">\n                        <RiArrowRightUpLine className=\"w-6 h-6 mr-1 text-red\" />\n                        Expenses\n                    </h5>\n                    <Button\n                        variant=\"icon\"\n                        disabled={isLoading}\n                        onClick={() =>\n                            openPopoutWithContext(\n                                <PlanEventPopout key=\"new-expense-event\">\n                                    <PlanEventForm\n                                        mode=\"create\"\n                                        flow=\"expense\"\n                                        onSubmit={({ initialValue, ...data }) =>\n                                            createEvent({\n                                                ...data,\n                                                initialValue: initialValue?.negated(),\n                                            })\n                                        }\n                                    />\n                                </PlanEventPopout>\n                            )\n                        }\n                    >\n                        <RiAddLine className=\"w-6 h-6 text-gray-50\" />\n                    </Button>\n                </div>\n                <div className=\"mt-4\">\n                    {!isLoading && expenseEvents.length ? (\n                        <div className=\"space-y-3\">\n                            {expenseEvents.map((event) => (\n                                <PlanEventCard\n                                    key={event.id}\n                                    event={event}\n                                    projection={projection}\n                                    onClick={() =>\n                                        openPopoutWithContext(\n                                            <PlanEventPopout key={event.id}>\n                                                <PlanEventForm\n                                                    mode=\"update\"\n                                                    flow=\"expense\"\n                                                    initialValues={{\n                                                        ...event,\n                                                        initialValue: event.initialValue.negated(),\n                                                    }}\n                                                    onSubmit={({ initialValue, ...data }) =>\n                                                        updateEvent(event.id, {\n                                                            ...data,\n                                                            initialValue: initialValue?.negated(),\n                                                        })\n                                                    }\n                                                    onDelete={() => deleteEvent(event.id)}\n                                                />\n                                            </PlanEventPopout>\n                                        )\n                                    }\n                                />\n                            ))}\n                        </div>\n                    ) : (\n                        <EmptyStateCards\n                            isLoading={isLoading}\n                            message=\"No expense events added yet\"\n                        />\n                    )}\n                </div>\n            </div>\n        </div>\n    )\n}\n\nfunction EmptyStateCards({\n    isLoading,\n    message,\n}: {\n    isLoading: boolean\n    message: string\n}): JSX.Element {\n    return (\n        <div className=\"relative\">\n            <div className=\"space-y-2\">\n                {Array.from({ length: 3 }).map((_, idx) => (\n                    <div\n                        key={idx}\n                        className=\"relative bg-gray-800 rounded-xl w-full p-4 overflow-hidden text-base\"\n                    >\n                        {isLoading && (\n                            <div className=\"absolute top-0 left-0 w-full h-full bg-shine animate-shine\"></div>\n                        )}\n                        <div className=\"w-12 h-12 rounded-xl bg-gray-700\"></div>\n                    </div>\n                ))}\n            </div>\n            <div className=\"absolute top-0 left-0 w-full h-full bg-gradient-to-t from-black\"></div>\n            {!isLoading && (\n                <div className=\"absolute flex items-center justify-center top-0 left-0 w-full h-full text-base text-gray-50\">\n                    {message}\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/PlanEventPopout.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport { Button } from '@maybe-finance/design-system'\nimport { usePopoutContext } from '@maybe-finance/client/shared'\nimport { RiCloseFill } from 'react-icons/ri'\n\ntype PlanEventListProps = PropsWithChildren<{}>\n\nexport function PlanEventPopout({ children }: PlanEventListProps) {\n    const { close } = usePopoutContext()\n\n    return (\n        <div className=\"flex flex-col h-full w-full lg:w-[384px] p-6\">\n            <Button variant=\"icon\" title=\"Close\" onClick={close}>\n                <RiCloseFill className=\"w-6 h-6\" />\n            </Button>\n            <div className=\"flex flex-col grow mt-6\">{children}</div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/PlanExplainer.tsx",
    "content": "import type { ColumnDef } from '@tanstack/react-table'\nimport { IndexTabs } from '@maybe-finance/design-system'\nimport { useMemo, useRef } from 'react'\nimport { RiArticleLine, RiYoutubeLine } from 'react-icons/ri'\nimport {\n    ExplainerExternalLink,\n    ExplainerInfoBlock,\n    ExplainerSection,\n} from '@maybe-finance/client/shared'\nimport { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport classNames from 'classnames'\n\ntype Props = {\n    initialSection?: 'overview' | 'methodology' | 'returns' | 'survival' | 'learn'\n}\n\nexport function PlanExplainer({ initialSection }: Props) {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n\n    const overview = useRef<HTMLDivElement>(null)\n    const forecastingMethodology = useRef<HTMLDivElement>(null)\n    const returns = useRef<HTMLDivElement>(null)\n    const survivalRate = useRef<HTMLDivElement>(null)\n    const learnMore = useRef<HTMLDivElement>(null)\n\n    const initialIndex = useMemo(() => {\n        switch (initialSection) {\n            case 'methodology':\n                return 1\n            case 'returns':\n                return 2\n            case 'survival':\n                return 3\n            case 'learn':\n                return 4\n            default:\n                return 0\n        }\n    }, [initialSection])\n\n    return (\n        <div className=\"flex flex-col w-full h-full\">\n            <h5 className=\"px-4 font-display font-bold text-2xl\">How this works</h5>\n            <div className=\"shrink-0 px-4 py-3\">\n                <IndexTabs\n                    initialIndex={initialIndex}\n                    scrollContainer={scrollContainer}\n                    sections={[\n                        { name: 'Overview', elementRef: overview },\n                        {\n                            name: 'Forecasting methodology',\n                            elementRef: forecastingMethodology,\n                        },\n                        { name: 'Survival rate', elementRef: survivalRate },\n                        {\n                            name: 'Returns',\n                            elementRef: returns,\n                        },\n                        {\n                            name: 'Learn more',\n                            elementRef: learnMore,\n                        },\n                    ]}\n                />\n            </div>\n            <div ref={scrollContainer} className=\"grow px-4 pb-16 basis-px custom-gray-scroll\">\n                <ExplainerSection title=\"Definition\" ref={overview} className=\"space-y-3\">\n                    <p>\n                        To forecast your wealth in future years, we use a technique called a “Monte\n                        Carlo simulation”. This is a common technique used by financial planners and\n                        hedge funds to predict how a given portfolio of assets will perform based on\n                        a variety of market conditions.\n                    </p>\n\n                    <p>\n                        Luckily, this mathematical technique is not just available to money\n                        managers. We can also apply this to your <em>personal</em> portfolio!\n                    </p>\n                </ExplainerSection>\n\n                <ExplainerSection\n                    title=\"Forecast methodology\"\n                    ref={forecastingMethodology}\n                    className=\"space-y-3\"\n                >\n                    <p>\n                        At Maybe, we can guarantee accurate calculations, but{' '}\n                        <span className=\"text-white font-semibold\">\n                            we cannot predict your future\n                        </span>\n                        . Forecasting outcomes over a long time-horizon includes significant\n                        uncertainty.\n                    </p>\n\n                    <p>\n                        We encourage you to treat these results{' '}\n                        <span className=\"text-white font-semibold\">\n                            as a range of possibilities\n                        </span>\n                        , not a certainty.\n                    </p>\n\n                    <p>Here&rsquo;s an example interpretation of your results:</p>\n\n                    <p>\n                        At age 68, your portfolio ranges from, say $100 - $200 at the 10th and 90th\n                        \"percentiles\".\n                    </p>\n\n                    <p>\n                        This simply means, \"We ran 1,000 simulations, and 90% of those simulations\n                        resulted in a net worth between $100-$200 at age 68\".\n                    </p>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Survival Rate\" ref={survivalRate} className=\"space-y-3\">\n                    <p className=\"italic\">\n                        <span className=\"font-semibold\">Disclaimer:</span> this is an estimate. We\n                        cannot guarantee the success of your plan.\n                    </p>\n\n                    <p>\n                        In each tooltip, you will see a percentage indicator we call \"Survival\n                        rate\". We use this to determine the percentage of simulations that ended up\n                        with a positive net worth.\n                    </p>\n\n                    <p>\n                        Let's say your survival rate is 70%. Assuming we run 1,000 simulations, this\n                        means that 700/1000 simulations \"survived\" (i.e. positive net worth), while\n                        300/1000 did not \"survive\" (i.e. negative net worth) in that year.\n                    </p>\n\n                    <ExplainerInfoBlock title=\"TL;DR\">\n                        It&rsquo;s your &ldquo;will I ever run out of money?&rdquo; metric.\n                    </ExplainerInfoBlock>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Returns\" ref={returns} className=\"space-y-3\">\n                    <p>\n                        Below are the average returns by asset class that we use as <em>inputs</em>{' '}\n                        to our Monte Carlo simulation:\n                    </p>\n\n                    <div>\n                        <ReturnsTable />\n                    </div>\n\n                    <p>\n                        Negative percentages mean that the asset is either a depreciating asset or\n                        losing value due to inflation.\n                    </p>\n                </ExplainerSection>\n\n                <ExplainerSection title=\"Learn more\" ref={learnMore}>\n                    <ExplainerExternalLink\n                        icon={RiArticleLine}\n                        href=\"https://www.investopedia.com/terms/m/montecarlosimulation.asp\"\n                    >\n                        Article on Monte Carlo simulations\n                    </ExplainerExternalLink>\n\n                    <ExplainerExternalLink\n                        icon={RiYoutubeLine}\n                        href=\"https://www.youtube.com/watch?v=7TqhmX92P6U\"\n                    >\n                        Video on Monte Carlo simulations\n                    </ExplainerExternalLink>\n                </ExplainerSection>\n            </div>\n        </div>\n    )\n}\n\ntype ReturnsData = {\n    asset: string\n    return: number\n    volatility: number\n}\n\n// const columnHelper = createColumnHelper<ReturnsData>()\n\nconst returnsData: ReturnsData[] = [\n    {\n        asset: 'Stocks',\n        return: 0.05,\n        volatility: 0.186,\n    },\n    {\n        asset: 'Bonds',\n        return: 0.02,\n        volatility: 0.052,\n    },\n    {\n        asset: 'Cash',\n        return: -0.02,\n        volatility: 0.05,\n    },\n    {\n        asset: 'Crypto',\n        return: 1.0,\n        volatility: 1.0,\n    },\n    {\n        asset: 'Property',\n        return: 0.1,\n        volatility: 0.2,\n    },\n    {\n        asset: 'Other',\n        return: -0.02,\n        volatility: 0,\n    },\n]\n\nfunction ReturnsTable() {\n    const columns = useMemo(() => {\n        return [\n            {\n                header: 'Asset',\n                accessorKey: 'asset',\n            },\n            {\n                id: 'return',\n                header: 'Return',\n                accessorFn: (row) =>\n                    NumberUtil.format(row.return, 'percent', { signDisplay: 'auto' }),\n            } as ColumnDef<ReturnsData, ReturnsData['return']>,\n            {\n                id: 'volatility',\n                header: 'Volatility',\n                accessorFn: (row) =>\n                    NumberUtil.format(row.volatility, 'percent', { signDisplay: 'auto' }),\n            } as ColumnDef<ReturnsData, ReturnsData['volatility']>,\n        ]\n    }, [])\n\n    const table = useReactTable({\n        data: returnsData,\n        columns,\n        getCoreRowModel: getCoreRowModel(),\n    })\n\n    return (\n        <table className=\"table-fixed min-w-full gap-x-5 text-base\">\n            <thead>\n                {table.getHeaderGroups().map((headerGroup) => (\n                    <tr key={headerGroup.id}>\n                        {headerGroup.headers.map((header) => (\n                            <th\n                                key={header.id}\n                                colSpan={header.colSpan}\n                                className=\"whitespace-nowrap text-gray-100 text-right first:text-left font-normal\"\n                            >\n                                {!header.isPlaceholder &&\n                                    flexRender(header.column.columnDef.header, header.getContext())}\n                            </th>\n                        ))}\n                    </tr>\n                ))}\n            </thead>\n            <tbody>\n                {table.getRowModel().rows.map((row) => (\n                    <tr key={row.id} className=\"cursor-pointer hover:bg-gray-800\">\n                        {row.getVisibleCells().map((cell) => (\n                            <td\n                                key={cell.id}\n                                className={classNames(\n                                    'py-3 first:rounded-l-lg last:rounded-r-lg whitespace-nowrap truncate text-white font-medium border-b border-b-gray-700 text-right first:text-left'\n                                )}\n                            >\n                                {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                            </td>\n                        ))}\n                    </tr>\n                ))}\n            </tbody>\n        </table>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/PlanMenu.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { DateUtil, PlanUtil } from '@maybe-finance/shared'\n\nimport { InsightPopout, usePlanApi, usePopoutContext } from '@maybe-finance/client/shared'\nimport { Button, Dialog, Menu } from '@maybe-finance/design-system'\nimport { RiArrowGoBackLine, RiBookReadLine } from 'react-icons/ri'\nimport { useState } from 'react'\nimport { usePlanContext } from './PlanContext'\nimport { PlanExplainer } from './PlanExplainer'\n\ntype Props = {\n    plan?: SharedType.Plan\n}\n\nexport function PlanMenu({ plan }: Props) {\n    const [isOpen, setIsOpen] = useState(false)\n    const { userAge } = usePlanContext()\n    const { useUpdatePlanTemplate } = usePlanApi()\n\n    const { open: openPopout } = usePopoutContext()\n\n    const update = useUpdatePlanTemplate()\n\n    if (!plan) return null\n\n    return (\n        <>\n            <Menu>\n                <Menu.Button variant=\"icon\">\n                    <i className=\"ri-more-2-fill text-white\" />\n                </Menu.Button>\n                <Menu.Items placement=\"bottom-end\">\n                    <Menu.Item icon={<RiArrowGoBackLine />} onClick={() => setIsOpen(true)}>\n                        Reset plan\n                    </Menu.Item>\n                    <Menu.Item\n                        icon={<RiBookReadLine />}\n                        onClick={() =>\n                            openPopout(\n                                <InsightPopout>\n                                    <PlanExplainer />\n                                </InsightPopout>\n                            )\n                        }\n                    >\n                        How this works\n                    </Menu.Item>\n                </Menu.Items>\n            </Menu>\n            <Dialog isOpen={isOpen} onClose={() => setIsOpen(false)} showCloseButton={false}>\n                <Dialog.Content className=\"text-gray-50 text-base text-center\">\n                    <div className=\"flex items-center justify-center bg-cyan bg-opacity-10 w-12 h-12 rounded-lg transform -translate-y-4 mx-auto\">\n                        <RiArrowGoBackLine className=\"w-6 h-6 text-cyan\" />\n                    </div>\n                    <h4 className=\"text-white mb-2\">Reset plan?</h4>\n                    <p className=\"mb-4\">\n                        By doing this you will be resetting any changes you have made to the plan so\n                        far. You cannot undo this action.\n                    </p>\n\n                    <div className=\"flex items-center gap-4 mt-4\">\n                        <Button\n                            className=\"w-2/4\"\n                            variant=\"secondary\"\n                            onClick={() => setIsOpen(false)}\n                        >\n                            Cancel\n                        </Button>\n\n                        <Button\n                            className=\"w-2/4\"\n                            onClick={async () => {\n                                /**\n                                 * @todo - as we incorporate more plan templates, we should\n                                 * be resetting to the current plan's template if possible\n                                 * and only defaulting to retirement as the \"default\" template\n                                 *\n                                 * for now, we are assuming all plans use the retirement template\n                                 */\n                                await update.mutateAsync({\n                                    id: plan.id,\n                                    shouldReset: true,\n                                    data: {\n                                        type: 'retirement',\n                                        data: {\n                                            retirementYear: DateUtil.ageToYear(\n                                                PlanUtil.RETIREMENT_MILESTONE_AGE,\n                                                userAge ?? PlanUtil.DEFAULT_AGE\n                                            ),\n                                        },\n                                    },\n                                })\n\n                                setIsOpen(false)\n                            }}\n                        >\n                            Reset\n                        </Button>\n                    </div>\n                </Dialog.Content>\n            </Dialog>\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/PlanMilestones.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { BoxIcon } from '@maybe-finance/client/shared'\nimport { Button, DialogV2, LoadingPlaceholder } from '@maybe-finance/design-system'\nimport { DateUtil, NumberUtil } from '@maybe-finance/shared'\nimport { GiPalmTree } from 'react-icons/gi'\nimport { RiAddLine, RiDeleteBin6Line, RiPencilLine } from 'react-icons/ri'\nimport { usePlanContext } from './PlanContext'\nimport toast from 'react-hot-toast'\nimport { useMemo, useState, Fragment } from 'react'\n\ntype MilestoneRange = {\n    lower: SharedType.Decimal\n    upper: SharedType.Decimal\n    confidence: number // A percentage confidence in our range estimate\n}\n\ntype Props = {\n    isLoading: boolean\n    onAdd: () => void\n    onEdit: (id: SharedType.PlanMilestone['id']) => void\n    onDelete: (id: SharedType.PlanMilestone['id']) => void\n    milestones: (SharedType.PlanMilestone & MilestoneRange)[]\n    events: SharedType.PlanEvent[]\n}\n\nexport function PlanMilestones({ isLoading, onAdd, onEdit, onDelete, milestones, events }: Props) {\n    const { userAge } = usePlanContext()\n\n    const [confirmDeleteId, setConfirmDeleteId] = useState<SharedType.PlanMilestone['id'] | null>(\n        null\n    )\n\n    const eventsToBeDeleted = useMemo(() => {\n        if (!confirmDeleteId) return []\n\n        return events.filter(\n            (event) =>\n                event.startMilestoneId === confirmDeleteId ||\n                event.endMilestoneId === confirmDeleteId\n        )\n    }, [confirmDeleteId, events])\n\n    return (\n        <div className=\"mt-4\">\n            {isLoading ? (\n                <div className=\"flex gap-4 items-start text-base\">\n                    <div className=\"relative w-12 h-12 rounded-xl bg-gray-800 overflow-hidden\">\n                        <div className=\"absolute inset-0 bg-shine animate-shine\"></div>\n                    </div>\n                    <div className=\"flex flex-col gap-1 items-start\">\n                        <LoadingPlaceholder className=\"pr-12\" overlayClassName=\"!bg-gray-800\">\n                            Milestone\n                        </LoadingPlaceholder>\n                        <LoadingPlaceholder className=\"pr-32\" overlayClassName=\"!bg-gray-800\">\n                            Description\n                        </LoadingPlaceholder>\n                    </div>\n                </div>\n            ) : milestones.length ? (\n                milestones.map((milestone) => {\n                    return (\n                        <Fragment key={milestone.id}>\n                            <div className=\"group flex justify-between\">\n                                <div className=\"flex gap-4\">\n                                    {/* Icon and vertical line */}\n                                    <div>\n                                        <BoxIcon icon={GiPalmTree} />\n                                        <div className=\"flex items-center justify-center my-4\">\n                                            <span className=\"bg-gray-500 h-[27px] w-[2px]\" />\n                                        </div>\n                                    </div>\n\n                                    <div className=\"max-w-[500px]\">\n                                        <p className=\"text-base font-medium\">{milestone.name}</p>\n                                        <p className=\"text-gray-100 text-base\">\n                                            Based on your current savings and portfolio, we project\n                                            with a{' '}\n                                            {NumberUtil.format(milestone.confidence, 'percent', {\n                                                signDisplay: 'auto',\n                                            })}{' '}\n                                            confidence interval that you will have between{' '}\n                                            <span className=\"text-gray-25\">\n                                                {NumberUtil.format(\n                                                    milestone.lower,\n                                                    'short-currency'\n                                                )}\n                                            </span>{' '}\n                                            and{' '}\n                                            <span className=\"text-gray-25\">\n                                                {NumberUtil.format(\n                                                    milestone.upper,\n                                                    'short-currency'\n                                                )}\n                                            </span>{' '}\n                                            at age{' '}\n                                            {milestone.type === 'year'\n                                                ? DateUtil.yearToAge(milestone.year!, userAge)\n                                                : '--'}\n                                            .\n                                        </p>\n                                    </div>\n                                </div>\n\n                                {/* Edit and delete buttons on hover */}\n                                <div className=\"group-hover:flex gap-2 hidden\">\n                                    <Button variant=\"icon\" onClick={() => onEdit(milestone.id)}>\n                                        <RiPencilLine className=\"w-6 h-6 text-gray-50 hover:text-white\" />\n                                    </Button>\n                                    <Button\n                                        variant=\"icon\"\n                                        onClick={() => setConfirmDeleteId(milestone.id)}\n                                    >\n                                        <RiDeleteBin6Line className=\"w-6 h-6 text-gray-50 hover:text-white\" />\n                                    </Button>\n                                </div>\n                            </div>\n                            <div className=\"flex gap-4\">\n                                <div className=\"w-12 flex justify-center\">\n                                    <Button\n                                        variant=\"icon\"\n                                        className=\"bg-gray-500 rounded-[10px]\"\n                                        onClick={onAdd}\n                                    >\n                                        <RiAddLine className=\"w-6 h-6 text-gray-50\" />\n                                    </Button>\n                                </div>\n                                <div className=\"max-w-[500px]\">\n                                    <p className=\"text-base font-medium\">Add new milestone</p>\n                                    <p className=\"text-gray-100 text-base\">\n                                        Tell us about any goals, what ifs, or windfalls you expect.\n                                    </p>\n                                </div>\n                            </div>\n                        </Fragment>\n                    )\n                })\n            ) : (\n                <div className=\"flex flex-col items-center\">\n                    <img alt=\"Maybe\" className=\"h-14\" src=\"/assets/plan-milestones.svg\" />\n                    <p className=\"max-w-[300px] mt-4 text-center text-base text-gray-50\">\n                        No milestones added yet.{' '}\n                        <em className=\"not-italic text-white\">Add a new one</em> to see how it\n                        impacts your plan.\n                    </p>\n                </div>\n            )}\n\n            <DialogV2\n                size=\"sm\"\n                open={confirmDeleteId !== null}\n                onClose={() => setConfirmDeleteId(null)}\n            >\n                <div className=\"flex flex-col items-center\">\n                    <BoxIcon icon={GiPalmTree} variant=\"red\" />\n                    <h4 className=\"mt-4\">Delete milestone?</h4>\n                    <div className=\"text-center text-base text-gray-50\">\n                        <p className=\"mt-2\">\n                            This will impact your plan and forecast. You will not be able to undo\n                            this action.\n                        </p>\n                        {eventsToBeDeleted.length > 0 && (\n                            <p className=\"mt-2\">\n                                The following events will also be deleted:\n                                <br />\n                                {eventsToBeDeleted.map(({ name }, idx) => (\n                                    <>\n                                        <span className=\"text-white\">{name}</span>\n                                        {idx < eventsToBeDeleted.length - 1 && ', '}\n                                    </>\n                                ))}\n                            </p>\n                        )}\n                    </div>\n                    <div className=\"flex gap-3 w-full mt-5\">\n                        <Button\n                            variant=\"secondary\"\n                            className=\"w-1/2\"\n                            onClick={() => setConfirmDeleteId(null)}\n                        >\n                            Cancel\n                        </Button>\n                        <Button\n                            variant=\"danger\"\n                            className=\"w-1/2\"\n                            onClick={() => {\n                                onDelete(confirmDeleteId!)\n                                setConfirmDeleteId(null)\n                            }}\n                        >\n                            Delete\n                        </Button>\n                    </div>\n                </div>\n            </DialogV2>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/PlanParameterCard.tsx",
    "content": "import type { PropsWithChildren, ReactNode } from 'react'\nimport { LoadingPlaceholder, Tooltip } from '@maybe-finance/design-system'\nimport { RiQuestionLine } from 'react-icons/ri'\nimport classNames from 'classnames'\n\ntype PlanParameterCardProps = PropsWithChildren<{\n    isLoading?: boolean\n    title: string\n    value: string\n    detail: ReactNode\n    className?: string\n    info?: ReactNode\n}>\n\nexport function PlanParameterCard({\n    isLoading = false,\n    title,\n    value,\n    detail,\n    className,\n    info,\n}: PlanParameterCardProps) {\n    return (\n        <div className={classNames('flex flex-col bg-gray-800 rounded-lg w-full p-4', className)}>\n            <div className=\"grow flex items-center justify-between space-x-1.5\">\n                <div className=\"flex items-center\">\n                    <p className=\"text-base text-gray-100\">{title}</p>\n                    {info && (\n                        <Tooltip\n                            content={<div className=\"text-base text-gray-50\">{info}</div>}\n                            className=\"max-w-[350px]\"\n                        >\n                            <span>\n                                <RiQuestionLine className=\"w-5 h-5 text-gray-50 mx-1.5\" />\n                            </span>\n                        </Tooltip>\n                    )}\n                </div>\n            </div>\n\n            <div className=\"mt-4\">\n                <LoadingPlaceholder isLoading={isLoading} className=\"whitespace-nowrap\">\n                    <h3>{value}</h3>\n                    <div className=\"mt-1 text-base text-gray-100\">{detail}</div>\n                </LoadingPlaceholder>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/PlanRangeInput.tsx",
    "content": "import React, { useState } from 'react'\nimport type { MouseEventHandler, ReactNode } from 'react'\nimport { NumericFormat } from 'react-number-format'\nimport classNames from 'classnames'\nimport { DateTime } from 'luxon'\nimport AnimateHeight from 'react-animate-height'\nimport type { IconType } from 'react-icons'\nimport {\n    RiCalendar2Line,\n    RiCheckFill,\n    RiHeartPulseLine,\n    RiKeyboardBoxLine,\n    RiPlayCircleLine,\n} from 'react-icons/ri'\nimport { GiPalmTree } from 'react-icons/gi'\nimport { Input, Popover } from '@maybe-finance/design-system'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { DateUtil } from '@maybe-finance/shared'\nimport { usePlanContext } from '.'\n\ntype ValueType = { year: number | null; milestoneId: SharedType.PlanMilestone['id'] | null }\n\nexport type PlanRangeInputProps = {\n    type: 'start' | 'end'\n    value: ValueType\n    onChange: (value: ValueType) => void\n    className?: string\n    error?: string\n}\n\ntype OptionType = 'retirement' | 'start' | 'end' | 'now' | 'age' | 'year'\n\nconst OptionMeta: Record<OptionType, { label: string; icon: IconType; manual?: boolean }> = {\n    retirement: { label: 'Retirement', icon: GiPalmTree },\n    start: { label: 'Start of plan', icon: RiPlayCircleLine },\n    end: { label: 'End of plan', icon: RiHeartPulseLine },\n    now: { label: 'Now', icon: RiCalendar2Line },\n    age: { label: 'Enter age', icon: RiKeyboardBoxLine, manual: true },\n    year: { label: 'Enter year', icon: RiKeyboardBoxLine, manual: true },\n}\n\nexport function PlanRangeInput({\n    type,\n    value: { year, milestoneId },\n    onChange,\n    className,\n    error,\n}: PlanRangeInputProps) {\n    const { userAge: currentAge, milestones } = usePlanContext()\n\n    const [selected, setSelected] = useState<OptionType>(\n        milestoneId != null ? 'retirement' : year ? (currentAge ? 'age' : 'year') : type\n    )\n\n    const onOptionClick = (type: OptionType, close: () => void) => {\n        switch (type) {\n            case 'retirement':\n                onChange({ year: null, milestoneId: milestones[0].id })\n                close()\n                break\n            case 'now':\n                onChange({ year: DateTime.now().year, milestoneId: null })\n                close()\n                break\n            case 'start':\n            case 'end':\n                onChange({ year: null, milestoneId: null })\n                close()\n                break\n            case 'age':\n            case 'year':\n                if (type === selected) close()\n                else\n                    onChange({\n                        year: year ?? DateTime.now().year,\n                        milestoneId: null,\n                    })\n                break\n        }\n\n        setSelected(type)\n    }\n\n    const { icon: SelectionIcon } = OptionMeta[selected]\n\n    const selectionLabel =\n        milestoneId != null\n            ? 'Retirement'\n            : year\n            ? ['year', 'currentYear'].includes(selected)\n                ? year\n                : `Age ${DateUtil.yearToAge(year, currentAge)}`\n            : type === 'start'\n            ? 'Start of plan'\n            : 'End of plan'\n\n    return (\n        <Popover className={className}>\n            <label>\n                <span className=\"block mb-1 text-base text-gray-50 font-light leading-6\">\n                    {type === 'start' ? 'Start' : 'End'}\n                </span>\n                <Popover.Button variant=\"secondary\" className=\"w-full font-normal !px-2.5\">\n                    <div className=\"flex items-center space-x-2.5\">\n                        <SelectionIcon className=\"w-5 h-5 text-gray-100\" />\n                        <div className=\"text-left\">{selectionLabel}</div>\n                    </div>\n                </Popover.Button>\n                {error && (\n                    <span className=\"block ml-1 mt-1 text-sm leading-4 text-red\">{error}</span>\n                )}\n            </label>\n            <Popover.Panel className=\"!p-0 flex flex-col w-48\">\n                {({ open, close }: { open: boolean; close: () => void }) => (\n                    <>\n                        <SectionLabel>Quick select</SectionLabel>\n                        <Option\n                            type={type}\n                            selected={selected === type}\n                            open={open}\n                            onClick={() => onOptionClick(type, close)}\n                        />\n                        <Option\n                            type=\"now\"\n                            selected={selected === 'now'}\n                            open={open}\n                            onClick={() => onOptionClick('now', close)}\n                        />\n                        {milestones.length > 0 && (\n                            <>\n                                {/* TODO: Handle multiple milestones, not just retirement */}\n                                <SectionLabel>Milestones</SectionLabel>\n                                <Option\n                                    type=\"retirement\"\n                                    selected={selected === 'retirement'}\n                                    open={open}\n                                    onClick={() => onOptionClick('retirement', close)}\n                                />\n                            </>\n                        )}\n\n                        <SectionLabel>Manual</SectionLabel>\n                        <Option\n                            type=\"age\"\n                            selected={selected === 'age'}\n                            open={open}\n                            onClick={() => onOptionClick('age', close)}\n                            value={DateUtil.yearToAge(year ?? DateTime.now().year, currentAge)}\n                            onChange={(age) =>\n                                age &&\n                                onChange({\n                                    year: DateUtil.ageToYear(age, currentAge),\n                                    milestoneId: null,\n                                })\n                            }\n                            onSubmit={close}\n                        />\n                        <Option\n                            type=\"year\"\n                            selected={selected === 'year'}\n                            open={open}\n                            onClick={() => onOptionClick('year', close)}\n                            value={year ?? DateTime.now().year}\n                            onChange={(year) => {\n                                year &&\n                                    onChange({\n                                        year,\n                                        milestoneId: null,\n                                    })\n                            }}\n                            onSubmit={close}\n                        />\n                    </>\n                )}\n            </Popover.Panel>\n        </Popover>\n    )\n}\n\nfunction SectionLabel({ children }: { children: ReactNode }) {\n    return <span className=\"p-2 text-sm text-gray-100\">{children}</span>\n}\n\nfunction Option({\n    type,\n    selected,\n    open,\n    onClick,\n    value,\n    onChange,\n    onSubmit,\n}: {\n    type: OptionType\n    selected: boolean\n    open: boolean\n    onClick: MouseEventHandler<HTMLDivElement>\n    value?: number\n    onChange?: (value: number | null) => void\n    onSubmit?: () => void\n}) {\n    const { label, icon: Icon, manual } = OptionMeta[type]\n\n    return (\n        <div\n            role=\"button\"\n            onClick={onClick}\n            className={classNames('py-1.5 px-2 hover:bg-gray-500', selected && 'bg-gray-500')}\n        >\n            <span className=\"flex items-center\">\n                <Icon className=\"shrink-0 w-5 h-5 mr-2 text-gray-100\" />\n                <span className=\"grow shrink-0 text-left text-base text-gray-25\">{label}</span>\n                {selected && <RiCheckFill className=\"shrink-0 w-5 h-5 ml-8 text-white\" />}\n            </span>\n\n            {manual && (\n                <AnimateHeight height={selected ? 'auto' : 0} duration={100}>\n                    <div className=\"mt-2\">\n                        <NumericFormat\n                            customInput={Input}\n                            className=\"grow\"\n                            allowNegative={false}\n                            value={value}\n                            onValueChange={(value) => {\n                                selected &&\n                                    open &&\n                                    onChange &&\n                                    onChange(value.value ? parseFloat(value.value) : null)\n                            }}\n                            onKeyDown={(e: React.KeyboardEvent) => {\n                                if (e.key === 'Enter') {\n                                    onSubmit && onSubmit()\n                                    e.preventDefault()\n                                }\n                            }}\n                            onClick={(e: React.MouseEvent) => e.stopPropagation()}\n                        />\n                    </div>\n                </AnimateHeight>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/PlanRangeSelector.tsx",
    "content": "import { useState } from 'react'\nimport { NumericFormat } from 'react-number-format'\nimport { RiArrowRightLine as RightArrow } from 'react-icons/ri'\nimport { Input, Popover, Tab } from '@maybe-finance/design-system'\nimport { DateUtil } from '@maybe-finance/shared'\nimport { usePlanContext } from '.'\n\nexport type PlanRangeSelectorProps = {\n    fromYear: number\n    toYear: number\n    onChange: (range: { from: number; to: number }) => void\n    mode: 'age' | 'year'\n    onModeChange(mode: 'age' | 'year'): void\n}\n\nexport function PlanRangeSelector({\n    fromYear,\n    toYear,\n    onChange,\n    mode,\n    onModeChange,\n}: PlanRangeSelectorProps): JSX.Element {\n    const { userAge: currentAge, planStartYear, planEndYear } = usePlanContext()\n\n    const [from, setFrom] = useState(fromYear)\n    const [to, setTo] = useState(toYear)\n\n    const formattedFrom =\n        fromYear === planStartYear\n            ? 'Today'\n            : mode === 'age'\n            ? `Age ${DateUtil.yearToAge(fromYear, currentAge)}`\n            : fromYear\n\n    const formattedTo = mode === 'age' ? `Age ${DateUtil.yearToAge(toYear, currentAge)}` : toYear\n\n    return (\n        <Popover>\n            <Popover.Button variant=\"secondary\">\n                <div className=\"flex items-center\">\n                    {formattedFrom}\n                    <span className=\"mx-2 text-gray-100\">\n                        <RightArrow className=\"w-4 h-4 text-gray-100\" />\n                    </span>{' '}\n                    {formattedTo}\n                </div>\n            </Popover.Button>\n            <Popover.Panel>\n                <div className=\"w-80 p-2\">\n                    <Tab.Group\n                        onChange={(index) => {\n                            onModeChange(index === 0 ? 'age' : 'year')\n                        }}\n                    >\n                        <Tab.List className=\"mb-4 bg-gray-600 !flex\">\n                            <Tab>Age</Tab>\n                            <Tab>Year</Tab>\n                        </Tab.List>\n                        <Tab.Panels>\n                            {/* Age */}\n                            <Tab.Panel>\n                                <div className=\"flex items-center\">\n                                    <NumericFormat\n                                        customInput={Input}\n                                        inputClassName=\"w-full text-center\"\n                                        allowNegative={false}\n                                        decimalScale={0}\n                                        value={DateUtil.yearToAge(from, currentAge)}\n                                        onValueChange={(value) => {\n                                            if (value?.floatValue)\n                                                setFrom(\n                                                    DateUtil.ageToYear(value.floatValue, currentAge)\n                                                )\n                                        }}\n                                    />\n                                    <RightArrow className=\"shrink-0 w-5 h-5 mx-5 text-gray-50\" />\n                                    <NumericFormat\n                                        customInput={Input}\n                                        inputClassName=\"w-full text-center\"\n                                        allowNegative={false}\n                                        decimalScale={0}\n                                        value={to - (planStartYear - currentAge)}\n                                        onValueChange={(value) => {\n                                            if (value?.floatValue)\n                                                setTo(\n                                                    DateUtil.ageToYear(value.floatValue, currentAge)\n                                                )\n                                        }}\n                                    />\n                                </div>\n                            </Tab.Panel>\n\n                            {/* Absolute years */}\n                            <Tab.Panel>\n                                <div className=\"flex items-center\">\n                                    <NumericFormat\n                                        customInput={Input}\n                                        inputClassName=\"w-full text-center\"\n                                        allowNegative={false}\n                                        decimalScale={0}\n                                        value={from}\n                                        onValueChange={(value) => {\n                                            if (value?.floatValue) setFrom(value.floatValue)\n                                        }}\n                                    />\n                                    <RightArrow className=\"shrink-0 w-5 h-5 mx-5 text-gray-50\" />\n                                    <NumericFormat\n                                        customInput={Input}\n                                        inputClassName=\"w-full text-center\"\n                                        allowNegative={false}\n                                        decimalScale={0}\n                                        value={to}\n                                        onValueChange={(value) => {\n                                            if (value?.floatValue) setTo(value.floatValue)\n                                        }}\n                                    />\n                                </div>\n                            </Tab.Panel>\n                        </Tab.Panels>\n                    </Tab.Group>\n\n                    <div className=\"flex justify-end space-x-3 mt-4\">\n                        <Popover.PanelButton\n                            variant=\"secondary\"\n                            onClick={() => {\n                                setFrom(fromYear)\n                                setTo(toYear)\n                            }}\n                        >\n                            Cancel\n                        </Popover.PanelButton>\n                        <Popover.PanelButton\n                            variant=\"primary\"\n                            disabled={from < planStartYear || to > planEndYear || from >= to}\n                            onClick={() => onChange({ from, to })}\n                        >\n                            Apply\n                        </Popover.PanelButton>\n                    </div>\n                </div>\n            </Popover.Panel>\n        </Popover>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/RetirementMilestoneForm.tsx",
    "content": "import { Button, Input, InputCurrency, Listbox } from '@maybe-finance/design-system'\nimport { DateUtil } from '@maybe-finance/shared'\nimport upperFirst from 'lodash/upperFirst'\nimport { useState } from 'react'\nimport { Controller, useForm } from 'react-hook-form'\nimport { usePlanContext } from './PlanContext'\n\ntype FormData = {\n    monthlySpending: number\n    age?: number\n    year?: number\n}\n\ntype RefMode = {\n    value: 'age' | 'year'\n    display: string\n}\n\ntype Props = {\n    mode: 'create' | 'update'\n    defaultValues: Partial<FormData>\n    onSubmit: (data: Required<Omit<FormData, 'age'>>) => void\n}\n\nconst refModes: RefMode[] = [\n    {\n        value: 'age',\n        display: 'At age',\n    },\n    {\n        value: 'year',\n        display: 'In the year',\n    },\n]\n\nexport function RetirementMilestoneForm({ mode, onSubmit, defaultValues }: Props) {\n    const { userAge, planStartYear, planEndYear } = usePlanContext()\n\n    const {\n        register,\n        control,\n        handleSubmit,\n        watch,\n        setValue,\n        formState: { errors, isSubmitting, isValid },\n    } = useForm<FormData>({\n        mode: 'onChange',\n        shouldUnregister: true,\n        defaultValues,\n    })\n\n    const [refMode, setRefMode] = useState<RefMode>(userAge ? refModes[0] : refModes[1])\n    const [age, year] = watch(['age', 'year'])\n\n    return (\n        <form\n            onSubmit={handleSubmit((data) =>\n                onSubmit({\n                    monthlySpending: data.monthlySpending,\n                    year:\n                        refMode.value === 'age'\n                            ? DateUtil.ageToYear(data.age!, userAge)\n                            : data.year!,\n                })\n            )}\n        >\n            <span className=\"text-gray-50 text-base inline-block pb-1\">I want to retire</span>\n            <div className=\"flex gap-2\">\n                <Listbox\n                    value={refMode}\n                    onChange={(refMode: RefMode) => {\n                        if (refMode.value === 'age' && year) {\n                            setValue('age', DateUtil.yearToAge(year, userAge))\n                        }\n\n                        if (refMode.value === 'year' && age) {\n                            setValue('year', DateUtil.ageToYear(age, userAge))\n                        }\n\n                        setRefMode(refMode)\n                    }}\n                    className=\"w-1/2\"\n                >\n                    <Listbox.Button>{refMode.display}</Listbox.Button>\n                    <Listbox.Options>\n                        {refModes.map((m) => (\n                            <Listbox.Option key={m.value} value={m}>\n                                {m.display}\n                            </Listbox.Option>\n                        ))}\n                    </Listbox.Options>\n                </Listbox>\n\n                {refMode.value === 'age' && (\n                    <Input\n                        type=\"text\"\n                        error={\n                            errors.age &&\n                            (errors.age.type === 'validate'\n                                ? `Age must be between ${DateUtil.yearToAge(\n                                      planStartYear,\n                                      userAge\n                                  )} and ${DateUtil.yearToAge(planEndYear, userAge)}`\n                                : 'Age required')\n                        }\n                        className=\"w-1/2\"\n                        {...register('age', {\n                            required: true,\n                            validate: (age) => {\n                                if (!age) return true\n                                const year = DateUtil.ageToYear(age, userAge)\n                                return year >= planStartYear && year <= planEndYear\n                            },\n                            valueAsNumber: true,\n                        })}\n                    />\n                )}\n\n                {refMode.value === 'year' && (\n                    <Input\n                        type=\"text\"\n                        error={\n                            errors.year &&\n                            (errors.year.type === 'validate'\n                                ? `Year must be between ${planStartYear} and ${planEndYear}`\n                                : 'Year required')\n                        }\n                        className=\"w-1/2\"\n                        {...register('year', {\n                            required: true,\n                            validate: (year) => {\n                                if (!year) return true\n                                return year >= planStartYear && year <= planEndYear\n                            },\n                            valueAsNumber: true,\n                        })}\n                    />\n                )}\n            </div>\n\n            {mode === 'create' && (\n                <Controller\n                    control={control}\n                    name=\"monthlySpending\"\n                    rules={{ required: true }}\n                    render={({ field, fieldState }) => (\n                        <InputCurrency\n                            label=\"In retirement I expect to spend\"\n                            error={fieldState.error && 'Monthly spending is required'}\n                            className=\"mt-4\"\n                            fixedRightOverride={\n                                <span className=\"text-base text-gray-100\">per month</span>\n                            }\n                            {...field}\n                        />\n                    )}\n                />\n            )}\n\n            <Button\n                type=\"submit\"\n                fullWidth\n                className=\"mt-6\"\n                disabled={isSubmitting || !isValid}\n                data-testid=\"retirement-milestone-form-submit\"\n            >\n                {upperFirst(mode)}\n            </Button>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/RetirementPlanChart.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { PlanUtil } from '@maybe-finance/shared'\nimport { DateUtil, NumberUtil } from '@maybe-finance/shared'\nimport { TSeries } from '@maybe-finance/client/shared'\nimport { Group } from '@visx/group'\nimport { DateTime } from 'luxon'\nimport { useCallback, useMemo } from 'react'\nimport { usePlanContext } from './PlanContext'\nimport { getMilestoneIcon, getEventIcon } from './icon-utils'\nimport { FractionalCircle } from '@maybe-finance/design-system'\nimport classNames from 'classnames'\nimport take from 'lodash/take'\n\ntype Props = {\n    isLoading: boolean\n    isError: boolean\n    dateRange: SharedType.DateRange<DateTime> // date range in years\n    retirement: {\n        milestone: SharedType.PlanProjectionMilestone\n        projection: SharedType.PlanProjectionData\n    } | null\n    maxStackCount: number // determines the max number of icons for a single year (for chart padding)\n    data?: SharedType.PlanProjectionResponse\n    onAddEvent: (date: string) => void\n    failsEarly: boolean\n    mode: 'age' | 'year'\n}\n\nconst MAX_ICONS_PER_DATUM = 4\n\nexport function RetirementPlanChart({\n    isLoading,\n    isError,\n    data,\n    dateRange,\n    retirement,\n    onAddEvent,\n    maxStackCount,\n    failsEarly,\n    mode,\n}: Props) {\n    const { userAge } = usePlanContext()\n\n    const getDatumIcons = useCallback<\n        (date: string) => {\n            events: SharedType.PlanProjectionData['values']['events']\n            milestones: SharedType.PlanProjectionData['values']['milestones']\n        } | null\n    >(\n        (date) => {\n            const currentDatum = data?.projection.data.find((d) => d.date === date)\n\n            if (!currentDatum) return null\n\n            const events =\n                currentDatum.values.events.filter((d) => {\n                    const eventStartYear =\n                        d.event.startYear ??\n                        PlanUtil.resolveMilestoneYear(data!.projection, d.event.startMilestoneId!)\n\n                    const eventEndYear =\n                        d.event.endYear ??\n                        PlanUtil.resolveMilestoneYear(data!.projection, d.event.endMilestoneId!)\n\n                    return (\n                        eventStartYear === currentDatum.values.year ||\n                        eventEndYear === currentDatum.values.year\n                    )\n                }) ?? []\n\n            const milestones = currentDatum?.values.milestones ?? []\n\n            return {\n                events,\n                milestones,\n            }\n        },\n        [data]\n    )\n\n    const colorAccessorFn = useCallback<\n        TSeries.AccessorFn<SharedType.PlanProjectionData['values'], string>\n    >(\n        (datum) => {\n            if (failsEarly) {\n                return TSeries.tailwindScale('red')\n            }\n\n            if (!retirement) {\n                return TSeries.tailwindScale('cyan')\n            }\n\n            const retirementIdx = data?.projection.data.findIndex(\n                (d) => d.date === retirement.projection.date\n            )\n            const datumIdx = data?.projection.data.findIndex((d) => d.date === datum.date)\n\n            if (!retirementIdx || !datumIdx || retirementIdx < 0 || datumIdx < 0) {\n                return TSeries.tailwindScale('cyan')\n            }\n\n            return datumIdx <= retirementIdx\n                ? TSeries.tailwindScale('cyan')\n                : TSeries.tailwindScale('grape')\n        },\n        [data?.projection.data, retirement, failsEarly]\n    )\n\n    const chartData = useMemo(() => {\n        function showDate(date: string) {\n            const _date = DateTime.fromISO(date)\n            const { start, end } = dateRange\n            return _date >= start && _date <= end\n        }\n\n        const projection = data?.projection.data.filter((datum) => showDate(datum.date))\n\n        if (!projection || !data?.simulations.length) return undefined\n\n        const lowerProjection = data?.simulations[0]?.simulation.data.filter((datum) =>\n            showDate(datum.date)\n        )\n        const upperProjection = data?.simulations[\n            data.simulations.length - 1\n        ]?.simulation.data.filter((datum) => showDate(datum.date))\n\n        if (!upperProjection || !lowerProjection) return undefined\n\n        return {\n            projection: projection,\n            upperProjection: upperProjection,\n            lowerProjection: lowerProjection,\n        }\n    }, [data, dateRange])\n\n    return (\n        <TSeries.Chart<SharedType.PlanProjectionData['values']>\n            id=\"retirement-chart\"\n            isLoading={isLoading}\n            isError={isError || (!chartData && !isLoading)}\n            dateRange={{\n                start: dateRange.start.toISODate(),\n                end: dateRange.end.toISODate(),\n            }}\n            margin={{ top: 20, right: 20 }}\n            padding={{\n                top:\n                    0.125 *\n                    (maxStackCount > MAX_ICONS_PER_DATUM ? MAX_ICONS_PER_DATUM : maxStackCount),\n            }} // 12.5% padding for each additional stacked icon, up to 4 total icons max\n            interval={data?.projection.interval}\n            series={[\n                {\n                    key: 'portfolio',\n                    dataKey: 'projection',\n                    accessorFn: (d) => d.values.netWorth?.toNumber(),\n                    color: colorAccessorFn,\n                },\n                {\n                    key: 'portfolio-upper-range',\n                    dataKey: 'upperProjection',\n                    accessorFn: (d) => d.values.netWorth?.toNumber(),\n                },\n                {\n                    key: 'portfolio-lower-range',\n                    dataKey: 'lowerProjection',\n                    accessorFn: (d) => d.values.netWorth?.toNumber(),\n                },\n            ]}\n            data={chartData}\n            tooltipOptions={{\n                referenceSeriesKey: 'portfolio',\n            }}\n            xAxis={\n                <TSeries.AxisBottom\n                    interval={data?.projection.interval}\n                    tickFormat={(d) =>\n                        mode === 'age'\n                            ? DateUtil.yearToAge(\n                                  +DateTime.fromJSDate(d as Date, { zone: 'utc' }).toFormat('yyyy'),\n                                  userAge\n                              ).toString()\n                            : DateTime.fromJSDate(d as Date, { zone: 'utc' }).toFormat('yyyy')\n                    }\n                />\n            }\n            renderTooltip={(tooltipData) => {\n                if (!tooltipData) return null\n\n                const [main, upper, lower] = tooltipData.values\n                const icons = getDatumIcons(tooltipData.date)\n\n                const datumYear = +DateTime.fromISO(tooltipData.date, { zone: 'utc' }).toFormat(\n                    'yyyy'\n                )\n\n                const successRate =\n                    tooltipData.series?.['portfolio'].originalDatum.values.successRate\n\n                return (\n                    <div className=\"flex flex-col gap-2 text-gray-25 text-base w-[250px]\">\n                        <div className=\"bg-gray-700 border border-gray-600 rounded p-2 space-y-1\">\n                            <p className=\"text-gray-100\">\n                                {datumYear}\n                                {` (age ${DateUtil.yearToAge(datumYear, userAge)})`}\n                            </p>\n                            <div className=\"flex items-center gap-1\">\n                                <span className=\"w-1 h-3 bg-cyan mr-2 inline-block rounded-sm\" />\n                                <span>Projected net worth</span>\n                                <span className=\"ml-auto\">\n                                    {NumberUtil.format(main, 'short-currency')}\n                                </span>\n                            </div>\n                            <p className=\"text-gray-100\">\n                                Range:{' '}\n                                {NumberUtil.format(lower, 'short-currency', {\n                                    minimumFractionDigits: 0,\n                                    maximumFractionDigits: 1,\n                                })}\n                                {' to '}\n                                {NumberUtil.format(upper, 'short-currency', {\n                                    minimumFractionDigits: 0,\n                                    maximumFractionDigits: 1,\n                                })}\n                            </p>\n\n                            {successRate && (\n                                <div\n                                    className={classNames(\n                                        'flex items-center gap-2',\n                                        successRate.greaterThanOrEqualTo(0.95)\n                                            ? 'text-teal'\n                                            : successRate.greaterThanOrEqualTo(0.7)\n                                            ? 'text-yellow'\n                                            : 'text-red'\n                                    )}\n                                >\n                                    <FractionalCircle\n                                        percent={successRate.times(100).toNumber()}\n                                        variant={\n                                            successRate.greaterThanOrEqualTo(0.95)\n                                                ? 'green'\n                                                : successRate.greaterThanOrEqualTo(0.7)\n                                                ? 'yellow'\n                                                : 'red'\n                                        }\n                                    />\n\n                                    <span>\n                                        {NumberUtil.format(successRate, 'percent', {\n                                            signDisplay: 'auto',\n                                        })}{' '}\n                                        survival rate\n                                    </span>\n                                </div>\n                            )}\n                        </div>\n                        {icons && icons.milestones.length > 0 && (\n                            <div className=\"bg-gray-700 border border-gray-600 rounded p-2 space-y-2\">\n                                {icons.milestones.map((milestone) => {\n                                    const milestoneIcon = getMilestoneIcon(milestone)\n\n                                    return (\n                                        <div className=\"flex items-center gap-2\" key={milestone.id}>\n                                            <div\n                                                className=\"flex items-center justify-center w-6 h-6 rounded-full\"\n                                                style={{\n                                                    backgroundColor: milestoneIcon.bgColor,\n                                                }}\n                                            >\n                                                <milestoneIcon.icon\n                                                    className=\"w-4 h-4 text-cyan\"\n                                                    style={{ color: milestoneIcon.color }}\n                                                />\n                                            </div>\n                                            <span>{milestoneIcon.label}</span>\n                                        </div>\n                                    )\n                                })}\n                            </div>\n                        )}\n                        {icons && icons.events.length > 0 && chartData?.projection && (\n                            <div className=\"bg-gray-700 border border-gray-600 rounded p-2 space-y-2\">\n                                {icons.events.map((event) => {\n                                    if (!data?.projection) return null\n\n                                    const eventIcon = getEventIcon(\n                                        event,\n                                        data.projection,\n                                        datumYear\n                                    )\n\n                                    return (\n                                        <div\n                                            className=\"flex items-center gap-2\"\n                                            key={event.event.id}\n                                        >\n                                            <div\n                                                className=\"flex items-center justify-center w-6 h-6 rounded-full\"\n                                                style={{ backgroundColor: eventIcon.bgColor }}\n                                            >\n                                                <eventIcon.icon\n                                                    className=\"w-4 h-4\"\n                                                    style={{ color: eventIcon.color }}\n                                                />\n                                            </div>\n                                            <span>{eventIcon.label}</span>\n                                        </div>\n                                    )\n                                })}\n                            </div>\n                        )}\n                    </div>\n                )\n            }}\n        >\n            {({ xScale, data: ctxData, tooltipOpen, tooltipData, y1Scale }) => {\n                const upperData = Array.isArray(ctxData) ? ctxData : ctxData['upperProjection']\n\n                return (\n                    <>\n                        <TSeries.LineRange\n                            mainSeriesKey=\"portfolio\"\n                            lowerSeriesKey=\"portfolio-upper-range\"\n                            upperSeriesKey=\"portfolio-lower-range\"\n                            renderGlyph={(tooltipData, left, top) => {\n                                return (\n                                    <TSeries.PlusCircleGlyph\n                                        className=\"cursor-pointer hover:opacity-90\"\n                                        fill={\n                                            tooltipData.series?.['portfolio']\n                                                ? colorAccessorFn(\n                                                      tooltipData.series?.['portfolio']\n                                                          .originalDatum\n                                                  )\n                                                : TSeries.tailwindScale('cyan')\n                                        }\n                                        stroke=\"black\"\n                                        left={left}\n                                        top={top}\n                                        onClick={() => onAddEvent(tooltipData.date)}\n                                    />\n                                )\n                            }}\n                        />\n\n                        {/* Event icons - for proper SVG stacking order, keep these as the last children of the chart  */}\n                        <Group>\n                            {upperData.map((datum, idx) => {\n                                const icons = getDatumIcons(datum.date)\n\n                                // Place icon above the highest series value\n                                const upperValue = datum.values.netWorth?.toNumber()\n\n                                if (\n                                    !upperValue ||\n                                    !icons ||\n                                    (!icons.events.length && !icons.milestones.length)\n                                )\n                                    return null\n\n                                const isCurrent =\n                                    tooltipOpen && tooltipData && tooltipData.date === datum.date\n\n                                // Stack each event on top of each other if multiple events per datum\n                                return (\n                                    <Group key={idx}>\n                                        {icons.milestones.map((milestone, iconIdx) => {\n                                            const milestoneIcon = getMilestoneIcon(milestone)\n\n                                            return (\n                                                <TSeries.FloatingIcon\n                                                    key={iconIdx}\n                                                    left={xScale(datum.dateJS)}\n                                                    top={y1Scale(upperValue) - 25}\n                                                    stackIdx={iconIdx}\n                                                    fill={\n                                                        isCurrent\n                                                            ? milestoneIcon.bgColor\n                                                            : TSeries.tailwindBgScale('gray')\n                                                    }\n                                                    icon={milestoneIcon.icon}\n                                                    iconColor={\n                                                        isCurrent\n                                                            ? milestoneIcon.color\n                                                            : TSeries.tailwindScale('gray-100')\n                                                    }\n                                                />\n                                            )\n                                        })}\n\n                                        {take(\n                                            icons.events,\n                                            MAX_ICONS_PER_DATUM - icons.milestones.length\n                                        ).map((event, iconIdx) => {\n                                            if (!data?.projection) return null\n\n                                            const eventIcon = getEventIcon(\n                                                event,\n                                                data.projection,\n                                                datum.values.year\n                                            )\n\n                                            return (\n                                                <TSeries.FloatingIcon\n                                                    key={iconIdx}\n                                                    left={xScale(datum.dateJS)}\n                                                    top={y1Scale(upperValue) - 25}\n                                                    stackIdx={iconIdx + icons.milestones.length}\n                                                    fill={\n                                                        isCurrent\n                                                            ? eventIcon.bgColor\n                                                            : TSeries.tailwindBgScale('gray')\n                                                    }\n                                                    icon={eventIcon.icon}\n                                                    iconColor={\n                                                        isCurrent\n                                                            ? eventIcon.color\n                                                            : TSeries.tailwindScale('gray-100')\n                                                    }\n                                                />\n                                            )\n                                        })}\n                                    </Group>\n                                )\n                            })}\n                        </Group>\n                    </>\n                )\n            }}\n        </TSeries.Chart>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/icon-utils.ts",
    "content": "import { RiMoneyDollarBoxLine, RiMoneyDollarCircleLine } from 'react-icons/ri'\nimport { GiPalmTree } from 'react-icons/gi'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { PlanUtil } from '@maybe-finance/shared'\nimport { TSeries } from '@maybe-finance/client/shared'\n\nexport function getEventIcon(\n    { event }: SharedType.PlanProjectionEvent,\n    projection: SharedType.PlanProjectionResponse['projection'],\n    currentYear?: number\n) {\n    const eventStartYear =\n        event.startYear ?? PlanUtil.resolveMilestoneYear(projection, event.startMilestoneId!)\n    const eventEndYear =\n        event.endYear ?? PlanUtil.resolveMilestoneYear(projection, event.endMilestoneId!)\n\n    const status =\n        currentYear && eventStartYear === currentYear\n            ? 'starting'\n            : currentYear && eventEndYear === currentYear\n            ? 'ending'\n            : 'active'\n\n    const color = event.initialValue.isNegative() ? 'red' : 'cyan'\n\n    return {\n        icon: RiMoneyDollarBoxLine,\n        color: TSeries.tailwindScale(color),\n        bgColor: TSeries.tailwindBgScale(color),\n        label:\n            status === 'starting'\n                ? `Start of ${event.name}`\n                : status === 'ending'\n                ? `End of ${event.name}`\n                : event.name,\n    }\n}\n\nexport function getMilestoneIcon(milestone: SharedType.PlanMilestone) {\n    if (milestone.category === PlanUtil.PlanMilestoneCategory.Retirement) {\n        return {\n            icon: GiPalmTree,\n            color: TSeries.tailwindScale('cyan'),\n            bgColor: TSeries.tailwindBgScale('cyan'),\n            label: milestone.name,\n        }\n    }\n\n    return {\n        icon: RiMoneyDollarCircleLine,\n        color: TSeries.tailwindScale('cyan'),\n        bgColor: TSeries.tailwindBgScale('cyan'),\n        label: milestone.name,\n    }\n}\n"
  },
  {
    "path": "libs/client/features/src/plans/index.ts",
    "content": "export * from './NewPlanForm'\nexport * from './PlanContext'\nexport * from './PlanEventList'\nexport * from './PlanEventPopout'\nexport * from './PlanParameterCard'\nexport * from './PlanRangeInput'\nexport * from './PlanRangeSelector'\nexport * from './RetirementPlanChart'\nexport * from './PlanMenu'\nexport * from './PlanMilestones'\nexport * from './AddPlanScenario'\nexport * from './RetirementMilestoneForm'\nexport * from './PlanEventForm'\n"
  },
  {
    "path": "libs/client/features/src/transactions-list/ExcludeTransactionDialog.tsx",
    "content": "import { useTransactionApi } from '@maybe-finance/client/shared'\nimport { Button, Dialog } from '@maybe-finance/design-system'\nimport type { SharedType } from '@maybe-finance/shared'\n\nexport interface ExcludeTransactionDialogProps {\n    transaction: SharedType.AccountTransaction\n    excluding: boolean\n    isOpen: boolean\n    onClose: (excluded: boolean) => void\n}\n\nexport function ExcludeTransactionDialog({\n    transaction,\n    excluding,\n    isOpen,\n    onClose,\n}: ExcludeTransactionDialogProps) {\n    const { useUpdateTransaction } = useTransactionApi()\n\n    const updateTransaction = useUpdateTransaction()\n\n    return (\n        <Dialog\n            isOpen={isOpen}\n            onClose={() => onClose(transaction.excluded)}\n            showCloseButton={false}\n        >\n            <Dialog.Title>{excluding ? 'Exclude from' : 'Include in'} insights</Dialog.Title>\n            <Dialog.Content>\n                <p className=\"mt-4 text-base text-gray-50\">\n                    {excluding ? (\n                        <>\n                            Excluding this transaction will prevent it from impacting your income,\n                            expense, and debt insights.\n                        </>\n                    ) : (\n                        <>\n                            Including this transaction will allow it to impact your income, expense,\n                            and debt insights.\n                        </>\n                    )}\n                </p>\n                <div className=\"mt-8 grid grid-cols-2 gap-4\">\n                    <Button variant=\"secondary\" onClick={() => onClose(transaction.excluded)}>\n                        Cancel\n                    </Button>\n                    <Button\n                        variant=\"primary\"\n                        disabled={updateTransaction.isLoading}\n                        onClick={async () => {\n                            await updateTransaction.mutateAsync({\n                                id: transaction.id,\n                                data: { excluded: excluding },\n                            })\n                            onClose(excluding)\n                        }}\n                    >\n                        {excluding ? 'Exclude' : 'Include'}\n                    </Button>\n                </div>\n            </Dialog.Content>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/transactions-list/TransactionList.tsx",
    "content": "import { useMemo } from 'react'\nimport { LoadingSpinner } from '@maybe-finance/design-system'\nimport { DateTime } from 'luxon'\nimport { useAccountApi, useUserAccountContext, InfiniteScroll } from '@maybe-finance/client/shared'\nimport groupBy from 'lodash/groupBy'\nimport { TransactionListItem } from './TransactionListItem'\n\nexport function TransactionList({ accountId }: { accountId: number }) {\n    const { isReady } = useUserAccountContext()\n\n    const { useAccountTransactions } = useAccountApi()\n\n    const accountTransactionsQuery = useAccountTransactions(\n        { id: accountId },\n        { enabled: !!accountId && isReady }\n    )\n\n    const groupedTransactions = useMemo(() => {\n        if (!accountTransactionsQuery.data?.pages) return {}\n\n        // Flatten, normalize, and group transactions by date\n        const transactions = accountTransactionsQuery.data.pages\n            .flatMap((page) => page.transactions)\n            .map((txn) => ({\n                ...txn,\n                dateFormatted: DateTime.fromJSDate(txn.date, { zone: 'utc' }).toFormat(\n                    'MMM d yyyy'\n                ),\n                // Flip amount values to be more user-friendly (positive inflow, negative outflow)\n                amount: txn.amount.negated(),\n            }))\n\n        return groupBy(transactions, (t) => t.dateFormatted)\n    }, [accountTransactionsQuery.data])\n\n    return (\n        <div className=\"pb-4\">\n            {accountTransactionsQuery?.data &&\n                (Object.keys(groupedTransactions).length ? (\n                    <InfiniteScroll\n                        getScrollParent={() => document.getElementById('mainScrollArea')}\n                        useWindow={false}\n                        initialLoad={false}\n                        loadMore={() => accountTransactionsQuery.fetchNextPage()}\n                        hasMore={accountTransactionsQuery.hasNextPage}\n                    >\n                        <div className=\"text-base\">\n                            {[...Object.keys(groupedTransactions)].map((group) => (\n                                <div key={group}>\n                                    <div className=\"font-medium\">{group}</div>\n                                    <ol className=\"mt-4 mb-6 rounded-xl border border-gray-500 p-4\">\n                                        {groupedTransactions[group].map((transaction) => (\n                                            <TransactionListItem\n                                                transaction={transaction}\n                                                key={transaction.id}\n                                            />\n                                        ))}\n                                    </ol>\n                                </div>\n                            ))}\n                        </div>\n                    </InfiniteScroll>\n                ) : (\n                    <div className=\"text-base text-gray-100\">\n                        No transactions found for the selected date range\n                    </div>\n                ))}\n            {}\n            {(accountTransactionsQuery.isLoading ||\n                accountTransactionsQuery.isFetchingNextPage) && (\n                <div className=\"flex items-center justify-center py-2\">\n                    <LoadingSpinner variant=\"secondary\" />\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/transactions-list/TransactionListItem.tsx",
    "content": "import { useState } from 'react'\nimport Image from 'next/legacy/image'\nimport {\n    RiAddLine as PlusIcon,\n    RiEyeLine,\n    RiEyeOffLine,\n    RiMore2Fill,\n    RiPencilLine,\n    RiSubtractLine as MinusIcon,\n    RiTimeLine as PendingIcon,\n} from 'react-icons/ri'\nimport { NumberUtil, TransactionUtil } from '@maybe-finance/shared'\nimport { BrowserUtil, useTransactionApi } from '@maybe-finance/client/shared'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { Button, DialogV2, Listbox, Menu } from '@maybe-finance/design-system'\nimport classNames from 'classnames'\nimport { type InfiniteData, useQueryClient } from '@tanstack/react-query'\nimport toast from 'react-hot-toast'\nimport { Controller, useForm } from 'react-hook-form'\nimport type { TransactionType } from '@prisma/client'\n\nconst types = {\n    INCOME: 'Income',\n    EXPENSE: 'Expense',\n    TRANSFER: 'Transfer',\n    PAYMENT: 'Debt payment',\n}\n\nexport function TransactionListItem({\n    transaction,\n}: {\n    transaction: SharedType.AccountTransactionResponse['transactions'][0]\n}) {\n    const queryClient = useQueryClient()\n    const { useUpdateTransaction } = useTransactionApi()\n    const [editTxn, setEditTxn] = useState(false)\n\n    // Optimistically updates transaction, see https://github.com/TanStack/query/discussions/848#discussioncomment-473919\n    const updateTxn = useUpdateTransaction({\n        async onMutate(updatedTxnData) {\n            const queryKey = ['accounts', transaction.accountId, 'transactions']\n            await queryClient.cancelQueries({ queryKey })\n            const previousTxns = queryClient.getQueryData(queryKey)\n\n            // Finds transaction in pages and optimistically updates\n            queryClient.setQueryData<InfiniteData<SharedType.AccountTransactionResponse>>(\n                queryKey,\n                (data) => {\n                    if (data) {\n                        return {\n                            ...data,\n                            pages: data.pages.map((page) => ({\n                                ...page,\n                                transactions: page.transactions.map((txn) => {\n                                    return txn.id === transaction.id\n                                        ? {\n                                              ...txn,\n                                              ...updatedTxnData.data,\n                                              type: updatedTxnData.data.typeUser\n                                                  ? updatedTxnData.data.typeUser\n                                                  : txn.type,\n                                              category: updatedTxnData.data.categoryUser\n                                                  ? updatedTxnData.data.categoryUser\n                                                  : txn.category,\n                                          }\n                                        : txn\n                                }),\n                            })),\n                        }\n                    }\n\n                    return data\n                }\n            )\n\n            return { previousTxns }\n        },\n        onSettled() {\n            queryClient.invalidateQueries(['users', 'insights'])\n            setEditTxn(false)\n        },\n        onSuccess() {\n            toast.success('Transaction updated!')\n        },\n        onError(err, newTxn, context) {\n            toast.error('Transaction failed to update.')\n            queryClient.setQueryData(\n                ['accounts', transaction.accountId, 'transactions'],\n                (context as any).previousTxns\n            )\n        },\n    })\n\n    const { control, handleSubmit } = useForm({\n        defaultValues: {\n            categoryUser: transaction.category,\n            typeUser: transaction.type,\n        },\n    })\n\n    const isPositive = transaction.amount.isPositive()\n\n    const subtext = [\n        transaction.pending && 'Pending',\n        transaction.excluded && 'Excluded from insights',\n    ].filter((t) => typeof t === 'string')\n\n    return (\n        <li className=\"flex flex-wrap items-center justify-between\" key={transaction.id}>\n            <div className=\"flex items-center my-2\">\n                <div className=\"relative\">\n                    {transaction.category !== 'TRANSFER' ? (\n                        <div className=\"relative h-8 w-8 sm:w-12 sm:h-12 bg-gray-400 rounded-xl overflow-hidden\">\n                            <Image\n                                loader={BrowserUtil.enhancerizerLoader}\n                                src={JSON.stringify({\n                                    kind: 'merchant',\n                                    ...(transaction.merchantName\n                                        ? {\n                                              name: transaction.merchantName,\n                                          }\n                                        : {}),\n                                    description: transaction.name,\n                                })}\n                                layout=\"fill\"\n                                sizes=\"48px, 64px, 96px, 128px\"\n                                onError={({ currentTarget }) => {\n                                    // Fail gracefully and hide image\n                                    currentTarget.onerror = null\n                                    currentTarget.style.display = 'none'\n                                }}\n                            />\n                            {transaction.excluded && (\n                                <div className=\"absolute flex items-center justify-center w-full h-full z-10 bg-gray-800 bg-opacity-50\">\n                                    <RiEyeOffLine className=\"w-5 h-5 text-gray-50\" />\n                                </div>\n                            )}\n                        </div>\n                    ) : (\n                        <div\n                            className={`flex items-center justify-center w-12 h-12 rounded-xl bg-opacity-10 ${\n                                isPositive ? 'bg-teal' : 'bg-red'\n                            }`}\n                        >\n                            {isPositive ? (\n                                <PlusIcon className=\"w-5 h-5 text-teal-500\" />\n                            ) : (\n                                <MinusIcon className=\"w-5 h-5 text-red-500\" />\n                            )}\n                        </div>\n                    )}\n                    {transaction.pending && (\n                        <div className=\"absolute flex items-center justify-center -bottom-1 -right-2 w-5 h-5 box-border rounded-full border-2 border-gray-700 bg-gray-500\">\n                            <PendingIcon className=\"w-3.5 h-3.5\" />\n                        </div>\n                    )}\n                </div>\n                <div className=\"ml-4 text-sm sm:text-base\">\n                    <div title={transaction.name} className=\"w-[100px] lg:w-auto truncate\">\n                        {transaction.merchantName || transaction.name}\n                    </div>\n                    <div className=\"text-sm text-gray-200\">\n                        {subtext.length > 0 && <em>{subtext.join(' • ')}</em>}\n                    </div>\n                </div>\n            </div>\n            <div className=\"flex items-center gap-2\">\n                <div className=\"hidden sm:flex xl:hidden items-center justify-self-end\">\n                    <div\n                        className={classNames(\n                            'w-1 h-3 mr-3 rounded-full',\n                            isPositive ? 'bg-teal' : 'bg-red',\n                            transaction.type === 'INCOME'\n                                ? 'bg-green'\n                                : transaction.type === 'EXPENSE'\n                                ? 'bg-red'\n                                : transaction.type === 'TRANSFER'\n                                ? 'bg-teal'\n                                : 'bg-orange'\n                        )}\n                    ></div>\n                    {types[transaction.type]}{' '}\n                    <span className=\"text-gray-50 inline-block mx-1\">/</span>\n                    {transaction.category}\n                </div>\n                <div className=\"hidden xl:flex items-center justify-self-end gap-3\">\n                    <Listbox\n                        value={transaction.type}\n                        onChange={(type) => {\n                            updateTxn.mutate({\n                                id: transaction.id,\n                                data: { typeUser: type },\n                            })\n                        }}\n                    >\n                        <Listbox.Button className=\"bg-transparent\">\n                            <div className=\"flex items-center gap-3\">\n                                <div\n                                    className={classNames(\n                                        'w-1 h-3 rounded-full',\n                                        transaction.type === 'INCOME'\n                                            ? 'bg-green'\n                                            : transaction.type === 'EXPENSE'\n                                            ? 'bg-red'\n                                            : transaction.type === 'TRANSFER'\n                                            ? 'bg-teal'\n                                            : 'bg-orange'\n                                    )}\n                                ></div>\n                                {types[transaction.type]}\n                            </div>\n                        </Listbox.Button>\n                        <Listbox.Options>\n                            {Object.keys(types).map((type) => (\n                                <Listbox.Option key={type} value={type}>\n                                    {types[type as TransactionType]}\n                                </Listbox.Option>\n                            ))}\n                        </Listbox.Options>\n                    </Listbox>\n\n                    <Listbox\n                        value={transaction.category}\n                        onChange={(category) => {\n                            updateTxn.mutate({\n                                id: transaction.id,\n                                data: { categoryUser: category },\n                            })\n                        }}\n                    >\n                        <Listbox.Button>{transaction.category}</Listbox.Button>\n                        <Listbox.Options>\n                            {TransactionUtil.CATEGORIES.map((category) => (\n                                <Listbox.Option key={category} value={category}>\n                                    {category}\n                                </Listbox.Option>\n                            ))}\n                        </Listbox.Options>\n                    </Listbox>\n                </div>\n                <div\n                    className={`sm:min-w-[100px] text-sm sm:text-base text-right font-semibold tabular-nums ${\n                        isPositive ? 'text-teal-500' : 'text-red-500'\n                    }`}\n                >\n                    {NumberUtil.format(transaction.amount, 'currency')}\n                </div>\n                <div>\n                    <Menu>\n                        <Menu.Button variant=\"icon\">\n                            <RiMore2Fill className=\"text-gray-50\" />\n                        </Menu.Button>\n                        <Menu.Items placement=\"bottom-end\">\n                            <Menu.Item\n                                icon={transaction.excluded ? <RiEyeLine /> : <RiEyeOffLine />}\n                                onClick={() =>\n                                    updateTxn.mutate({\n                                        id: transaction.id,\n                                        data: { excluded: !transaction.excluded },\n                                    })\n                                }\n                            >\n                                {transaction.excluded\n                                    ? 'Include in insights'\n                                    : 'Exclude from insights'}\n                            </Menu.Item>\n\n                            <Menu.Item\n                                icon={<RiPencilLine />}\n                                onClick={() => setEditTxn(true)}\n                                className=\"xl:hidden\"\n                            >\n                                Edit Transaction\n                            </Menu.Item>\n                            <DialogV2\n                                title=\"Edit transaction\"\n                                open={editTxn}\n                                className=\"mx-2\"\n                                onClose={() => setEditTxn(false)}\n                            >\n                                <form\n                                    className=\"space-y-6\"\n                                    onSubmit={handleSubmit(({ typeUser, categoryUser }) => {\n                                        updateTxn.mutate({\n                                            id: transaction.id,\n                                            data: {\n                                                typeUser,\n                                                categoryUser,\n                                            },\n                                        })\n                                    })}\n                                >\n                                    <Controller\n                                        name=\"typeUser\"\n                                        control={control}\n                                        render={({ field }) => (\n                                            <Listbox {...field}>\n                                                <Listbox.Button label=\"Type\">\n                                                    {types[field.value]}\n                                                </Listbox.Button>\n                                                <Listbox.Options>\n                                                    {Object.keys(types).map((type) => (\n                                                        <Listbox.Option key={type} value={type}>\n                                                            {types[type as TransactionType]}\n                                                        </Listbox.Option>\n                                                    ))}\n                                                </Listbox.Options>\n                                            </Listbox>\n                                        )}\n                                    />\n\n                                    <Controller\n                                        name=\"categoryUser\"\n                                        control={control}\n                                        render={({ field }) => (\n                                            <Listbox {...field}>\n                                                <Listbox.Button label=\"Category\">\n                                                    {field.value}\n                                                </Listbox.Button>\n                                                <Listbox.Options>\n                                                    {TransactionUtil.CATEGORIES.map((category) => (\n                                                        <Listbox.Option\n                                                            key={category}\n                                                            value={category}\n                                                        >\n                                                            {category}\n                                                        </Listbox.Option>\n                                                    ))}\n                                                </Listbox.Options>\n                                            </Listbox>\n                                        )}\n                                    />\n\n                                    <Button type=\"submit\" fullWidth>\n                                        Save\n                                    </Button>\n                                </form>\n                            </DialogV2>\n                        </Menu.Items>\n                    </Menu>\n                </div>\n            </div>\n        </li>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/transactions-list/index.ts",
    "content": "export * from './TransactionList'\n"
  },
  {
    "path": "libs/client/features/src/user-billing/BillingPreferences.tsx",
    "content": "import { useUserApi } from '@maybe-finance/client/shared'\nimport { Button, LoadingSpinner } from '@maybe-finance/design-system'\nimport { useState } from 'react'\nimport { RiExternalLinkLine } from 'react-icons/ri'\nimport { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'\nimport { UpgradeTakeover } from '.'\n\nexport function BillingPreferences() {\n    const { useSubscription, useCreateCustomerPortalSession } = useUserApi()\n\n    const { data, isLoading, isError } = useSubscription()\n    const createCustomerPortalSession = useCreateCustomerPortalSession()\n\n    const [takeoverOpen, setTakeoverOpen] = useState(false)\n\n    if (isError) {\n        return <p className=\"text-gray-50\">Failed to load billing information.</p>\n    }\n\n    return (\n        <>\n            <div className=\"max-w-lg mt-6 text-base\">\n                <h4 className=\"mb-2 text-lg uppercase\">Billing</h4>\n                {isLoading || !data ? (\n                    <div className=\"flex items-center justify-center max-w-full py-8 w-lg\">\n                        <LoadingSpinner />\n                    </div>\n                ) : (\n                    <div className=\"overflow-hidden bg-gray-800 rounded-lg\">\n                        <div className=\"flex items-center p-4\">\n                            <div className=\"pr-4 grow text-gray-50\">\n                                {data.trialing ? (\n                                    <p>\n                                        Your free trial will end on{' '}\n                                        <span className=\"text-white\">\n                                            {data.trialEnd?.toFormat('MMMM d, yyyy')}\n                                        </span>\n                                    </p>\n                                ) : data.subscribed ? (\n                                    <p>\n                                        Your current plan will {data.canceled ? 'end' : 'renew'} on{' '}\n                                        <span className=\"text-white\">\n                                            {data.currentPeriodEnd?.toFormat('MMMM d, yyyy')}\n                                        </span>\n                                    </p>\n                                ) : (\n                                    <p>You&rsquo;re not currently subscribed to Maybe.</p>\n                                )}\n                            </div>\n                            <div className=\"shrink-0\">\n                                {data.subscribed && !data.trialing ? (\n                                    <Button\n                                        variant=\"secondary\"\n                                        onClick={() =>\n                                            createCustomerPortalSession\n                                                .mutateAsync('monthly')\n                                                .then(({ url }) => (window.location.href = url))\n                                        }\n                                    >\n                                        Manage\n                                        {createCustomerPortalSession.isLoading ||\n                                        createCustomerPortalSession.isSuccess ? (\n                                            <LoadingIcon className=\"text-gray-100 ml-2.5 mr-1 w-3.5 h-3.5 animate-spin\" />\n                                        ) : (\n                                            <RiExternalLinkLine className=\"w-5 h-5 ml-2\" />\n                                        )}\n                                    </Button>\n                                ) : (\n                                    <Button variant=\"primary\" onClick={() => setTakeoverOpen(true)}>\n                                        Subscribe\n                                    </Button>\n                                )}\n                            </div>\n                        </div>\n                        <div className=\"p-3 text-sm text-gray-100 bg-gray-700\">\n                            You&rsquo;ll be redirected to Stripe to manage billing.\n                        </div>\n                    </div>\n                )}\n            </div>\n            {process.env.STRIPE_API_KEY && (\n                <UpgradeTakeover open={takeoverOpen} onClose={() => setTakeoverOpen(false)} />\n            )}\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/user-billing/PlanSelector.tsx",
    "content": "import classNames from 'classnames'\n\nexport type PlanSelectorProps = {\n    selected: 'monthly' | 'yearly'\n    onChange: (selected: 'monthly' | 'yearly') => void\n    className?: string\n}\n\nexport function PlanSelector({ selected, onChange, className }: PlanSelectorProps) {\n    return (\n        <div\n            className={classNames(\n                className,\n                'flex flex-wrap space-y-4 sm:flex-nowrap sm:space-x-6 sm:space-y-0'\n            )}\n        >\n            <PlanOption\n                name=\"Annual\"\n                bonus=\"Save $79\"\n                price=\"$149\"\n                period=\"year\"\n                subtext=\"$12.42/month billed yearly\"\n                active={selected === 'yearly'}\n                onClick={() => onChange('yearly')}\n            />\n            <PlanOption\n                name=\"Monthly\"\n                price=\"$19\"\n                period=\"month\"\n                subtext=\"$228/year billed monthly\"\n                active={selected === 'monthly'}\n                onClick={() => onChange('monthly')}\n            />\n        </div>\n    )\n}\n\nfunction PlanOption({\n    name,\n    bonus,\n    price,\n    period,\n    subtext,\n    onClick,\n    active,\n}: {\n    name: string\n    bonus?: string\n    price: string\n    period: string\n    subtext: string\n    onClick: () => void\n    active: boolean\n}) {\n    return (\n        <button\n            className={classNames(\n                'block w-full p-4 text-left text-base rounded-xl border transition-colors duration-50 cursor-pointer outline-none',\n                active ? 'border-cyan bg-gray-600' : 'border-gray-500 hover:bg-gray-700'\n            )}\n            onClick={onClick}\n        >\n            <div className=\"flex items-start\">\n                <div className=\"pr-2 text-gray-100 grow\">{name}</div>\n                {bonus && (\n                    <div\n                        className={classNames(\n                            'shrink-0 flex items-center space-x-2.5 -mr-2 -mt-2 py-1 px-2 rounded-lg font-medium text-sm leading-4',\n                            active ? 'text-black bg-cyan' : 'text-white bg-gray-500',\n                            bonus && 'pr-1.5'\n                        )}\n                    >\n                        {bonus}\n                    </div>\n                )}\n            </div>\n            <div className=\"mt-1 text-lg font-bold text-white font-display\">\n                <span className=\"text-3xl\">{price}</span>/{period}\n            </div>\n            <div className=\"text-gray-100\">{subtext}</div>\n        </button>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/user-billing/PremiumIcon.tsx",
    "content": "import classNames from 'classnames'\n\nconst sizes = Object.freeze({\n    md: {\n        glow: 'blur-[6px]',\n        outer: 'w-8 h-8',\n    },\n    xl: {\n        glow: 'blur-[15px]',\n        outer: 'w-20 h-20',\n    },\n})\n\ntype PremiumIconProps = React.HTMLAttributes<HTMLImageElement> & {\n    size: keyof typeof sizes\n    tilt?: boolean\n    glow?: boolean\n}\n\nexport function PremiumIcon({\n    size,\n    tilt = true,\n    glow = true,\n    className,\n    ...rest\n}: PremiumIconProps) {\n    return (\n        <div\n            className={classNames(\n                className,\n                'relative',\n                sizes[size].outer,\n                tilt && 'rotate-[4deg]'\n            )}\n        >\n            {glow && (\n                <div\n                    className={classNames(\n                        'absolute block inset-0 opacity-60',\n                        'bg-[linear-gradient(192deg,#52EDFF_9.79%,#4361EE_31.87%,#7209B7_60.44%,#F12980_90.2%)]',\n                        sizes[size].glow\n                    )}\n                ></div>\n            )}\n            <img\n                alt=\"Maybe\"\n                src=\"/assets/maybe-box.svg\"\n                className={'relative w-full h-full'}\n                {...rest}\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/user-billing/SubscriberGuard.tsx",
    "content": "import { signOut } from 'next-auth/react'\nimport { MainContentOverlay, useUserApi } from '@maybe-finance/client/shared'\nimport { LoadingSpinner } from '@maybe-finance/design-system'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { useRouter } from 'next/router'\nimport type { PropsWithChildren } from 'react'\n\nfunction shouldRedirect(path: string, data?: SharedType.UserSubscription) {\n    if (!data || data?.subscribed) return false\n\n    // Redirect to upgrade for all paths except /accounts, /onboarding, /upgrade, and /settings\n    if (\n        !['/accounts', '/upgrade'].includes(path) &&\n        !path.startsWith('/settings') &&\n        !path.startsWith('/onboarding')\n    ) {\n        return true\n    }\n\n    return false\n}\n\nexport function SubscriberGuard({ children }: PropsWithChildren) {\n    const router = useRouter()\n    const { useSubscription } = useUserApi()\n    const subscription = useSubscription()\n\n    if (subscription.isError) {\n        return (\n            <MainContentOverlay\n                title=\"Unable to load subscription\"\n                actionText=\"Log out\"\n                onAction={() => signOut()}\n            >\n                <p>Contact us if this issue persists.</p>\n            </MainContentOverlay>\n        )\n    }\n\n    if (subscription.isLoading || shouldRedirect(router.asPath, subscription.data)) {\n        return (\n            <div className=\"absolute inset-0 flex items-center justify-center\">\n                <LoadingSpinner />\n            </div>\n        )\n    }\n\n    // eslint-disable-next-line react/jsx-no-useless-fragment\n    return <>{children}</>\n}\n"
  },
  {
    "path": "libs/client/features/src/user-billing/UpgradePrompt.tsx",
    "content": "import { useRef, useState } from 'react'\nimport type { MouseEvent, CSSProperties } from 'react'\nimport { useUserApi } from '@maybe-finance/client/shared'\nimport { Button } from '@maybe-finance/design-system'\nimport { PremiumIcon } from './PremiumIcon'\nimport { animate, motion, useMotionValue } from 'framer-motion'\nimport { UpgradeTakeover } from './UpgradeTakeover'\n\nexport function UpgradePrompt() {\n    const { useSubscription } = useUserApi()\n\n    const { data, isSuccess } = useSubscription()\n\n    const container = useRef<HTMLDivElement>(null)\n    const mouseX = useMotionValue(0.5)\n    const mouseY = useMotionValue(0.5)\n\n    const mouseMove = (e: MouseEvent<HTMLDivElement>) => {\n        if (!container.current) return\n\n        const rect = container.current.getBoundingClientRect()\n        animate(mouseX, (e.clientX - rect.left) / rect.width)\n        animate(mouseY, (e.clientY - rect.top) / rect.height)\n    }\n\n    const [takeoverOpen, setTakeoverOpen] = useState(false)\n\n    const trialDaysLeft = Math.ceil(data?.trialEnd?.diffNow('days').days ?? 0)\n\n    return isSuccess && (!data?.subscribed || data.trialing) ? (\n        <>\n            <motion.div\n                ref={container}\n                className=\"p-[1px] rounded-lg\"\n                style={\n                    {\n                        '--mx': mouseX,\n                        '--my': mouseY,\n                        backgroundImage: `\n                        linear-gradient(calc((var(--mx) + 0.5) * 45deg), #2C2D32EE 30%, transparent, #2C2D32EE 70%),\n                        linear-gradient(148.38deg, #4CC9F0 28.24%, #4361EE 46.15%, #7209B7 61.01%, #F72585 80.62%)\n                    `,\n                    } as CSSProperties\n                }\n                onPointerMove={mouseMove}\n            >\n                <div\n                    className=\"p-3 rounded-lg bg-gray-700\"\n                    style={{\n                        backgroundImage: `\n                        radial-gradient(farthest-corner circle at 30% calc((var(--my) - 0.5) * 100%), #4CC9F00F, transparent 50%),\n                        radial-gradient(farthest-corner circle at 70% calc((var(--my) + 0.5) * 100%), #F725850F, transparent 50%)\n                    `,\n                    }}\n                >\n                    <div className=\"flex space-x-3\">\n                        <PremiumIcon size=\"md\" className=\"shrink-0\" />\n                        <div className=\"grow text-base text-white cursor-default\">\n                            {data.trialing ? (\n                                <>\n                                    {trialDaysLeft} day{trialDaysLeft !== 1 ? 's' : ''} left in your\n                                    free trial\n                                </>\n                            ) : (\n                                <>Subscribe to continue using Maybe</>\n                            )}\n                        </div>\n                    </div>\n                    <div className=\"flex justify-end mt-2\">\n                        <Button\n                            onClick={() => setTakeoverOpen(true)}\n                            variant=\"secondary\"\n                            className=\"!py-1 !px-3\"\n                            data-testid=\"upgrade-prompt-upgrade-button\"\n                        >\n                            Subscribe\n                        </Button>\n                    </div>\n                </div>\n            </motion.div>\n            <UpgradeTakeover open={takeoverOpen} onClose={() => setTakeoverOpen(false)} />\n        </>\n    ) : null\n}\n"
  },
  {
    "path": "libs/client/features/src/user-billing/UpgradeTakeover.tsx",
    "content": "import type { IconType } from 'react-icons'\nimport { Fragment, useRef, useState } from 'react'\nimport { Button, Takeover } from '@maybe-finance/design-system'\nimport { PlanSelector, PremiumIcon } from '.'\nimport { Transition } from '@headlessui/react'\nimport {\n    RiArrowUpLine,\n    RiBankLine,\n    RiCloseFill,\n    RiFlagLine,\n    RiLineChartLine,\n    RiLinksLine,\n    RiWechatLine,\n} from 'react-icons/ri'\nimport { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'\nimport { TakeoverBackground, useUserApi } from '@maybe-finance/client/shared'\nimport upperFirst from 'lodash/upperFirst'\nimport { FeaturesGlow, SideGrid } from './graphics'\n\ntype UpgradeTakeoverProps = {\n    open: boolean\n    onClose: () => void\n}\n\nexport function UpgradeTakeover({ open, onClose }: UpgradeTakeoverProps) {\n    const scrollContainerRef = useRef<HTMLDivElement>(null)\n\n    const { useProfile, useCreateCheckoutSession } = useUserApi()\n    const profileQuery = useProfile()\n    const user = profileQuery.data\n\n    const createCheckoutSession = useCreateCheckoutSession()\n\n    const [selectedPlan, setSelectedPlan] = useState<'monthly' | 'yearly'>('yearly')\n\n    return (\n        <Takeover open={open} onClose={onClose} scrollContainerRef={scrollContainerRef}>\n            <div className=\"absolute inset-0 z-auto overflow-hidden\">\n                <TakeoverBackground className=\"absolute -translate-x-1/2 -top-3 left-1/2\" />\n                <TakeoverBackground className=\"absolute rotate-180 -translate-x-1/2 -bottom-3 left-1/2\" />\n            </div>\n\n            <div className=\"absolute z-10 top-12 right-12\">\n                <Button variant=\"icon\" title=\"Close\" onClick={onClose}>\n                    <RiCloseFill className=\"w-6 h-6\" />\n                </Button>\n            </div>\n\n            <Transition.Child\n                as={Fragment}\n                enter=\"ease-out duration-300\"\n                enterFrom=\"translate-y-8\"\n                enterTo=\"translate-y-0\"\n                leave=\"ease-in duration-300\"\n                leaveFrom=\"translate-y-0\"\n                leaveTo=\"translate-y-8\"\n            >\n                <div className=\"relative flex flex-col items-center max-w-3xl px-6 pt-16 pb-24 mx-auto\">\n                    <PremiumIcon size=\"xl\" />\n\n                    <h3 className=\"max-w-sm mt-10 text-center\">\n                        Take control of your financial future\n                    </h3>\n\n                    <div\n                        className=\"relative mt-6 w-full p-[1px] rounded-2xl\"\n                        style={{\n                            backgroundImage: `\n                                linear-gradient(85deg, #1C1C20EE 5%, transparent, #1C1C20EE 95%),\n                                linear-gradient(180deg, #4CC9F0 28.24%, #4361EE 46.15%, #7209B7 61.01%, #F72585 80.62%)\n                            `,\n                        }}\n                    >\n                        <div\n                            className=\"flex flex-col items-center p-8 text-base text-center bg-gray-800 rounded-2xl text-gray-50\"\n                            style={{\n                                backgroundImage: `\n                                    radial-gradient(107% 89% at 23% -42%, #4CC9F040, transparent 100%),\n                                    radial-gradient(87% 71% at 77% 139%, #F7258530, transparent 100%)\n                                `,\n                            }}\n                        >\n                            <p>Choose annual or monthly billing to start</p>\n\n                            <PlanSelector\n                                selected={selectedPlan}\n                                onChange={setSelectedPlan}\n                                className=\"w-full mt-6\"\n                            />\n\n                            <Button\n                                variant=\"primary\"\n                                className=\"min-w-[50%] mt-6\"\n                                onClick={() =>\n                                    createCheckoutSession\n                                        .mutateAsync(selectedPlan)\n                                        .then(({ url }) => (window.location.href = url))\n                                }\n                                disabled={createCheckoutSession.isLoading}\n                                data-testid=\"upgrade-takeover-upgrade-button\"\n                            >\n                                {createCheckoutSession.isLoading && (\n                                    <LoadingIcon className=\"inline w-3 h-3 mr-2 animate-spin text-gray\" />\n                                )}\n                                Subscribe to Maybe\n                            </Button>\n                            <p className=\"max-w-sm mt-2 text-sm\">\n                                In the next step you&rsquo;ll be asked to enter your payment\n                                details.\n                            </p>\n                        </div>\n                    </div>\n\n                    <div className=\"relative w-full max-w-2xl text-base mt-14\">\n                        <div className=\"relative -z-10\">\n                            <FeaturesGlow className=\"absolute -translate-x-1/2 -top-16 left-1/2\" />\n                            <SideGrid className=\"absolute left-0 -top-16\" />\n                        </div>\n\n                        <h4 className=\"text-center\">What&rsquo;s included</h4>\n\n                        <div className=\"grid grid-cols-2 mt-6 sm:grid-cols-3 gap-y-6 gap-x-2\">\n                            {(\n                                [\n                                    [RiLinksLine, 'Net worth tracking'],\n                                    [RiBankLine, 'Personal finance insights'],\n                                    [RiLineChartLine, 'Investment planning & insights'],\n                                    [RiFlagLine, 'Retirement planning & milestone simulation'],\n                                    [RiWechatLine, 'Priority customer support'],\n                                ] as [IconType, string][]\n                            ).map(([Icon, description]) => (\n                                <div key={description} className=\"flex flex-col items-center\">\n                                    <div\n                                        className=\"p-[1px] rounded-2xl bg-black\"\n                                        style={{\n                                            backgroundImage: `\n                                                radial-gradient(107% 89% at 23% -42%, #4CC9F080, transparent 100%),\n                                                radial-gradient(87% 73% at 84% 139%, #F7258580, transparent 100%)\n                                            `,\n                                        }}\n                                    >\n                                        <div className=\"p-3 bg-black bg-opacity-50 rounded-2xl\">\n                                            <Icon className=\"w-6 h-6 text-white\" />\n                                        </div>\n                                    </div>\n                                    <p className=\"mt-3 text-center text-white\">{description}</p>\n                                </div>\n                            ))}\n                        </div>\n                    </div>\n\n                    <div className=\"w-full max-w-2xl p-6 text-base bg-gray-700 border border-gray-700 mt-14 rounded-2xl bg-opacity-10 backdrop-blur-lg\">\n                        <p className=\"text-sm text-gray-100\">A message from the Maybe team</p>\n                        <div className=\"flex flex-wrap mt-6 space-y-6 italic leading-5 text-white sm:flex-nowrap sm:space-y-0 sm:space-x-7\">\n                            <div className=\"w-full sm:w-1/2\">\n                                <p>\n                                    Hey {user?.firstName},\n                                    <br />\n                                    <br />\n                                    Thanks for considering Maybe.\n                                    <br />\n                                    <br />\n                                    If the financial world is known for anything, it&rsquo;s for\n                                    being opaque.\n                                    <br />\n                                    <br />\n                                    Set it and forget it strategies, % of your assets &amp; returns,\n                                    management fees, cold calls and upsells for products you never\n                                    needed.\n                                    <br />\n                                    <br />\n                                    With this subscription you're paying for control of your\n                                    finances.\n                                </p>\n                            </div>\n                            <div className=\"w-full sm:w-1/2\">\n                                <p>\n                                    You deserve to know what your money is doing, why it's doing it\n                                    and how to change it. You deserve to be equipped with the tools\n                                    to live the life you want now and not decades down the road.\n                                    <br />\n                                    <br />\n                                    This is our goal with Maybe. We make the tools, you make the\n                                    rules.\n                                    <br />\n                                    <br />\n                                    Thanks again,\n                                    <br />\n                                    Josh, Travis\n                                </p>\n                                <div className=\"flex mt-5 -space-x-2 shrink-0\">\n                                    {['josh', 'travis'].map((name) => (\n                                        <img\n                                            key={name}\n                                            alt={upperFirst(name)}\n                                            className=\"w-12 h-12 border-2 border-black rounded-full\"\n                                            src={`/assets/images/team/${name}.jpg`}\n                                        />\n                                    ))}\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n\n                    <div className=\"mt-8\">\n                        <Button\n                            variant=\"icon\"\n                            className=\"p-2 text-gray-50 hover:text-white\"\n                            onClick={() =>\n                                scrollContainerRef?.current?.scrollTo({\n                                    top: 0,\n                                    behavior: 'smooth',\n                                })\n                            }\n                        >\n                            <RiArrowUpLine className=\"w-6 h-6 shrink-0\" />\n                        </Button>\n                    </div>\n                </div>\n            </Transition.Child>\n        </Takeover>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/user-billing/graphics/FeaturesGlow.tsx",
    "content": "export function FeaturesGlow({ className }: { className: string }) {\n    return (\n        <svg\n            className={className}\n            width=\"891\"\n            height=\"596\"\n            viewBox=\"0 0 891 596\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n        >\n            <g filter=\"url(#filter0_f_13_52)\">\n                <path\n                    d=\"M165.1 430.73C170.774 396.301 179.982 338.992 186.72 302.955C189.379 288.734 193.035 269.351 198.006 254.786C200.53 247.389 204.783 240.082 208.103 233.062C212.921 222.876 217.712 212.861 223.903 203.648C231.715 192.021 242.241 182.802 252.295 173.829C253.9 172.396 259.697 165.493 262.154 165.329C262.692 165.293 262.459 166.503 262.63 167.083C263.417 169.767 264.346 171.833 265.837 174.234C279.932 196.924 298.133 217.264 314.305 237.92C342.268 273.634 370.343 309.238 399.124 344.107C409.293 356.427 419.826 368.38 429.892 380.807C431.833 383.203 434.333 386.003 435.356 389.173C436.359 392.277 436.437 394.867 438.802 397.403C439.07 397.691 500.463 344.849 502.772 342.893C541.276 310.285 579.947 277.034 614.973 239.606C629.613 223.963 648.146 206.899 658.63 187.052C661.781 181.089 663.323 159.926 663.323 166.881C663.323 182.383 672.391 200.197 676.925 214.24C689.498 253.187 703.369 291.569 715.948 330.48C718.855 339.47 723.756 350.228 724.739 359.961C726.285 375.261 725.333 391.047 725.333 406.444\"\n                    stroke=\"url(#paint0_linear_13_52)\"\n                    strokeWidth=\"9\"\n                    strokeLinecap=\"round\"\n                />\n            </g>\n            <defs>\n                <filter\n                    id=\"filter0_f_13_52\"\n                    x=\"0.598907\"\n                    y=\"0.828247\"\n                    width=\"889.523\"\n                    height=\"594.403\"\n                    filterUnits=\"userSpaceOnUse\"\n                    colorInterpolationFilters=\"sRGB\"\n                >\n                    <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                    <feBlend\n                        mode=\"normal\"\n                        in=\"SourceGraphic\"\n                        in2=\"BackgroundImageFix\"\n                        result=\"shape\"\n                    />\n                    <feGaussianBlur stdDeviation=\"80\" result=\"effect1_foregroundBlur_13_52\" />\n                </filter>\n                <linearGradient\n                    id=\"paint0_linear_13_52\"\n                    x1=\"197.68\"\n                    y1=\"189.382\"\n                    x2=\"698.743\"\n                    y2=\"270.98\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#4CC9F0\" />\n                    <stop offset=\"0.28684\" stopColor=\"#4361EE\" />\n                    <stop offset=\"0.679646\" stopColor=\"#7209B7\" />\n                    <stop offset=\"0.83892\" stopColor=\"#F72585\" />\n                </linearGradient>\n            </defs>\n        </svg>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/user-billing/graphics/SideGrid.tsx",
    "content": "export function SideGrid({ className }: { className: string }) {\n    return (\n        <svg\n            className={className}\n            width=\"1110\"\n            height=\"370\"\n            viewBox=\"0 0 1110 370\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n        >\n            <path\n                d=\"M758.666 368.73L1223.77 184.998M1223.77 184.998L603.631 368.73M1223.77 184.998L758.666 1.26565M1223.77 184.998L603.631 1.26562M716.707 368.602L1223.77 184.996M666.215 368.73L1223.77 184.998M1223.77 184.998L525.403 368.73M1223.77 184.998L292.139 368.73M1223.77 184.998L666.215 1.26565M1223.77 184.998L525.403 1.26563M1223.77 184.998L292.139 1.26565M425.84 368.73L1223.78 184.998M1223.78 184.998L0.558602 264.141M1223.78 184.998L425.84 1.26564M1223.78 184.998L0.558602 105.855M104.387 368.73L1223.77 184.998L104.387 1.26563M0.558602 345.316L1223.77 184.996L0.558602 146.316M0.558602 304.605L1223.77 185.001M0.558602 223.68L1223.77 184.999L0.558602 24.6797M0.558594 183.473L1223.77 185M716.707 1.39455L1223.77 185M0.558602 65.3906L1223.77 184.995M149.906 368.73L149.906 2.28361M267.961 368.73V2.28361M364.678 368.73V2.28361M441.486 368.73V2.28361M506.914 368.73V2.28361M560.965 368.73V2.28361M607.898 368.73V2.28361M649.15 368.73V2.28361M686.127 368.73V2.28361M717.421 368.73V2.28361M744.444 368.73V2.28361M770.045 368.73V2.28361\"\n                stroke=\"url(#paint0_radial_856_44530)\"\n            />\n            <defs>\n                <radialGradient\n                    id=\"paint0_radial_856_44530\"\n                    cx=\"0\"\n                    cy=\"0\"\n                    r=\"1\"\n                    gradientUnits=\"userSpaceOnUse\"\n                    gradientTransform=\"translate(284.719 176.724) rotate(5.66477) scale(447.911 127.727)\"\n                >\n                    <stop stopColor=\"#232428\" />\n                    <stop offset=\"1\" stopColor=\"#1C1C20\" stopOpacity=\"0\" />\n                </radialGradient>\n            </defs>\n        </svg>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/user-billing/graphics/index.ts",
    "content": "export * from './FeaturesGlow'\nexport * from './SideGrid'\n"
  },
  {
    "path": "libs/client/features/src/user-billing/index.ts",
    "content": "export * from './BillingPreferences'\nexport * from './PlanSelector'\nexport * from './PremiumIcon'\nexport * from './UpgradePrompt'\nexport * from './UpgradeTakeover'\nexport * from './SubscriberGuard'\n"
  },
  {
    "path": "libs/client/features/src/user-details/DeleteUserButton.tsx",
    "content": "import { Alert, Button } from '@maybe-finance/design-system'\nimport { useState } from 'react'\nimport { useUserApi } from '@maybe-finance/client/shared'\nimport { DeleteUserModal } from './DeleteUserModal'\n\nexport function DeleteUserButton({ onDelete }: { onDelete: () => void }) {\n    const { useDelete } = useUserApi()\n\n    const deleteUser = useDelete({\n        onSuccess() {\n            if (onDelete) onDelete()\n        },\n        onError(err) {\n            console.error('Failed to delete user', err)\n        },\n    })\n\n    const [isOpen, setIsOpen] = useState(false)\n\n    return (\n        <>\n            <Button\n                variant=\"danger\"\n                onClick={() => setIsOpen(true)}\n                disabled={deleteUser.isLoading}\n            >\n                {deleteUser.isLoading\n                    ? 'Deleting Maybe Account...'\n                    : deleteUser.isSuccess\n                    ? 'Account Deleted'\n                    : 'Delete Maybe Account'}\n            </Button>\n            <Alert isVisible={deleteUser.isError} variant=\"error\" className=\"mt-2\">\n                We ran into issues deleting your account. Please contact us for assistance.\n            </Alert>\n            <DeleteUserModal\n                isOpen={isOpen}\n                onClose={() => setIsOpen(false)}\n                onConfirm={() => {\n                    setIsOpen(false)\n                    deleteUser.mutate({})\n                }}\n            />\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/user-details/DeleteUserModal.tsx",
    "content": "import { useEffect, useRef, useState } from 'react'\nimport { Alert, Button, Dialog } from '@maybe-finance/design-system'\n\nconst COUNT_DOWN_SECONDS = 3\n\nexport interface DeleteUserModalProps {\n    isOpen: boolean\n    onClose: () => void\n    onConfirm: () => void\n}\n\nconst Emphasized = ({ children }: { children: React.ReactNode }) => (\n    <span className=\"text-white\">{children}</span>\n)\n\nexport function DeleteUserModal({ isOpen, onClose, onConfirm }: DeleteUserModalProps) {\n    const cancelButton = useRef<HTMLButtonElement>(null)\n    const [countDown, setCountDown] = useState(COUNT_DOWN_SECONDS)\n    const countDownIntervalId = useRef<number | null>(null)\n\n    // Reset count down when modal is opened\n    useEffect(() => {\n        if (isOpen === true) {\n            setCountDown(COUNT_DOWN_SECONDS)\n            if (countDownIntervalId.current != null)\n                window.clearInterval(countDownIntervalId.current)\n            countDownIntervalId.current = window.setInterval(\n                () => setCountDown((countDown) => countDown - 1),\n                1000\n            )\n        }\n    }, [isOpen])\n\n    return (\n        <Dialog isOpen={isOpen} onClose={onClose} initialFocus={cancelButton}>\n            <Dialog.Title>Delete Maybe Account</Dialog.Title>\n            <Dialog.Content>\n                <Alert isVisible={true} variant=\"error\">\n                    This action cannot be undone\n                </Alert>\n                <p className=\"mt-4 text-base text-gray-50\">\n                    Are you sure you want to delete your Maybe account? All{' '}\n                    <Emphasized>accounts</Emphasized>, <Emphasized>balances</Emphasized>, and{' '}\n                    <Emphasized>other data</Emphasized> will be deleted{' '}\n                    <Emphasized>permanently</Emphasized>.\n                </p>\n            </Dialog.Content>\n            <Dialog.Actions>\n                <Button ref={cancelButton} fullWidth variant=\"secondary\" onClick={onClose}>\n                    Cancel\n                </Button>\n                <Button fullWidth variant=\"danger\" disabled={countDown > 0} onClick={onConfirm}>\n                    Delete Account\n                    {countDown > 0 && <span className=\"tabular-nums\">&nbsp;({countDown} s)</span>}\n                </Button>\n            </Dialog.Actions>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/user-details/UserDetails.tsx",
    "content": "import { useState } from 'react'\nimport { Controller, useForm } from 'react-hook-form'\nimport { signOut } from 'next-auth/react'\nimport classNames from 'classnames'\nimport { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'\nimport {\n    RiAnticlockwise2Line,\n    RiArrowGoBackFill,\n    RiDownloadLine,\n    RiShareForwardLine,\n} from 'react-icons/ri'\nimport {\n    Button,\n    DatePicker,\n    FractionalCircle,\n    Input,\n    LoadingPlaceholder,\n    Tooltip,\n} from '@maybe-finance/design-system'\nimport {\n    BrowserUtil,\n    MaybeCard,\n    MaybeCardShareModal,\n    useUserApi,\n} from '@maybe-finance/client/shared'\nimport { DateUtil, UserUtil } from '@maybe-finance/shared'\nimport { DeleteUserButton } from './DeleteUserButton'\nimport { DateTime } from 'luxon'\n\nexport function UserDetails() {\n    const { useProfile, useUpdateProfile } = useUserApi()\n\n    const updateProfileQuery = useUpdateProfile()\n\n    const profileQuery = useProfile()\n    const profile = profileQuery.data\n\n    if (profileQuery.error) {\n        return <p className=\"text-gray-50\">Something went wrong loading your profile...</p>\n    }\n\n    return (\n        <div className=\"max-w-lg mt-6 space-y-10\">\n            <section>\n                <h4 className=\"text-lg uppercase\">Profile</h4>\n                <LoadingPlaceholder isLoading={profileQuery.isLoading}>\n                    {profile && (\n                        <ProfileForm\n                            defaultValues={{\n                                firstName: profile.firstName ?? '',\n                                lastName: profile.lastName ?? '',\n                                dob: DateUtil.dateTransform(profile.dob!),\n                            }}\n                            onSubmit={(data) => {\n                                updateProfileQuery.mutate(data)\n                            }}\n                            isSubmitting={updateProfileQuery.isLoading}\n                        />\n                    )}\n                </LoadingPlaceholder>\n            </section>\n\n            <section>\n                <h4 className=\"text-lg uppercase\">Account</h4>\n                <LoadingPlaceholder isLoading={profileQuery.isLoading}>\n                    <div className=\"text-base\">\n                        <p className=\"mb-2 text-gray-50\">Email address</p>\n                        <form>\n                            <Input\n                                readOnly\n                                disabled\n                                value={profileQuery.data?.email ?? 'Loading...'}\n                                type=\"text\"\n                            />\n                        </form>\n                    </div>\n                </LoadingPlaceholder>\n            </section>\n\n            <section>\n                <MaybeCardSection />\n            </section>\n\n            <section>\n                <h4 className=\"text-lg uppercase\">Danger Zone</h4>\n                <p className=\"mb-4 text-base text-gray-100\">\n                    Deleting your account is a permanent action. If you delete your account, you\n                    will no longer be able to sign and all data will be deleted.\n                </p>\n                <DeleteUserButton onDelete={() => signOut()} />\n            </section>\n        </div>\n    )\n}\n\ntype ProfileFields = {\n    firstName: string\n    lastName: string\n    dob: string\n}\n\ntype ProfileFormProps = {\n    defaultValues: ProfileFields\n    onSubmit(data: ProfileFields): void\n    isSubmitting: boolean\n}\n\nfunction ProfileForm({ defaultValues, onSubmit, isSubmitting }: ProfileFormProps) {\n    const { register, control, handleSubmit, formState } = useForm<ProfileFields>({\n        mode: 'onChange',\n        defaultValues,\n    })\n\n    return (\n        <form onSubmit={handleSubmit(onSubmit)}>\n            <Input type=\"text\" label=\"First name\" {...register('firstName')} />\n            <Input className=\"mt-2\" type=\"text\" label=\"Last name\" {...register('lastName')} />\n            <Controller\n                control={control}\n                name=\"dob\"\n                rules={{\n                    validate: (d) =>\n                        BrowserUtil.validateFormDate(d, {\n                            minDate: DateTime.now().minus({ years: 100 }).toISODate(),\n                            required: true,\n                        }),\n                }}\n                render={({ field, fieldState: { error } }) => {\n                    return (\n                        <DatePicker\n                            label=\"Date of birth\"\n                            popperPlacement=\"bottom\"\n                            className=\"mt-2\"\n                            error={error?.message}\n                            {...field}\n                        />\n                    )\n                }}\n            />\n            <Button className=\"mt-4\" type=\"submit\" disabled={!formState.isValid}>\n                {isSubmitting && (\n                    <LoadingIcon className=\"w-3 h-3 animate-spin text-gray inline mr-2 mb-0.5\" />\n                )}\n                {isSubmitting ? 'Saving...' : 'Save changes'}\n            </Button>\n        </form>\n    )\n}\n\nfunction MaybeCardSection() {\n    const { useMemberCardDetails, useUpdateProfile } = useUserApi()\n\n    const {\n        register,\n        handleSubmit,\n        formState: { isSubmitting, isValid },\n        watch,\n        setValue,\n    } = useForm<{\n        title?: string\n        maybe?: string\n    }>({\n        mode: 'onChange',\n    })\n\n    const title = watch('title')\n    const maybe = watch('maybe')\n\n    const updateProfile = useUpdateProfile({ onSuccess: undefined })\n\n    const { data, isLoading } = useMemberCardDetails(undefined, {\n        onSuccess: (data) => {\n            if (!title)\n                setValue('title', data.title ?? UserUtil.randomUserTitle(), {\n                    shouldValidate: true,\n                })\n            if (!maybe)\n                setValue('maybe', data.maybe ?? '', {\n                    shouldValidate: true,\n                })\n        },\n    })\n\n    const [isCardFlipped, setIsCardFlipped] = useState(false)\n    const [isShareModalOpen, setIsShareModalOpen] = useState(false)\n\n    return (\n        <form\n            className=\"mt-5 text-base\"\n            onSubmit={handleSubmit((data) => updateProfile.mutate(data))}\n        >\n            <p className=\"mb-2 text-gray-50\">Maybe Card</p>\n            <div className=\"overflow-hidden rounded-lg\">\n                <MaybeCard variant=\"settings\" flipped={isCardFlipped} details={data} />\n                <MaybeCardShareModal\n                    isOpen={isShareModalOpen}\n                    onClose={() => setIsShareModalOpen(false)}\n                    cardUrl={data?.cardUrl ?? ''}\n                    card={{ details: data }}\n                />\n            </div>\n            <div className=\"flex justify-center w-full gap-3 mt-6\">\n                <Tooltip content=\"Share\" placement=\"bottom\">\n                    <div className=\"w-full\">\n                        <Button\n                            fullWidth\n                            type=\"button\"\n                            variant=\"secondary\"\n                            disabled={!data}\n                            onClick={() => {\n                                // Make sure title is persisted for sharing\n                                updateProfile.mutateAsync({ title })\n\n                                setIsShareModalOpen(true)\n                            }}\n                        >\n                            <RiShareForwardLine className=\"w-5 h-5 text-gray-50\" />\n                        </Button>\n                    </div>\n                </Tooltip>\n                <Tooltip content=\"Download\" placement=\"bottom\">\n                    <div className=\"w-full\">\n                        <Button\n                            as=\"a\"\n                            fullWidth\n                            variant=\"secondary\"\n                            className={classNames(!data && 'opacity-50 pointer-events-none')}\n                            href={data?.imageUrl}\n                            download=\"/assets/maybe-card.png\"\n                        >\n                            <RiDownloadLine className=\"w-5 h-5 text-gray-50\" />\n                        </Button>\n                    </div>\n                </Tooltip>\n                <Tooltip content=\"Randomize title\" placement=\"bottom\">\n                    <div className=\"w-full\">\n                        <Button\n                            fullWidth\n                            type=\"button\"\n                            variant=\"secondary\"\n                            onClick={() =>\n                                setValue('title', UserUtil.randomUserTitle(title), {\n                                    shouldValidate: true,\n                                })\n                            }\n                        >\n                            <RiArrowGoBackFill className=\"w-5 h-5 text-gray-50\" />\n                        </Button>\n                    </div>\n                </Tooltip>\n                <Tooltip content=\"Flip card\" placement=\"bottom\">\n                    <div className=\"w-full\">\n                        <Button\n                            fullWidth\n                            type=\"button\"\n                            variant=\"secondary\"\n                            onClick={() => setIsCardFlipped((flipped) => !flipped)}\n                        >\n                            <RiAnticlockwise2Line className=\"w-5 h-5 text-gray-50\" />\n                        </Button>\n                    </div>\n                </Tooltip>\n            </div>\n\n            <div className=\"relative mt-6\">\n                <label>\n                    <span className=\"block mb-1 text-base font-light leading-6 text-gray-50\">\n                        Your Maybe\n                    </span>\n                    <textarea\n                        rows={5}\n                        className=\"block w-full text-base bg-gray-500 border-0 rounded resize-none placeholder:text-gray-100 focus:ring-0\"\n                        placeholder=\"What's your Maybe?\"\n                        {...register('maybe', { required: true })}\n                        onKeyDown={(e) => e.key === 'Enter' && e.stopPropagation()}\n                        maxLength={UserUtil.MAX_MAYBE_LENGTH}\n                        disabled={isLoading}\n                    />\n                    <div className=\"absolute bottom-0 right-0 flex items-center gap-1 px-3 py-2\">\n                        <FractionalCircle\n                            radius={6}\n                            percent={((maybe?.length ?? 0) / UserUtil.MAX_MAYBE_LENGTH) * 100}\n                        />\n                        <span className=\"text-sm text-gray-50\">{240 - (maybe?.length ?? 0)}</span>\n                    </div>\n                </label>\n            </div>\n            <Button\n                type=\"submit\"\n                variant=\"secondary\"\n                className=\"mt-6\"\n                disabled={!isValid || isSubmitting}\n            >\n                {updateProfile.isLoading && (\n                    <LoadingIcon className=\"w-3 h-3 animate-spin text-gray inline mr-2 mb-0.5\" />\n                )}\n                {updateProfile.isLoading ? 'Saving...' : 'Save changes'}\n            </Button>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/user-details/UserDevTools.tsx",
    "content": "import { Checkbox } from '@maybe-finance/design-system'\nimport { DateUtil, type SharedType } from '@maybe-finance/shared'\n\ntype UserDevToolsProps = {\n    isAdmin: boolean\n    setIsAdmin: (isAdmin: boolean) => void\n    isOnboarded: boolean\n    setIsOnboarded: (isOnboarded: boolean) => void\n}\n\nexport function UserDevTools({\n    isAdmin,\n    setIsAdmin,\n    isOnboarded,\n    setIsOnboarded,\n}: UserDevToolsProps) {\n    return process.env.NODE_ENV === 'development' ? (\n        <div className=\"my-2 p-2 border border-red-300 rounded-md\">\n            <h6 className=\"flex text-red\">\n                Dev Tools <i className=\"ri-tools-fill ml-1.5\" />\n            </h6>\n            <p className=\"text-sm my-2\">\n                This section will NOT show in production and is solely for making testing easier.\n            </p>\n\n            <div className=\"flex flex-col\">\n                <Checkbox checked={isAdmin} onChange={setIsAdmin} label=\"Admin user\" />\n                <Checkbox checked={isOnboarded} onChange={setIsOnboarded} label=\"Onboarded user\" />\n            </div>\n        </div>\n    ) : null\n}\n\ninterface OnboardingType {\n    flow: SharedType.OnboardingFlow\n    updates: { key: string; markedComplete: boolean }[]\n    markedComplete?: boolean\n}\n\nexport const completedOnboarding: OnboardingType = {\n    flow: 'main',\n    updates: [\n        {\n            key: 'intro',\n            markedComplete: true,\n        },\n        {\n            key: 'profile',\n            markedComplete: true,\n        },\n        {\n            key: 'firstAccount',\n            markedComplete: true,\n        },\n        {\n            key: 'accountSelection',\n            markedComplete: true,\n        },\n        {\n            key: 'maybe',\n            markedComplete: true,\n        },\n        {\n            key: 'welcome',\n            markedComplete: true,\n        },\n    ],\n}\n\nexport const onboardedProfile: SharedType.UpdateUser = {\n    dob: DateUtil.dateTransform(new Date('2000-01-01')),\n    household: 'single',\n    country: 'US',\n}\n"
  },
  {
    "path": "libs/client/features/src/user-details/index.ts",
    "content": "export * from './UserDetails'\nexport * from './UserDevTools'\n"
  },
  {
    "path": "libs/client/features/src/user-security/PasswordReset.tsx",
    "content": "import { useUserApi } from '@maybe-finance/client/shared'\nimport { Button, InputPassword } from '@maybe-finance/design-system'\nimport { AiOutlineLoading3Quarters as LoadingIcon } from 'react-icons/ai'\nimport { useState } from 'react'\n\nexport function PasswordReset() {\n    const [currentPassword, setCurrentPassword] = useState('')\n    const [newPassword, setNewPassword] = useState('')\n    const [isValid, setIsValid] = useState(false)\n\n    const { useChangePassword } = useUserApi()\n\n    const changePassword = useChangePassword()\n    const onSubmit = async (event: any) => {\n        event.preventDefault()\n\n        setCurrentPassword('')\n        setNewPassword('')\n\n        changePassword.mutate({ currentPassword, newPassword })\n    }\n\n    return (\n        <form className=\"space-y-4\" onSubmit={onSubmit}>\n            <InputPassword\n                autoComplete=\"current-password\"\n                label=\"Current Password\"\n                value={currentPassword}\n                showComplexityBar={false}\n                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n                    setCurrentPassword(e.target.value)\n                }\n            />\n\n            <InputPassword\n                autoComplete=\"new-password\"\n                label=\"New password\"\n                value={newPassword}\n                showPasswordRequirements={!isValid}\n                onValidityChange={(checks) => {\n                    const passwordValid = checks.filter((c) => !c.isValid).length === 0\n                    setIsValid(passwordValid)\n                }}\n                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n                    setNewPassword(e.target.value)\n                }\n            />\n\n            <Button\n                type=\"submit\"\n                disabled={!isValid || changePassword.isLoading}\n                variant={isValid || changePassword.isLoading ? 'primary' : 'secondary'}\n            >\n                {changePassword.isLoading ? (\n                    <LoadingIcon className=\"w-3 h-3 animate-spin text-black inline mr-2 mb-0.5\" />\n                ) : null}\n                {changePassword.isLoading ? 'Saving...' : 'Save changes'}\n            </Button>\n        </form>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/user-security/SecurityPreferences.tsx",
    "content": "import { useUserApi } from '@maybe-finance/client/shared'\nimport { Button, LoadingSpinner } from '@maybe-finance/design-system'\nimport { PasswordReset } from './PasswordReset'\n\nexport function SecurityPreferences() {\n    return (\n        <>\n            <h4 className=\"mb-2 text-lg uppercase\">Password</h4>\n            <PasswordReset />\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/user-security/index.ts",
    "content": "export * from './SecurityPreferences'\n"
  },
  {
    "path": "libs/client/features/src/valuations-list/PerformanceMetric.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { TrendBadge } from '@maybe-finance/client/shared'\nimport { NumberUtil } from '@maybe-finance/shared'\n\nexport function PerformanceMetric({\n    trend,\n    isInitial = false,\n    negative = false,\n}: {\n    trend: SharedType.Trend\n    isInitial?: boolean\n    negative?: boolean\n}) {\n    if (trend.direction === 'flat') {\n        return (\n            <div className=\"text-gray-100 text-base font-normal\">\n                {isInitial ? '--' : 'No change'}\n            </div>\n        )\n    }\n\n    return (\n        <div className=\"flex flex-col items-end justify-end\">\n            <p className=\"text-base font-medium mb-1\">\n                {NumberUtil.format(trend.amount, 'currency')}\n            </p>\n            <TrendBadge trend={trend} negative={negative} badgeSize=\"sm\" />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/valuations-list/ValuationList.tsx",
    "content": "import type { ValuationRowData } from './types'\nimport type { SharedType } from '@maybe-finance/shared'\n\nimport { useValuationApi, useUserAccountContext } from '@maybe-finance/client/shared'\nimport { Button } from '@maybe-finance/design-system'\nimport Decimal from 'decimal.js'\nimport sortBy from 'lodash/sortBy'\nimport { DateTime } from 'luxon'\nimport { useMemo, useState } from 'react'\nimport { ValuationsTable } from './ValuationsTable'\n\ninterface ValuationListProps {\n    accountId: number\n    negative?: boolean\n}\n\nexport function ValuationList({ accountId, negative = false }: ValuationListProps) {\n    const { isReady, accountSyncing } = useUserAccountContext()\n\n    const { useAccountValuations } = useValuationApi()\n\n    const [rowEditingIndex, setRowEditingIndex] = useState<number | undefined>(undefined)\n    const [isAdding, setIsAdding] = useState(false)\n\n    const accountValuationsQuery = useAccountValuations(\n        { id: accountId },\n        { enabled: !!accountId && isReady }\n    )\n\n    const data = useMemo<ValuationRowData[]>(() => {\n        if (!accountValuationsQuery.isSuccess) return []\n\n        const mapValuation = (\n            valuation: SharedType.AccountValuationsResponse['valuations'][0],\n            isFirst: boolean\n        ): ValuationRowData => ({\n            accountId: valuation.accountId,\n            valuationId: valuation.id,\n            date: DateTime.fromJSDate(valuation.date, { zone: 'utc' }),\n            type: isFirst ? 'initial' : 'manual',\n            amount: valuation.amount,\n            period: valuation.trend\n                ? valuation.trend.period\n                : { direction: 'flat', amount: new Decimal(0), percentage: new Decimal(0) },\n            total: valuation.trend\n                ? valuation.trend.total\n                : { direction: 'flat', amount: new Decimal(0), percentage: new Decimal(0) },\n        })\n\n        // If there is only 1 valuation, no trends should be shown\n        if (accountValuationsQuery.data.valuations.length === 1 && !isAdding) {\n            const valuation = accountValuationsQuery.data.valuations[0]\n\n            return [mapValuation(valuation, true)]\n        }\n\n        const normalizedTrends: ValuationRowData[] = accountValuationsQuery.data.trends.map(\n            (trend) => ({\n                date: DateTime.fromISO(trend.date),\n                type: 'trend',\n                amount: trend.amount,\n                period: trend.period,\n                total: trend.total,\n            })\n        )\n\n        const normalizedValuations: ValuationRowData[] = accountValuationsQuery.data.valuations.map(\n            (valuation, index) => mapValuation(valuation, index === 0)\n        )\n\n        // Combines and sorts the trends and valuations by date, descending\n        const combinedValuations = sortBy([...normalizedTrends, ...normalizedValuations], (val) =>\n            val.date.toJSDate()\n        ).reverse()\n\n        // If the user has clicked \"Add entry\", insert a blank entry at the start of the array\n        if (isAdding) {\n            combinedValuations.unshift({\n                accountId,\n                amount: combinedValuations[0].amount, // set input to most recent valuation\n                date: DateTime.now(),\n                type: 'manual',\n                period: {\n                    direction: 'flat',\n                    amount: new Decimal(0),\n                    percentage: new Decimal(0),\n                },\n                total: {\n                    direction: 'flat',\n                    amount: new Decimal(0),\n                    percentage: new Decimal(0),\n                },\n            })\n        }\n\n        return combinedValuations\n    }, [accountValuationsQuery, isAdding, accountId])\n\n    return (\n        <div className=\"px-2 sm:px-0\">\n            <div className=\"flex items-center justify-between\">\n                <h5 className=\"uppercase\">ACTIVITY</h5>\n                <Button\n                    disabled={accountSyncing(accountId)}\n                    variant=\"secondary\"\n                    onClick={() => {\n                        setIsAdding(true)\n                        setRowEditingIndex(0)\n                    }}\n                >\n                    Add entry\n                </Button>\n            </div>\n            {/* \n                To achieve the CSS hover styles where an entire table row is highlighted, and expands outside\n                of the table bounds, a few tweaks have been made:\n\n                1) Table container is 16px * 2 = 32px = 2rem wider than its parent container (calc(100%+2rem))\n                2) Table container is shifted -16px to the left (-translate-x-4), so it breaks out of its parent container 16px on both sides\n                3) Table cells have horizontal padding of 16px to keep cell content aligned with the rest of the page (see <table></table>)\n\n                With these settings, the content should never break out of the viewport\n            */}\n            <div className=\"w-[calc(100%+2rem)] transform -translate-x-4 mt-4 custom-gray-scroll\">\n                <ValuationsTable\n                    data={data}\n                    isAdding={isAdding}\n                    rowEditingIndex={rowEditingIndex}\n                    onEdit={(index) => {\n                        setIsAdding(false)\n                        setRowEditingIndex(index)\n                    }}\n                    negative={negative}\n                />\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/valuations-list/ValuationsDateCell.tsx",
    "content": "import type { ValuationRowData } from './types'\nimport type { IconType } from 'react-icons'\nimport { useMemo } from 'react'\nimport { useValuationApi, useUserAccountContext } from '@maybe-finance/client/shared'\nimport { Button, Tooltip } from '@maybe-finance/design-system'\nimport classNames from 'classnames'\nimport { RiPriceTag3Line, RiKeyboardBoxLine, RiArrowUpLine, RiArrowDownLine } from 'react-icons/ri'\nimport { RiSubtractFill } from 'react-icons/ri'\nimport type { Row } from '@tanstack/react-table'\n\nexport function ValuationsDateCell({\n    row,\n    onEdit,\n}: {\n    row: Row<ValuationRowData>\n    onEdit(rowId?: number): void\n}) {\n    const data = row.original!\n    const { useDeleteValuation } = useValuationApi()\n    const deleteQuery = useDeleteValuation()\n    const { accountSyncing } = useUserAccountContext()\n\n    const { canEdit, canDelete, label, icon } = useMemo(() => {\n        const label =\n            data.type === 'initial'\n                ? 'Manually entered'\n                : data.type === 'trend'\n                ? 'Yearly trend'\n                : 'Manually entered'\n\n        let icon: React.ReactNode\n\n        if (data.type === 'initial') {\n            icon = <ValuationIcon className=\"bg-cyan text-cyan\" Icon={RiPriceTag3Line} />\n        } else if (data.type === 'manual') {\n            icon = <ValuationIcon className=\"bg-pink text-pink\" Icon={RiKeyboardBoxLine} />\n        } else if (data.type === 'trend') {\n            const { direction } = data.period\n            if (direction === 'up') {\n                icon = <ValuationIcon className=\"bg-teal text-teal\" Icon={RiArrowUpLine} />\n            }\n\n            if (direction === 'down') {\n                icon = <ValuationIcon className=\"bg-red text-red\" Icon={RiArrowDownLine} />\n            }\n\n            if (direction === 'flat') {\n                icon = <ValuationIcon className=\"bg-white text-gray-100\" Icon={RiSubtractFill} />\n            }\n        } else {\n            icon = <ValuationIcon className=\"bg-pink text-pink\" Icon={RiKeyboardBoxLine} />\n        }\n\n        return {\n            canEdit: data.type === 'manual',\n            canDelete: data.type === 'manual',\n            label,\n            icon,\n        }\n    }, [data])\n\n    return (\n        <div className=\"group w-full h-full flex text-base gap-4\">\n            {icon}\n            <div>\n                <p>{data.date.toFormat('MMM d yyyy')}</p>\n                <p className=\"text-gray-100 text-base\">{label}</p>\n            </div>\n            <div className=\"hidden group-hover:flex\">\n                {canEdit && !accountSyncing(data.accountId || 0) && (\n                    <Tooltip content=\"Edit\" placement=\"bottom\" offset={[0, 4]}>\n                        <Button\n                            className=\"ml-4 w-8\"\n                            variant=\"icon\"\n                            onClick={() => onEdit(row.index)}\n                        >\n                            <i className=\"ri-pencil-line text-gray-100 w-8\" />\n                        </Button>\n                    </Tooltip>\n                )}\n                {canDelete && !accountSyncing(data.accountId || 0) && (\n                    <Tooltip content=\"Delete\" placement=\"bottom\" offset={[0, 4]}>\n                        <Button\n                            className=\"ml-2 w-8\"\n                            variant=\"icon\"\n                            onClick={() =>\n                                deleteQuery.mutate(\n                                    {\n                                        id: data.valuationId!,\n                                    },\n                                    {\n                                        onSuccess: () => onEdit(),\n                                    }\n                                )\n                            }\n                        >\n                            <i className=\"ri-delete-bin-line text-gray-100 w-8\" />\n                        </Button>\n                    </Tooltip>\n                )}\n            </div>\n        </div>\n    )\n}\n\nfunction ValuationIcon({ className, Icon }: { className: string; Icon: IconType }) {\n    return (\n        <div\n            className={classNames(\n                'flex items-center justify-center rounded-xl bg-opacity-10 w-12 h-12',\n                className\n            )}\n        >\n            <Icon className=\"w-6 h-6\" />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/valuations-list/ValuationsTable.tsx",
    "content": "import type { ColumnDef } from '@tanstack/react-table'\nimport type { ValuationRowData } from './types'\nimport { useMemo } from 'react'\nimport { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { ValuationsTableForm } from './ValuationsTableForm'\nimport { PerformanceMetric } from './PerformanceMetric'\nimport { ValuationsDateCell } from './ValuationsDateCell'\n\ninterface ValuationsTableProps {\n    data: ValuationRowData[]\n    isAdding: boolean\n    rowEditingIndex?: number\n    onEdit(rowIndex?: number): void\n    negative?: boolean\n}\n\nexport function ValuationsTable({ data, rowEditingIndex, onEdit, negative }: ValuationsTableProps) {\n    const columns = useMemo(\n        () => [\n            {\n                id: 'date',\n                header: 'Date',\n                cell: ({ row }) => <ValuationsDateCell row={row} onEdit={onEdit} />,\n            } as ColumnDef<ValuationRowData, ValuationRowData['date']>,\n            {\n                header: 'Value',\n                accessorKey: 'amount',\n                cell: ({ getValue }) => (\n                    <p className=\"text-base font-medium min-w-24\">\n                        {NumberUtil.format(getValue(), 'currency')}\n                    </p>\n                ),\n            } as ColumnDef<ValuationRowData, ValuationRowData['amount']>,\n            {\n                header: 'Change',\n                accessorKey: 'period',\n                cell: ({ row, getValue }) => (\n                    <PerformanceMetric\n                        trend={getValue()}\n                        isInitial={row.original?.type === 'initial'}\n                        negative={negative}\n                    />\n                ),\n            } as ColumnDef<ValuationRowData, ValuationRowData['period']>,\n            {\n                header: 'Return',\n                accessorKey: 'total',\n                cell: ({ row, getValue }) => (\n                    <PerformanceMetric\n                        trend={getValue()}\n                        isInitial={row.original?.type === 'initial'}\n                        negative={negative}\n                    />\n                ),\n            } as ColumnDef<ValuationRowData, ValuationRowData['total']>,\n        ],\n        [onEdit, negative]\n    )\n\n    const table = useReactTable<ValuationRowData>({\n        data,\n        columns,\n        getCoreRowModel: getCoreRowModel(),\n    })\n\n    return (\n        <table\n            className=\"min-w-full text-base grid items-stretch\"\n            style={{\n                gridTemplateColumns: `minmax(300px, 3fr) repeat(2, minmax(200px, 2fr)) minmax(180px, 1fr)`,\n            }}\n        >\n            <thead className=\"contents\">\n                {table.getHeaderGroups().map((headerGroup) => (\n                    <tr key={headerGroup.id} className=\"contents\">\n                        {headerGroup.headers.map((header) => (\n                            <th\n                                key={header.id}\n                                className=\"py-3 first:pl-4 last:pr-4 whitespace-nowrap font-medium text-gray-100 text-right first:text-left\"\n                            >\n                                {!header.isPlaceholder &&\n                                    flexRender(header.column.columnDef.header, header.getContext())}\n                            </th>\n                        ))}\n                    </tr>\n                ))}\n            </thead>\n            <tbody className=\"contents\">\n                {table.getRowModel().rows.map((row) => {\n                    return row.index === rowEditingIndex ? (\n                        <tr key={row.id} className=\"contents\">\n                            <ValuationsTableForm row={row} onEdit={onEdit} />\n                        </tr>\n                    ) : (\n                        <tr key={row.id} className=\"contents group\">\n                            {row.getVisibleCells().map((cell) => (\n                                <td\n                                    key={cell.id}\n                                    className=\"py-4 first:pl-4 last:pr-4 whitespace-nowrap font-normal truncate text-right first:text-left group-hover:bg-gray-700 first:rounded-l-lg last:rounded-r-lg\"\n                                >\n                                    {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                                </td>\n                            ))}\n                        </tr>\n                    )\n                })}\n            </tbody>\n        </table>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/valuations-list/ValuationsTableForm.tsx",
    "content": "import { BrowserUtil, useValuationApi } from '@maybe-finance/client/shared'\nimport { Button, DatePicker, InputCurrency } from '@maybe-finance/design-system'\nimport { PerformanceMetric } from './PerformanceMetric'\nimport classNames from 'classnames'\nimport { Controller, useForm } from 'react-hook-form'\nimport { RiCheckLine, RiCloseFill, RiKeyboardBoxLine } from 'react-icons/ri'\nimport type { Row } from '@tanstack/react-table'\nimport type { ValuationRowData } from './types'\n\ntype FormValues = { date: string; amount: number }\n\nexport function ValuationsTableForm({\n    row,\n    onEdit,\n}: {\n    row: Row<ValuationRowData>\n    onEdit(rowIndex?: number): void\n}) {\n    const data = row.original!\n\n    const initialValues = {\n        amount: data.amount.toNumber(),\n        date: data.date,\n    }\n    const onClose = () => onEdit(undefined)\n\n    const { valuationId, accountId } = data\n\n    const { useUpdateValuation, useCreateValuation } = useValuationApi()\n    const updateQuery = useUpdateValuation()\n    const createQuery = useCreateValuation()\n\n    const { control, handleSubmit } = useForm<FormValues>({\n        mode: 'onChange',\n        defaultValues: { ...initialValues, date: initialValues.date.toISODate() },\n    })\n\n    const onSubmit = ({ date, amount }: FormValues) => {\n        const preparedData = {\n            date,\n            amount,\n        }\n\n        const onSuccess = onClose\n\n        if (valuationId) {\n            updateQuery.mutate({ id: valuationId, data: preparedData }, { onSuccess })\n        } else {\n            createQuery.mutate({ id: accountId!, data: preparedData }, { onSuccess })\n        }\n    }\n\n    return (\n        <>\n            <td className=\"flex items-center justify-start p-0 font-normal py-4 pl-4 rounded-l-lg bg-gray-700\">\n                <div\n                    className={classNames(\n                        'h-12 w-12 flex items-center justify-center shrink-0 mr-4 rounded-xl',\n                        valuationId ? 'bg-pink/10 text-pink' : 'bg-white/10 text-gray-100'\n                    )}\n                >\n                    <RiKeyboardBoxLine className=\"w-6 h-6\" />\n                </div>\n\n                <form onSubmit={handleSubmit(onSubmit)} className=\"contents\">\n                    <Controller\n                        control={control}\n                        name=\"date\"\n                        rules={{ validate: BrowserUtil.validateFormDate }}\n                        render={({ field, fieldState: { error } }) => {\n                            return (\n                                <DatePicker\n                                    popperPlacement=\"top-start\"\n                                    error={error?.message}\n                                    {...field}\n                                />\n                            )\n                        }}\n                    />\n                </form>\n            </td>\n            <td className=\"flex items-center justify-end font-normal py-4 text-right bg-gray-700\">\n                <form onSubmit={handleSubmit(onSubmit)} className=\"contents\">\n                    <Controller\n                        control={control}\n                        name=\"amount\"\n                        rules={{ required: true, validate: (val) => val >= 0 }}\n                        render={({ field, fieldState }) => (\n                            <InputCurrency\n                                {...field}\n                                error={fieldState.error && 'Positive value is required'}\n                            />\n                        )}\n                    />\n                </form>\n            </td>\n            <td className=\"flex items-center justify-between font-normal py-4 bg-gray-700\">\n                <div className=\"ml-4 space-x-2\">\n                    <Button variant=\"icon\" onClick={handleSubmit(onSubmit)}>\n                        <RiCheckLine className=\"w-6 h-6\" />\n                    </Button>\n                    <Button variant=\"icon\" onClick={onClose}>\n                        <RiCloseFill className=\"w-6 h-6\" />\n                    </Button>\n                </div>\n                <div>{valuationId && <PerformanceMetric trend={data.period} />}</div>\n            </td>\n            <td className=\"flex items-center justify-end font-normal py-4 pr-4 text-right rounded-r-lg bg-gray-700\">\n                {valuationId && <PerformanceMetric trend={data.total} />}\n            </td>\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/client/features/src/valuations-list/index.ts",
    "content": "export * from './ValuationList'\n"
  },
  {
    "path": "libs/client/features/src/valuations-list/types.ts",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport type { DateTime } from 'luxon'\n\nexport interface ValuationRowData {\n    date: DateTime\n    type: 'manual' | 'trend' | 'initial'\n    amount: SharedType.Decimal\n    period: SharedType.Trend\n    total: SharedType.Trend\n    valuationId?: number\n    accountId?: number\n}\n"
  },
  {
    "path": "libs/client/features/tsconfig.json",
    "content": "{\n    \"extends\": \"../../../tsconfig.base.json\",\n    \"compilerOptions\": {\n        \"jsx\": \"react-jsx\",\n        \"allowJs\": true,\n        \"esModuleInterop\": true,\n        \"allowSyntheticDefaultImports\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"strict\": true,\n        \"noImplicitReturns\": true,\n        \"noFallthroughCasesInSwitch\": true\n    },\n    \"files\": [],\n    \"include\": [],\n    \"references\": [\n        {\n            \"path\": \"./tsconfig.lib.json\"\n        },\n        {\n            \"path\": \"./tsconfig.spec.json\"\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/client/features/tsconfig.lib.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../../dist/out-tsc\",\n        \"types\": [\"node\"]\n    },\n    \"files\": [\n        \"../../../node_modules/@nrwl/react/typings/cssmodule.d.ts\",\n        \"../../../node_modules/@nrwl/react/typings/image.d.ts\"\n    ],\n    \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"**/*.spec.tsx\", \"**/*.test.tsx\", \"jest.config.ts\"],\n    \"include\": [\"**/*.js\", \"**/*.jsx\", \"**/*.ts\", \"**/*.tsx\"]\n}\n"
  },
  {
    "path": "libs/client/features/tsconfig.spec.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../../dist/out-tsc\",\n        \"module\": \"commonjs\",\n        \"types\": [\"jest\", \"node\"]\n    },\n    \"include\": [\n        \"**/*.spec.ts\",\n        \"**/*.test.ts\",\n        \"**/*.spec.tsx\",\n        \"**/*.test.tsx\",\n        \"**/*.spec.js\",\n        \"**/*.test.js\",\n        \"**/*.spec.jsx\",\n        \"**/*.test.jsx\",\n        \"**/*.d.ts\",\n        \"jest.config.ts\"\n    ]\n}\n"
  },
  {
    "path": "libs/client/shared/.babelrc",
    "content": "{\n    \"presets\": [\n        [\n            \"@nrwl/react/babel\",\n            {\n                \"runtime\": \"automatic\",\n                \"useBuiltIns\": \"usage\"\n            }\n        ]\n    ],\n    \"plugins\": []\n}\n"
  },
  {
    "path": "libs/client/shared/.eslintrc.json",
    "content": "{\n    \"extends\": [\"plugin:@nrwl/nx/react\", \"../../../.eslintrc.json\"],\n    \"ignorePatterns\": [\"!**/*\"],\n    \"overrides\": [\n        {\n            \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.ts\", \"*.tsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/client/shared/README.md",
    "content": "# client-shared\n\nThis library was generated with [Nx](https://nx.dev).\n\n## Running unit tests\n\nRun `nx test client-shared` to execute the unit tests via [Jest](https://jestjs.io).\n"
  },
  {
    "path": "libs/client/shared/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n    displayName: 'client-shared',\n    preset: '../../../jest.preset.js',\n    transform: {\n        '^.+\\\\.[tj]sx?$': ['babel-jest', { presets: ['@nrwl/react/babel'] }],\n    },\n    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n    coverageDirectory: '../../../coverage/libs/client/shared',\n}\n"
  },
  {
    "path": "libs/client/shared/src/api/index.ts",
    "content": "export * from './useAccountApi'\nexport * from './useAccountConnectionApi'\nexport * from './useAuthUserApi'\nexport * from './useInstitutionApi'\nexport * from './useUserApi'\nexport * from './useTellerApi'\nexport * from './useValuationApi'\nexport * from './useTransactionApi'\nexport * from './useHoldingApi'\nexport * from './useSecurityApi'\nexport * from './usePlanApi'\n"
  },
  {
    "path": "libs/client/shared/src/api/useAccountApi.ts",
    "content": "import type { AxiosInstance } from 'axios'\nimport type { Account } from '@prisma/client'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type {\n    UseInfiniteQueryOptions,\n    UseMutationOptions,\n    UseQueryOptions,\n} from '@tanstack/react-query'\n\nimport { useMemo } from 'react'\nimport toast from 'react-hot-toast'\nimport { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useAxiosWithAuth } from '..'\nimport { invalidateAccountQueries } from '../utils'\n\nconst AccountApi = (axios: AxiosInstance) => ({\n    async getAccounts() {\n        const { data } = await axios.get<SharedType.AccountsResponse>('/accounts')\n        return data\n    },\n\n    async get(id: SharedType.Account['id']): Promise<SharedType.AccountDetail> {\n        const { data } = await axios.get<SharedType.AccountDetail>(`/accounts/${id}`)\n        return data\n    },\n\n    async create(input: Record<string, any>) {\n        const { data } = await axios.post<SharedType.Account>('/accounts', input)\n        return data\n    },\n\n    async update(id: SharedType.Account['id'], input: Record<string, any>) {\n        const { data } = await axios.put<SharedType.Account>(`/accounts/${id}`, input)\n        return data\n    },\n\n    async delete(id: SharedType.Account['id']) {\n        const { data } = await axios.delete<SharedType.Account>(`/accounts/${id}`)\n        return data\n    },\n\n    async getBalances(\n        id: SharedType.Account['id'],\n        start: string,\n        end: string\n    ): Promise<SharedType.AccountBalanceResponse> {\n        const { data } = await axios.get<SharedType.AccountBalanceResponse>(\n            `/accounts/${id}/balances`,\n            { params: { start, end } }\n        )\n        return data\n    },\n\n    async getReturns(\n        id: SharedType.Account['id'],\n        start: string,\n        end: string,\n        compare: string[]\n    ): Promise<SharedType.AccountReturnResponse> {\n        const { data } = await axios.get<SharedType.AccountReturnResponse>(\n            `/accounts/${id}/returns`,\n            { params: { start, end, compare: compare.length ? compare.join(',') : undefined } }\n        )\n        return data\n    },\n\n    async getTransactions(\n        id: SharedType.Account['id'],\n        page: number\n    ): Promise<SharedType.AccountTransactionResponse> {\n        const { data } = await axios.get<SharedType.AccountTransactionResponse>(\n            `/accounts/${id}/transactions`,\n            { params: { page } }\n        )\n        return data\n    },\n\n    async getHoldings(\n        id: SharedType.Account['id'],\n        page: number\n    ): Promise<SharedType.AccountHoldingResponse> {\n        const { data } = await axios.get<SharedType.AccountHoldingResponse>(\n            `/accounts/${id}/holdings`,\n            { params: { page } }\n        )\n        return data\n    },\n\n    async getInvestmentTransactions(\n        id: SharedType.Account['id'],\n        page: number,\n        start?: string,\n        end?: string,\n        category?: SharedType.InvestmentTransactionCategory\n    ): Promise<SharedType.AccountInvestmentTransactionResponse> {\n        const { data } = await axios.get<SharedType.AccountInvestmentTransactionResponse>(\n            `/accounts/${id}/investment-transactions`,\n            { params: { page, start, end, category } }\n        )\n        return data\n    },\n\n    async getInsights(id: SharedType.Account['id']): Promise<SharedType.AccountInsights> {\n        const { data } = await axios.get<SharedType.AccountInsights>(`/accounts/${id}/insights`)\n        return data\n    },\n\n    async getRollup(start: string, end: string): Promise<SharedType.AccountRollup> {\n        const { data } = await axios.get<SharedType.AccountRollup>(`/account-rollup`, {\n            params: { start, end },\n        })\n        return data\n    },\n\n    async sync(id: SharedType.Account['id']) {\n        const { data } = await axios.post<SharedType.Account>(`/accounts/${id}/sync`)\n        return data\n    },\n})\n\nconst staleTimes = {\n    accounts: 30_000,\n    balances: 30_000,\n    rollup: 30_000,\n    insights: 30_000,\n    returns: 60_000,\n    // Transactions and holdings shouldn't update nearly as often (limited user-driven changes)\n    transactions: 60_000,\n    holdings: 60_000,\n    investmentTransactions: 60_000,\n}\n\nexport function useAccountApi() {\n    const queryClient = useQueryClient()\n    const { axios } = useAxiosWithAuth()\n    const api = useMemo(() => AccountApi(axios), [axios])\n\n    const useAccounts = (\n        options?: Omit<\n            UseQueryOptions<\n                SharedType.AccountsResponse,\n                unknown,\n                SharedType.AccountsResponse,\n                string[]\n            >,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) =>\n        useQuery(['accounts'], api.getAccounts, {\n            staleTime: staleTimes.accounts,\n            onSuccess: (...args) => {\n                if (options?.onSuccess) options.onSuccess(...args)\n            },\n            ...options,\n        })\n\n    const useAccount = (\n        id: Account['id'],\n        options?: Omit<\n            UseQueryOptions<SharedType.AccountDetail, unknown, SharedType.AccountDetail, any[]>,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useQuery(['accounts', id], () => api.get(id), {\n            staleTime: staleTimes.accounts,\n            ...options,\n        })\n    }\n\n    const useCreateAccount = (\n        options?: UseMutationOptions<SharedType.Account, unknown, Record<string, any>>\n    ) =>\n        useMutation(api.create, {\n            onSuccess: () => {\n                toast.success('Account successfully added!')\n            },\n            onError: () => {\n                toast.error('Error adding account')\n            },\n            onSettled: () => {\n                invalidateAccountQueries(queryClient)\n            },\n            ...options,\n        })\n\n    const useUpdateAccount = (\n        options?: UseMutationOptions<SharedType.Account, unknown, Record<string, any>>\n    ) =>\n        useMutation(\n            ({ id, data }: { id: SharedType.Account['id']; data: Record<string, any> }) =>\n                api.update(id, data),\n            {\n                onSuccess: () => {\n                    toast.success('Account successfully updated!')\n                },\n                onError: () => {\n                    toast.error('Error updating account')\n                },\n                onSettled: () => {\n                    invalidateAccountQueries(queryClient)\n                },\n                ...options,\n            }\n        )\n\n    const useDeleteAccount = () =>\n        useMutation(api.delete, {\n            onSuccess: (data) => {\n                toast.success(`${data.name} deleted!`)\n            },\n            onError: () => {\n                toast.error('Failed to delete account')\n            },\n            onSettled: () => {\n                invalidateAccountQueries(queryClient)\n            },\n        })\n\n    const useSyncAccount = () =>\n        useMutation(api.sync, {\n            onSuccess: (data) => {\n                toast.success(`${data.name} sync initiated`)\n            },\n            onError: () => {\n                toast.error('Failed to sync account')\n            },\n            onSettled: () => {\n                invalidateAccountQueries(queryClient, false)\n            },\n        })\n\n    const useAccountBalances = (\n        {\n            id,\n            start,\n            end,\n        }: {\n            id: Account['id']\n        } & SharedType.DateRange,\n        options?: Omit<\n            UseQueryOptions<\n                SharedType.AccountBalanceResponse,\n                unknown,\n                SharedType.AccountBalanceResponse,\n                any[]\n            >,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useQuery(\n            ['accounts', id, 'balances', { start, end }],\n            () => api.getBalances(id, start, end),\n            { staleTime: staleTimes.balances, ...options }\n        )\n    }\n\n    const useAccountReturns = (\n        {\n            id,\n            start,\n            end,\n            compare,\n        }: {\n            id: Account['id']\n            compare: string[]\n        } & SharedType.DateRange,\n        options?: Omit<\n            UseQueryOptions<\n                SharedType.AccountReturnResponse,\n                unknown,\n                SharedType.AccountReturnResponse,\n                any[]\n            >,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useQuery(\n            ['accounts', id, 'returns', { start, end, compare }],\n            () => api.getReturns(id, start, end, compare),\n            { staleTime: staleTimes.returns, ...options }\n        )\n    }\n\n    const useAccountTransactions = (\n        { id }: { id: Account['id'] },\n        options?: Omit<\n            UseInfiniteQueryOptions<\n                SharedType.AccountTransactionResponse,\n                unknown,\n                SharedType.AccountTransactionResponse,\n                SharedType.AccountTransactionResponse,\n                any[]\n            >,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useInfiniteQuery(\n            ['accounts', id, 'transactions'],\n            ({ pageParam = 0 }) => api.getTransactions(id, pageParam),\n            {\n                staleTime: staleTimes.transactions,\n                ...options,\n                getNextPageParam: (lastPage, pages) =>\n                    lastPage.totalTransactions >\n                    pages.reduce((total, { transactions }) => total + transactions.length, 0)\n                        ? pages.length\n                        : undefined,\n            }\n        )\n    }\n\n    const useAccountHoldings = (\n        {\n            id,\n        }: {\n            id: Account['id']\n        },\n        options?: Omit<\n            UseInfiniteQueryOptions<\n                SharedType.AccountHoldingResponse,\n                unknown,\n                SharedType.AccountHoldingResponse,\n                SharedType.AccountHoldingResponse,\n                any[]\n            >,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useInfiniteQuery(\n            ['accounts', id, 'holdings'],\n            ({ pageParam = 0 }) => api.getHoldings(id, pageParam),\n            {\n                staleTime: staleTimes.holdings,\n                ...options,\n                getNextPageParam: (lastPage, pages) =>\n                    lastPage.totalHoldings >\n                    pages.reduce((total, { holdings }) => total + holdings.length, 0)\n                        ? pages.length\n                        : undefined,\n            }\n        )\n    }\n\n    const useAccountInvestmentTransactions = (\n        {\n            id,\n            start,\n            end,\n            category,\n        }: {\n            id: Account['id']\n            category?: SharedType.InvestmentTransactionCategory\n        } & Partial<SharedType.DateRange>,\n        options?: Omit<\n            UseInfiniteQueryOptions<\n                SharedType.AccountInvestmentTransactionResponse,\n                unknown,\n                SharedType.AccountInvestmentTransactionResponse,\n                SharedType.AccountInvestmentTransactionResponse,\n                any[]\n            >,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useInfiniteQuery(\n            ['accounts', id, 'investmentTransactions', { start, end, category }],\n            ({ pageParam = 0 }) =>\n                api.getInvestmentTransactions(id, pageParam, start, end, category),\n            {\n                staleTime: staleTimes.investmentTransactions,\n                ...options,\n                getNextPageParam: (lastPage, pages) =>\n                    lastPage.totalInvestmentTransactions >\n                    pages.reduce(\n                        (total, { investmentTransactions }) =>\n                            total + investmentTransactions.length,\n                        0\n                    )\n                        ? pages.length\n                        : undefined,\n            }\n        )\n    }\n\n    const useAccountRollup = (\n        { start, end }: SharedType.DateRange,\n        options?: Omit<\n            UseQueryOptions<SharedType.AccountRollup, unknown, SharedType.AccountRollup, any[]>,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) =>\n        useQuery(['accounts', 'rollup', { start, end }], () => api.getRollup(start, end), {\n            staleTime: staleTimes.rollup,\n            ...options,\n        })\n\n    const useAccountInsights = (\n        id: Account['id'],\n        options?: Omit<\n            UseQueryOptions<SharedType.AccountInsights, unknown, SharedType.AccountInsights, any[]>,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) =>\n        useQuery(['accounts', id, 'insights'], () => api.getInsights(id), {\n            staleTime: staleTimes.insights,\n            ...options,\n        })\n\n    return {\n        useAccounts,\n        useAccount,\n        useCreateAccount,\n        useUpdateAccount,\n        useDeleteAccount,\n        useSyncAccount,\n        useAccountBalances,\n        useAccountReturns,\n        useAccountTransactions,\n        useAccountHoldings,\n        useAccountInvestmentTransactions,\n        useAccountRollup,\n        useAccountInsights,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/api/useAccountConnectionApi.ts",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport type { AxiosInstance } from 'axios'\nimport type { UseMutationOptions } from '@tanstack/react-query'\nimport { useMemo } from 'react'\nimport { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { toast } from 'react-hot-toast'\nimport { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'\nimport { invalidateAccountQueries } from '../utils'\n\nconst AccountConnectionApi = (axios: AxiosInstance) => ({\n    async update(id: SharedType.AccountConnection['id'], input: Record<string, any>) {\n        const { data } = await axios.put<SharedType.AccountConnection>(`/connections/${id}`, input)\n        return data\n    },\n\n    async delete(id: SharedType.AccountConnection['id']) {\n        const { data } = await axios.delete<SharedType.AccountConnection>(`/connections/${id}`)\n        return data\n    },\n\n    async deleteAll() {\n        const { data } = await axios.delete('/connections')\n        return data\n    },\n\n    async disconnect(id: SharedType.AccountConnection['id']) {\n        const { data } = await axios.post<SharedType.AccountConnection>(\n            `/connections/${id}/disconnect`\n        )\n        return data\n    },\n\n    async reconnect(id: SharedType.AccountConnection['id']) {\n        const { data } = await axios.post<SharedType.AccountConnection>(\n            `/connections/${id}/reconnect`\n        )\n        return data\n    },\n\n    async sync(id: SharedType.AccountConnection['id']) {\n        const { data } = await axios.post<SharedType.AccountConnection>(`/connections/${id}/sync`)\n        return data\n    },\n})\n\nexport function useAccountConnectionApi() {\n    const queryClient = useQueryClient()\n    const { axios } = useAxiosWithAuth()\n    const api = useMemo(() => AccountConnectionApi(axios), [axios])\n\n    const useUpdateConnection = (\n        options?: UseMutationOptions<SharedType.AccountConnection, unknown, Record<string, any>>\n    ) =>\n        useMutation(\n            ({ id, data }: { id: SharedType.AccountConnection['id']; data: Record<string, any> }) =>\n                api.update(id, data),\n            {\n                onSettled: () => {\n                    invalidateAccountQueries(queryClient)\n                },\n                ...options,\n            }\n        )\n\n    const useDeleteConnection = () =>\n        useMutation(api.delete, {\n            onSuccess: (data) => {\n                toast.success(`${data.name} deleted!`)\n            },\n            onError: () => {\n                toast.error('Failed to delete account')\n            },\n            onSettled: () => {\n                invalidateAccountQueries(queryClient)\n            },\n        })\n\n    const useDeleteAllConnections = () =>\n        useMutation(api.deleteAll, {\n            onSuccess: () => {\n                toast.success(`Deleted all connections`)\n            },\n            onError: () => {\n                toast.error('Failed to delete all connections')\n            },\n            onSettled: () => {\n                invalidateAccountQueries(queryClient)\n            },\n        })\n\n    const useDisconnectConnection = () =>\n        useMutation(api.disconnect, {\n            onSuccess: (data) => {\n                toast.success(`${data.name} disconnected`)\n            },\n            onError: () => {\n                toast.error('Failed to disconnect account')\n            },\n            onSettled: () => {\n                invalidateAccountQueries(queryClient)\n            },\n        })\n\n    const useReconnectConnection = () =>\n        useMutation(api.reconnect, {\n            onSuccess: (data) => {\n                toast.success(`${data.name} reconnected!`)\n            },\n            onError: () => {\n                toast.error('Failed to reconnect account')\n            },\n            onSettled: () => {\n                invalidateAccountQueries(queryClient)\n            },\n        })\n\n    const useSyncConnection = () =>\n        useMutation(api.sync, {\n            onSuccess: (connection) => {\n                // update accounts cache immediately\n                queryClient.setQueryData(\n                    ['accounts'],\n                    (\n                        prev: SharedType.AccountsResponse = {\n                            accounts: [],\n                            connections: [],\n                        }\n                    ) => {\n                        const { connections, ...other } = prev\n                        return {\n                            ...other,\n                            connections: connections.map((c) =>\n                                c.id === connection.id ? { ...c, ...connection } : c\n                            ),\n                        }\n                    }\n                )\n            },\n            onSettled: () => {\n                invalidateAccountQueries(queryClient, false)\n            },\n        })\n\n    return {\n        useUpdateConnection,\n        useDeleteConnection,\n        useDeleteAllConnections,\n        useReconnectConnection,\n        useDisconnectConnection,\n        useSyncConnection,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/api/useAuthUserApi.ts",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport type { AxiosInstance } from 'axios'\nimport { useMemo } from 'react'\nimport { useQuery } from '@tanstack/react-query'\nimport { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'\n\nconst AuthUserApi = (axios: AxiosInstance) => ({\n    async getByEmail(email: string) {\n        const { data } = await axios.get<SharedType.AuthUser>(`/auth-users/${email}`)\n        return data\n    },\n})\n\nconst staleTimes = {\n    user: 30_000,\n}\n\nexport function useAuthUserApi() {\n    const { axios } = useAxiosWithAuth()\n    const api = useMemo(() => AuthUserApi(axios), [axios])\n\n    const useGetByEmail = (email: string) =>\n        useQuery(['auth-users', email], () => api.getByEmail(email), { staleTime: staleTimes.user })\n\n    return {\n        useGetByEmail,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/api/useHoldingApi.ts",
    "content": "import type { Holding } from '@prisma/client'\nimport type { AxiosInstance } from 'axios'\nimport type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { useMemo } from 'react'\nimport toast from 'react-hot-toast'\nimport { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'\nimport { useAxiosWithAuth } from '..'\nimport { invalidateAccountQueries } from '../utils'\n\nconst HoldingApi = (axios: AxiosInstance) => ({\n    async getHolding(id: Holding['id']) {\n        const { data } = await axios.get<SharedType.AccountHolding>(`/holdings/${id}`)\n        return data\n    },\n\n    async getInsights(id: Holding['id']) {\n        const { data } = await axios.get<SharedType.HoldingInsights>(`/holdings/${id}/insights`)\n        return data\n    },\n\n    async update(id: Holding['id'], input: Record<string, any>) {\n        const { data } = await axios.put<Holding>(`/holdings/${id}`, input)\n        return data\n    },\n})\n\nexport function useHoldingApi() {\n    const queryClient = useQueryClient()\n    const { axios } = useAxiosWithAuth()\n    const api = useMemo(() => HoldingApi(axios), [axios])\n\n    const useHolding = (\n        id: Holding['id'],\n        options?: Omit<\n            UseQueryOptions<SharedType.AccountHolding, unknown, SharedType.AccountHolding, any[]>,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useQuery(['holdings', id], () => api.getHolding(id), {\n            staleTime: 30_000,\n            ...options,\n        })\n    }\n\n    const useHoldingInsights = (\n        id: Holding['id'],\n        options?: Omit<\n            UseQueryOptions<SharedType.HoldingInsights, unknown, SharedType.HoldingInsights, any[]>,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useQuery(['holdings', id, 'insights'], () => api.getInsights(id), {\n            staleTime: 30_000,\n            ...options,\n        })\n    }\n\n    const useUpdateHolding = (\n        options?: UseMutationOptions<Holding, unknown, Record<string, any>>\n    ) =>\n        useMutation(\n            ({ id, data }: { id: Holding['id']; data: Record<string, any> }) =>\n                api.update(id, data),\n            {\n                onSuccess: () => {\n                    toast.success('Holding successfully updated!')\n                },\n                onError: () => {\n                    toast.error('Error updating holding')\n                },\n                onSettled: () => {\n                    invalidateAccountQueries(queryClient)\n                    queryClient.invalidateQueries(['holdings'])\n                },\n                ...options,\n            }\n        )\n\n    return {\n        useHolding,\n        useHoldingInsights,\n        useUpdateHolding,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/api/useInstitutionApi.ts",
    "content": "import type { UseInfiniteQueryOptions } from '@tanstack/react-query'\nimport type { AxiosInstance } from 'axios'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { useMemo } from 'react'\nimport { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport toast from 'react-hot-toast'\nimport { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'\n\nconst InstitutionApi = (axios: AxiosInstance) => ({\n    async getInstitutions(page: number, search?: string) {\n        const { data } = await axios.get<SharedType.InstitutionsResponse>('/institutions', {\n            params: { page, q: search },\n        })\n        return data\n    },\n\n    async sync() {\n        const { data } = await axios.post(`/institutions/sync`)\n        return data\n    },\n\n    async deduplicate() {\n        const { data } = await axios.post(`/institutions/deduplicate`)\n        return data\n    },\n})\n\nconst staleTimes = {\n    institutions: 5 * 60 * 1000, // 5 minutes\n}\n\nexport function useInstitutionApi() {\n    const queryClient = useQueryClient()\n    const { axios } = useAxiosWithAuth()\n    const api = useMemo(() => InstitutionApi(axios), [axios])\n\n    const useInstitutions = (\n        {\n            search,\n        }: {\n            search?: string\n        },\n        options?: Omit<\n            UseInfiniteQueryOptions<\n                SharedType.InstitutionsResponse,\n                unknown,\n                SharedType.InstitutionsResponse,\n                SharedType.InstitutionsResponse,\n                any[]\n            >,\n            'queryKey' | 'queryFn'\n        >\n    ) => {\n        return useInfiniteQuery(\n            ['institutions', { search }],\n            ({ pageParam = 0 }) => api.getInstitutions(pageParam, search),\n            {\n                staleTime: staleTimes.institutions,\n                ...options,\n                getNextPageParam: (lastPage, pages) =>\n                    lastPage.totalInstitutions >\n                    pages.reduce((total, { institutions }) => total + institutions.length, 0)\n                        ? pages.length\n                        : undefined,\n            }\n        )\n    }\n\n    const useSyncInstitutions = () =>\n        useMutation(api.sync, {\n            onSuccess: () => {\n                toast.success(`Syncing institutions`)\n            },\n            onError: () => {\n                toast.error('Failed to sync institutions')\n            },\n            onSettled: () => {\n                queryClient.invalidateQueries(['institutions'])\n            },\n        })\n\n    const useDeduplicateInstitutions = () =>\n        useMutation(api.deduplicate, {\n            onMutate: () => {\n                toast.loading(`Deduplicating institutions`)\n            },\n            onSuccess: () => {\n                toast.success(`Deduplicated institutions`)\n            },\n            onError: () => {\n                toast.error('Failed to deduplicate institutions')\n            },\n            onSettled: () => {\n                queryClient.invalidateQueries(['institutions'])\n            },\n        })\n\n    return {\n        useInstitutions,\n        useSyncInstitutions,\n        useDeduplicateInstitutions,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/api/usePlanApi.ts",
    "content": "import type { AxiosInstance } from 'axios'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'\nimport { useMemo } from 'react'\nimport toast from 'react-hot-toast'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useAxiosWithAuth } from '..'\n\nconst PlanApi = (axios: AxiosInstance) => ({\n    async getPlans() {\n        const { data } = await axios.get<SharedType.PlansResponse>('/plans')\n        return data\n    },\n\n    async get(id: SharedType.Plan['id']) {\n        const { data } = await axios.get<SharedType.Plan>(`/plans/${id}`)\n        return data\n    },\n\n    async create(input: Record<string, any>) {\n        const { data } = await axios.post<SharedType.Plan>('/plans', input)\n        return data\n    },\n\n    async createTemplate(input: Record<string, any>) {\n        const { data } = await axios.post<SharedType.Plan>('/plans/template', input)\n        return data\n    },\n\n    async update(id: SharedType.Plan['id'], input: Record<string, any>) {\n        const { data } = await axios.put<SharedType.Plan>(`/plans/${id}`, input)\n        return data\n    },\n\n    async updateTemplate(\n        id: SharedType.Plan['id'],\n        input: Record<string, any>,\n        shouldReset?: boolean\n    ) {\n        const { data } = await axios.put<SharedType.Plan>(`/plans/${id}/template`, input, {\n            params: shouldReset ? { reset: shouldReset.toString() } : undefined,\n        })\n        return data\n    },\n\n    async delete(id: SharedType.Plan['id']) {\n        const { data } = await axios.delete<SharedType.Plan>(`/plans/${id}`)\n        return data\n    },\n\n    async projections(id: SharedType.Plan['id']) {\n        const { data } = await axios.get<SharedType.PlanProjectionResponse>(\n            `/plans/${id}/projections`\n        )\n        return data\n    },\n})\n\nconst staleTimes = {\n    plans: 60_000,\n    projections: Infinity, // Will never go stale unless manually invalidated\n}\n\nexport function usePlanApi() {\n    const queryClient = useQueryClient()\n    const { axios } = useAxiosWithAuth()\n    const api = useMemo(() => PlanApi(axios), [axios])\n\n    const usePlans = (\n        options?: Omit<\n            UseQueryOptions<SharedType.PlansResponse, unknown, SharedType.PlansResponse, string[]>,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) =>\n        useQuery(['plans'], api.getPlans, {\n            staleTime: staleTimes.plans,\n            ...options,\n        })\n\n    const usePlan = (\n        id: SharedType.Plan['id'],\n        options?: Omit<\n            UseQueryOptions<SharedType.Plan, unknown, SharedType.Plan, any[]>,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useQuery(['plans', id], () => api.get(id), {\n            staleTime: staleTimes.plans,\n            ...options,\n        })\n    }\n\n    const useCreatePlan = (\n        options?: UseMutationOptions<SharedType.Plan, unknown, Record<string, any>>\n    ) =>\n        useMutation(api.create, {\n            onSuccess: () => {\n                toast.success('Plan successfully added!')\n            },\n            onError: () => {\n                toast.error('Error adding plan')\n            },\n            onSettled: () => {\n                queryClient.invalidateQueries(['plans'])\n            },\n            ...options,\n        })\n\n    const useCreatePlanTemplate = (\n        options?: UseMutationOptions<SharedType.Plan, unknown, Record<string, any>>\n    ) =>\n        useMutation(api.createTemplate, {\n            onSuccess: () => {\n                toast.success('Plan successfully added!')\n            },\n            onError: () => {\n                toast.error('Error adding plan')\n            },\n            onSettled: () => {\n                queryClient.invalidateQueries(['plans'])\n            },\n            ...options,\n        })\n\n    const useUpdatePlanTemplate = () =>\n        useMutation(\n            ({\n                id,\n                data,\n                shouldReset = false,\n            }: {\n                id: SharedType.Plan['id']\n                data: Record<string, any>\n                shouldReset?: boolean\n            }) => api.updateTemplate(id, data, shouldReset),\n            {\n                onSuccess: () => {\n                    toast.success('Plan successfully updated!')\n                },\n                onError: () => {\n                    toast.error('Unable to update plan')\n                },\n                onSettled: () => {\n                    queryClient.invalidateQueries(['plans'])\n                },\n            }\n        )\n\n    const useUpdatePlan = () =>\n        useMutation(\n            ({ id, data }: { id: SharedType.Plan['id']; data: Record<string, any> }) =>\n                api.update(id, data),\n            {\n                onSuccess: () => {\n                    toast.success('Plan successfully updated!')\n                },\n                onError: () => {\n                    toast.error('Error updating plan')\n                },\n                onSettled: () => {\n                    queryClient.invalidateQueries(['plans'])\n                },\n            }\n        )\n\n    const useDeletePlan = () =>\n        useMutation(api.delete, {\n            onSuccess: (data) => {\n                toast.success(`${data.name} deleted!`)\n            },\n            onError: () => {\n                toast.error('Failed to delete plan')\n            },\n            onSettled: () => {\n                queryClient.invalidateQueries(['plans'])\n            },\n        })\n\n    const usePlanProjections = (\n        id: SharedType.Plan['id'],\n        options?: Omit<\n            UseQueryOptions<\n                SharedType.PlanProjectionResponse,\n                unknown,\n                SharedType.PlanProjectionResponse,\n                any[]\n            >,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useQuery(['plans', id, 'projections'], () => api.projections(id), {\n            staleTime: staleTimes.projections,\n            ...options,\n        })\n    }\n\n    return {\n        usePlans,\n        usePlan,\n        useCreatePlan,\n        useCreatePlanTemplate,\n        useUpdatePlan,\n        useUpdatePlanTemplate,\n        useDeletePlan,\n        usePlanProjections,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/api/useSecurityApi.ts",
    "content": "import type { Security } from '@prisma/client'\nimport type { AxiosInstance } from 'axios'\nimport type { UseQueryOptions } from '@tanstack/react-query'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { useMemo } from 'react'\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { useAxiosWithAuth } from '..'\nimport toast from 'react-hot-toast'\n\nconst SecurityApi = (axios: AxiosInstance) => ({\n    async getAllSecurities() {\n        const { data } = await axios.get<SharedType.SecuritySymbolExchange>(`/securities`)\n        return data\n    },\n\n    async getSecurity(id: Security['id']) {\n        const { data } = await axios.get<SharedType.SecurityWithPricing>(`/securities/${id}`)\n        return data\n    },\n\n    async getSecurityDetails(id: Security['id']) {\n        const { data } = await axios.get<SharedType.SecurityDetails>(`/securities/${id}/details`)\n        return data\n    },\n\n    async syncUSStockTickers() {\n        const { data } = await axios.post(`/securities/sync/us-stock-tickers`)\n        return data\n    },\n\n    async syncSecurityPricing() {\n        const { data } = await axios.post(`/securities/sync/stock-pricing`)\n        return data\n    },\n})\n\nconst staleTimes = {\n    security: 30 * 1000, // 30 seconds\n    securityDetails: 30 * 1000, // 30 seconds\n}\n\nexport function useSecurityApi() {\n    const queryClient = useQueryClient()\n    const { axios } = useAxiosWithAuth()\n    const api = useMemo(() => SecurityApi(axios), [axios])\n\n    // Add another API call that gets all the securities from the database\n    const useAllSecurities = (\n        options?: Omit<\n            UseQueryOptions<\n                SharedType.SecuritySymbolExchange,\n                unknown,\n                SharedType.SecuritySymbolExchange,\n                any[]\n            >,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) =>\n        useQuery(['securities'], () => api.getAllSecurities(), {\n            staleTime: staleTimes.security,\n            ...options,\n        })\n\n    const useSecurity = (\n        id: Security['id'],\n        options?: Omit<\n            UseQueryOptions<\n                SharedType.SecurityWithPricing,\n                unknown,\n                SharedType.SecurityWithPricing,\n                any[]\n            >,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useQuery(['securities', id], () => api.getSecurity(id), {\n            staleTime: staleTimes.security,\n            ...options,\n        })\n    }\n\n    const useSecurityDetails = (\n        id: Security['id'],\n        options?: Omit<\n            UseQueryOptions<SharedType.SecurityDetails, unknown, SharedType.SecurityDetails, any[]>,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useQuery(['securities', id, 'details'], () => api.getSecurityDetails(id), {\n            staleTime: staleTimes.securityDetails,\n            ...options,\n        })\n    }\n\n    const useSyncUSStockTickers = () =>\n        useMutation(api.syncUSStockTickers, {\n            onSuccess: () => {\n                toast.success(`Syncing stock tickers`)\n            },\n            onError: () => {\n                toast.error('Failed to sync stock tickers')\n            },\n            onSettled: () => {\n                queryClient.invalidateQueries(['securities'])\n            },\n        })\n\n    const useSyncSecurityPricing = () =>\n        useMutation(api.syncSecurityPricing, {\n            onSuccess: () => {\n                toast.success(`Syncing securities pricing`)\n            },\n            onError: () => {\n                toast.error('Failed to sync securities pricing')\n            },\n            onSettled: () => {\n                queryClient.invalidateQueries(['securities'])\n            },\n        })\n\n    return {\n        useAllSecurities,\n        useSecurity,\n        useSecurityDetails,\n        useSyncUSStockTickers,\n        useSyncSecurityPricing,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/api/useTellerApi.ts",
    "content": "import { useMemo } from 'react'\nimport toast from 'react-hot-toast'\nimport { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'\nimport { useMutation, useQueryClient } from '@tanstack/react-query'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type { AxiosInstance } from 'axios'\nimport type { TellerTypes } from '@maybe-finance/teller-api'\nimport { useAccountConnectionApi } from './useAccountConnectionApi'\n\ntype TellerInstitution = {\n    name: string\n    id: string\n}\n\nconst TellerApi = (axios: AxiosInstance) => ({\n    async handleEnrollment(input: {\n        institution: TellerInstitution\n        enrollment: TellerTypes.Enrollment\n    }) {\n        const { data } = await axios.post<SharedType.AccountConnection>(\n            '/teller/handle-enrollment',\n            input\n        )\n        return data\n    },\n})\n\nexport function useTellerApi() {\n    const queryClient = useQueryClient()\n    const { axios } = useAxiosWithAuth()\n    const api = useMemo(() => TellerApi(axios), [axios])\n\n    const { useSyncConnection } = useAccountConnectionApi()\n    const syncConnection = useSyncConnection()\n\n    const addConnectionToState = (connection: SharedType.AccountConnection) => {\n        const accountsData = queryClient.getQueryData<SharedType.AccountsResponse>(['accounts'])\n        if (!accountsData)\n            queryClient.setQueryData<SharedType.AccountsResponse>(['accounts'], {\n                connections: [{ ...connection, accounts: [] }],\n                accounts: [],\n            })\n        else {\n            const { connections, ...rest } = accountsData\n            queryClient.setQueryData<SharedType.AccountsResponse>(['accounts'], {\n                connections: [...connections, { ...connection, accounts: [] }],\n                ...rest,\n            })\n        }\n    }\n\n    const useHandleEnrollment = () =>\n        useMutation(api.handleEnrollment, {\n            onSuccess: (_connection) => {\n                addConnectionToState(_connection)\n                syncConnection.mutate(_connection.id)\n                toast.success(`Account connection added!`)\n            },\n        })\n\n    return {\n        useHandleEnrollment,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/api/useTransactionApi.ts",
    "content": "import type { AxiosInstance } from 'axios'\nimport type { Transaction } from '@prisma/client'\nimport type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { useMemo } from 'react'\nimport toast from 'react-hot-toast'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useAxiosWithAuth } from '..'\n\nconst TransactionApi = (axios: AxiosInstance) => ({\n    async getAll(pageIndex: number, pageSize: number) {\n        const { data } = await axios.get<SharedType.TransactionsResponse>('/transactions', {\n            params: { pageIndex, pageSize },\n        })\n        return data\n    },\n\n    async get(id: SharedType.Transaction['id']): Promise<SharedType.TransactionWithAccountDetail> {\n        const { data } = await axios.get<SharedType.TransactionWithAccountDetail>(\n            `/transactions/${id}`\n        )\n        return data\n    },\n\n    async update(id: SharedType.Transaction['id'], input: Record<string, any>) {\n        const { data } = await axios.put<SharedType.Transaction>(`/transactions/${id}`, input)\n        return data\n    },\n})\n\nexport function useTransactionApi() {\n    const queryClient = useQueryClient()\n    const { axios } = useAxiosWithAuth()\n    const api = useMemo(() => TransactionApi(axios), [axios])\n\n    const useTransactions = (\n        { pageIndex = 0, pageSize = 50 }: { pageIndex?: number; pageSize?: number },\n        options?: Omit<\n            UseQueryOptions<\n                SharedType.TransactionsResponse,\n                unknown,\n                SharedType.TransactionsResponse,\n                any[]\n            >,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) =>\n        useQuery(['transactions', pageIndex, pageSize], () => api.getAll(pageIndex, pageSize), {\n            staleTime: 60_000,\n            ...options,\n        })\n\n    const useTransaction = (\n        id: Transaction['id'],\n        options?: Omit<\n            UseQueryOptions<\n                SharedType.TransactionWithAccountDetail,\n                unknown,\n                SharedType.TransactionWithAccountDetail,\n                any[]\n            >,\n            'queryKey' | 'queryFn' | 'staleTime'\n        >\n    ) => {\n        return useQuery(['transactions', id], () => api.get(id), {\n            staleTime: 60_000,\n            ...options,\n        })\n    }\n\n    const useUpdateTransaction = (\n        options?: UseMutationOptions<SharedType.Transaction, unknown, Record<string, any>>\n    ) =>\n        useMutation(\n            ({ id, data }: { id: SharedType.Transaction['id']; data: Record<string, any> }) =>\n                api.update(id, data),\n            {\n                onSuccess: () => {\n                    toast.success('Transaction successfully updated!')\n                },\n                onError: () => {\n                    toast.error('Error updating transaction')\n                },\n                onSettled: () => {\n                    queryClient.invalidateQueries(['transactions'])\n                },\n                ...options,\n            }\n        )\n\n    return {\n        useTransactions,\n        useTransaction,\n        useUpdateTransaction,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/api/useUserApi.ts",
    "content": "import type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type { AxiosInstance } from 'axios'\nimport * as Sentry from '@sentry/react'\nimport { useMemo } from 'react'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { toast } from 'react-hot-toast'\nimport { DateTime } from 'luxon'\nimport { useAxiosWithAuth } from '../hooks/useAxiosWithAuth'\n\nconst UserApi = (axios: AxiosInstance) => ({\n    async getNetWorthSeries(start: string, end: string) {\n        const { data } = await axios.get<SharedType.NetWorthTimeSeriesResponse>(\n            `/users/net-worth`,\n            {\n                params: { start, end },\n            }\n        )\n        return data\n    },\n\n    async getInsights() {\n        const { data } = await axios.get<SharedType.UserInsights>(`/users/insights`)\n        return data\n    },\n\n    async getNetWorth(date: string) {\n        const { data } = await axios.get<SharedType.NetWorthTimeSeriesData>(\n            `/users/net-worth/${date}`\n        )\n        return data\n    },\n\n    async update(userData: SharedType.UpdateUser) {\n        const { data } = await axios.put<SharedType.User>('/users', userData)\n        return data\n    },\n\n    async get() {\n        const { data } = await axios.get<SharedType.User>('/users')\n        return data\n    },\n\n    async delete() {\n        return axios.delete('/users', { data: { confirm: true } })\n    },\n\n    async getOnboarding(flow: SharedType.OnboardingFlow) {\n        const { data } = await axios.get<SharedType.OnboardingResponse>(`/users/onboarding/${flow}`)\n        return data\n    },\n\n    async updateOnboarding(input: {\n        flow: SharedType.OnboardingFlow\n        updates: { key: string; markedComplete: boolean }[]\n        markedComplete?: boolean\n    }) {\n        const { data } = await axios.put<SharedType.User>('/users/onboarding', input)\n        return data\n    },\n\n    async getAuthProfile() {\n        const { data } = await axios.get<SharedType.AuthUser>('/users/auth-profile')\n        return data\n    },\n\n    async getSubscription() {\n        const { data } = await axios.get<SharedType.UserSubscription>('/users/subscription')\n        return data\n    },\n\n    async changePassword(newPassword: SharedType.PasswordReset) {\n        const { data } = await axios.put<\n            SharedType.PasswordReset,\n            SharedType.ApiResponse<{ success: boolean; error?: string }>\n        >('/users/change-password', newPassword)\n        return data\n    },\n\n    async resendEmailVerification(authId?: string) {\n        const { data } = await axios.post<{ success: boolean }>(\n            '/users/resend-verification-email',\n            { authId }\n        )\n\n        return data\n    },\n\n    async createCheckoutSession(plan: string) {\n        const { data } = await axios.post<{ url: string }>('/users/checkout-session', { plan })\n\n        return data\n    },\n\n    async createCustomerPortalSession(plan: string) {\n        const { data } = await axios.post<{ url: string }>('/users/customer-portal-session', {\n            plan,\n        })\n\n        return data\n    },\n\n    async getMemberCardDetails(memberId?: string) {\n        const { data } = await axios.get<SharedType.UserMemberCardDetails>(\n            `/users/card/${memberId ?? ''}`\n        )\n\n        return data\n    },\n\n    async sendTestEmail(input: SharedType.SendTestEmail) {\n        const { data } = await axios.post<SharedType.EmailSendingResponse>(\n            '/users/send-test-email',\n            input\n        )\n        return data\n    },\n})\n\nconst staleTimes = {\n    user: 30_000,\n    netWorth: 30_000,\n    insights: 30_000,\n}\n\nexport function useUserApi() {\n    const queryClient = useQueryClient()\n    const { axios } = useAxiosWithAuth()\n    const api = useMemo(() => UserApi(axios), [axios])\n\n    const useNetWorthSeries = (\n        { start, end }: { start: string; end: string },\n        options?: Omit<\n            UseQueryOptions<\n                SharedType.NetWorthTimeSeriesResponse,\n                unknown,\n                SharedType.NetWorthTimeSeriesResponse,\n                any[]\n            >,\n            'queryKey' | 'queryFn'\n        >\n    ) =>\n        useQuery(\n            ['users', 'net-worth', 'series', { start, end }],\n            () => api.getNetWorthSeries(start, end),\n            { staleTime: staleTimes.netWorth, ...options }\n        )\n\n    const useCurrentNetWorth = (date: string = DateTime.local().toISODate()) =>\n        useQuery(['users', 'net-worth', 'current', { date }], () => api.getNetWorth(date), {\n            staleTime: staleTimes.netWorth,\n        })\n\n    const useInsights = () =>\n        useQuery(['users', 'insights'], () => api.getInsights(), {\n            staleTime: staleTimes.insights,\n        })\n\n    const useProfile = (\n        options?: Omit<\n            UseQueryOptions<SharedType.User, unknown, SharedType.User, any[]>,\n            'queryKey' | 'queryFn'\n        >\n    ) =>\n        useQuery(['users'], api.get, {\n            staleTime: staleTimes.user,\n            ...options,\n        })\n\n    const useOnboarding = (\n        flow: SharedType.OnboardingFlow,\n        options?: Omit<\n            UseQueryOptions<\n                SharedType.OnboardingResponse,\n                unknown,\n                SharedType.OnboardingResponse,\n                any[]\n            >,\n            'queryKey' | 'queryFn'\n        >\n    ) => useQuery(['users', 'onboarding', flow], () => api.getOnboarding(flow), options)\n\n    const useUpdateOnboarding = (\n        options?: UseMutationOptions<\n            SharedType.User,\n            unknown,\n            {\n                flow: SharedType.OnboardingFlow\n                updates: { key: string; markedComplete: boolean }[]\n                markedComplete?: boolean\n            }\n        >\n    ) =>\n        useMutation(api.updateOnboarding, {\n            onSettled: () => queryClient.invalidateQueries(['users', 'onboarding']),\n            ...options,\n        })\n\n    const useUpdateProfile = (\n        options?: UseMutationOptions<SharedType.User, unknown, SharedType.UpdateUser>\n    ) =>\n        useMutation(api.update, {\n            onSuccess: () => {\n                toast.success(`Updated user!`)\n            },\n            onError: () => {\n                toast.error('Error updating user')\n            },\n            onSettled: () => {\n                queryClient.invalidateQueries(['users'])\n            },\n            ...options,\n        })\n\n    const useAuthProfile = (\n        options?: Omit<\n            UseQueryOptions<SharedType.AuthUser, unknown, SharedType.AuthUser, any[]>,\n            'queryKey' | 'queryFn'\n        >\n    ) =>\n        useQuery(['auth-profile'], api.getAuthProfile, {\n            staleTime: staleTimes.user,\n            ...options,\n        })\n\n    const useSubscription = (\n        options?: Omit<UseQueryOptions<SharedType.UserSubscription>, 'queryKey' | 'queryFn'>\n    ) => useQuery(['users', 'subscription'], api.getSubscription, options)\n\n    const useChangePassword = () =>\n        useMutation(api.changePassword, {\n            onSuccess: () => {\n                toast.success('Password reset successfully')\n            },\n            onError: (err) => {\n                toast.error(typeof err === 'string' ? err : 'Could not reset password')\n            },\n            onSettled: () => {\n                queryClient.invalidateQueries(['users'])\n            },\n        })\n\n    const useResendEmailVerification = (\n        options?: UseMutationOptions<{ success: boolean } | undefined, unknown, string | undefined>\n    ) =>\n        useMutation(api.resendEmailVerification, {\n            onError: () => {\n                toast.error(\n                    'Hmm... Something went wrong sending the verification email.  Please contact us for additional help.'\n                )\n            },\n            ...options,\n        })\n\n    const useCreateCheckoutSession = (\n        options?: UseMutationOptions<{ url: string }, unknown, string>\n    ) =>\n        useMutation(api.createCheckoutSession, {\n            onError: (err) => {\n                Sentry.captureException(err)\n                toast.error('Error creating checkout session')\n            },\n            ...options,\n        })\n\n    const useCreateCustomerPortalSession = (\n        options?: UseMutationOptions<{ url: string }, unknown, string>\n    ) =>\n        useMutation(api.createCustomerPortalSession, {\n            onError: (err) => {\n                Sentry.captureException(err)\n                toast.error('Error creating customer portal session')\n            },\n            ...options,\n        })\n\n    const useMemberCardDetails = (\n        memberId?: string,\n        options?: Omit<UseQueryOptions<SharedType.UserMemberCardDetails>, 'queryKey' | 'queryFn'>\n    ) =>\n        useQuery(\n            ['users', 'card', memberId ?? 'current'],\n            () => api.getMemberCardDetails(memberId),\n            options\n        )\n\n    const useSendTestEmail = () =>\n        useMutation(api.sendTestEmail, {\n            onSuccess: () => {\n                toast.success('Test email sent successfully')\n            },\n            onError: () => {\n                toast.error('Error sending test email')\n            },\n        })\n\n    const useDelete = (options?: UseMutationOptions<{}, unknown, any>) =>\n        useMutation(api.delete, options)\n\n    return {\n        useNetWorthSeries,\n        useInsights,\n        useCurrentNetWorth,\n        useProfile,\n        useUpdateProfile,\n        useAuthProfile,\n        useSubscription,\n        useChangePassword,\n        useResendEmailVerification,\n        useCreateCheckoutSession,\n        useCreateCustomerPortalSession,\n        useMemberCardDetails,\n        useSendTestEmail,\n        useOnboarding,\n        useUpdateOnboarding,\n        useDelete,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/api/useValuationApi.ts",
    "content": "import type { AxiosInstance } from 'axios'\nimport type { Account } from '@prisma/client'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'\n\nimport { useMemo } from 'react'\nimport { DateTime } from 'luxon'\nimport toast from 'react-hot-toast'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useAxiosWithAuth } from '..'\nimport { invalidateAccountQueries } from '../utils'\n\nconst ValuationApi = (axios: AxiosInstance) => ({\n    async create(accountId: SharedType.Account['id'], input: Record<string, any>) {\n        const { data } = await axios.post<SharedType.Valuation>(\n            `/accounts/${accountId}/valuations`,\n            input\n        )\n        return data\n    },\n\n    async update(id: SharedType.Valuation['id'], input: Record<string, any>) {\n        const { data } = await axios.put<SharedType.Valuation>(`/valuations/${id}`, input)\n        return data\n    },\n\n    async delete(id: SharedType.Valuation['id']) {\n        const { data } = await axios.delete<SharedType.Valuation>(`/valuations/${id}`)\n        return data\n    },\n\n    async getValuations(\n        id: SharedType.Account['id'],\n        start?: string,\n        end?: string\n    ): Promise<SharedType.AccountValuationsResponse> {\n        const { data } = await axios.get<SharedType.AccountValuationsResponse>(\n            `/accounts/${id}/valuations`,\n            {\n                params: { start, end },\n            }\n        )\n\n        return data\n    },\n})\n\nconst staleTimes = {\n    valuations: 30000,\n}\n\nexport function useValuationApi() {\n    const queryClient = useQueryClient()\n    const { axios } = useAxiosWithAuth()\n    const api = useMemo(() => ValuationApi(axios), [axios])\n\n    const useCreateValuation = (\n        options?: UseMutationOptions<SharedType.Valuation, unknown, Record<string, any>>\n    ) =>\n        useMutation(\n            ({ id, data }: { id: SharedType.Account['id']; data: Record<string, any> }) =>\n                api.create(id, data),\n            {\n                ...options,\n                onSuccess: (...args) => {\n                    toast.success('Valuation successfully added!')\n                    if (options?.onSuccess) options.onSuccess(...args)\n                },\n                onError: (...args) => {\n                    toast.error('Error adding valuation')\n                    if (options?.onError) options.onError(...args)\n                },\n                onSettled: () => {\n                    invalidateAccountQueries(queryClient)\n                },\n            }\n        )\n\n    const useUpdateValuation = (\n        options?: UseMutationOptions<SharedType.Valuation, unknown, Record<string, any>>\n    ) =>\n        useMutation(\n            ({ id, data }: { id: SharedType.Valuation['id']; data: Record<string, any> }) =>\n                api.update(id, data),\n            {\n                onSuccess: (...args) => {\n                    toast.success('Valuation successfully updated!')\n                    if (options?.onSuccess) options.onSuccess(...args)\n                },\n                onError: (...args) => {\n                    toast.error('Error updating valuation')\n                    if (options?.onError) options.onError(...args)\n                },\n                onSettled: () => {\n                    queryClient.invalidateQueries(['accounts'])\n                },\n            }\n        )\n\n    const useDeleteValuation = (\n        options?: UseMutationOptions<SharedType.Valuation, unknown, Record<string, any>>\n    ) =>\n        useMutation(({ id }: { id: SharedType.Valuation['id'] }) => api.delete(id), {\n            onSuccess: (...args) => {\n                toast.success(\n                    `Valuation for ${DateTime.fromJSDate(args[0].date, { zone: 'utc' }).toFormat(\n                        'MMM d, yyyy'\n                    )} deleted!`\n                )\n                if (options?.onSuccess) options.onSuccess(...args)\n            },\n            onError: (...args) => {\n                toast.error('Failed to delete valuation')\n                if (options?.onError) options.onError(...args)\n            },\n            onSettled: () => {\n                invalidateAccountQueries(queryClient)\n            },\n        })\n\n    const useAccountValuations = (\n        {\n            id,\n            start,\n            end,\n        }: {\n            id: Account['id']\n            start?: string\n            end?: string\n        },\n        options: Omit<\n            UseQueryOptions<\n                SharedType.AccountValuationsResponse,\n                unknown,\n                SharedType.AccountValuationsResponse,\n                any[]\n            >,\n            'queryKey' | 'queryFn'\n        >\n    ) => {\n        const queryKey = ['accounts', id, 'valuations']\n\n        if (start && end) {\n            queryKey.push({ start, end } as any)\n        }\n\n        return useQuery(queryKey, () => api.getValuations(id, start, end), {\n            staleTime: staleTimes.valuations,\n            ...options,\n        })\n    }\n\n    return {\n        useAccountValuations,\n        useCreateValuation,\n        useUpdateValuation,\n        useDeleteValuation,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/cards/MaybeCard.tsx",
    "content": "import { type MouseEvent, type CSSProperties, useRef, useEffect } from 'react'\nimport { animate, motion, useMotionValue, useTransform } from 'framer-motion'\nimport { DateTime } from 'luxon'\nimport classNames from 'classnames'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { LoadingSpinner } from '@maybe-finance/design-system'\n\nconst MaybeCardVariants = {\n    default: 'w-full h-full flex items-center justify-center',\n    onboarding: 'p-8',\n    settings: 'py-12 w-full flex items-center justify-center bg-gray-800',\n}\n\nexport type MaybeCardProps = {\n    variant?: keyof typeof MaybeCardVariants\n    details?: Omit<SharedType.UserMemberCardDetails, 'cardUrl' | 'imageUrl'>\n    flipped: boolean\n}\n\nconst now = new Date()\n\nexport function MaybeCard({ variant = 'default', details, flipped }: MaybeCardProps) {\n    const container = useRef<HTMLDivElement>(null)\n    const mouseX = useMotionValue(0.5)\n    const mouseY = useMotionValue(0.5)\n    const rotateX = useTransform(mouseY, [0, 1], ['-15deg', '15deg'])\n    const rotateY = useTransform(mouseX, [0, 1], ['15deg', '-15deg'])\n    const rotateYOffset = useMotionValue('0deg')\n\n    const mouseMove = (e: MouseEvent<HTMLDivElement>) => {\n        if (!container.current) return\n\n        const rect = container.current.getBoundingClientRect()\n        animate(mouseX, (e.clientX - rect.left) / rect.width)\n        animate(mouseY, (e.clientY - rect.top) / rect.height)\n    }\n\n    const mouseLeave = () => {\n        animate(mouseX, 0.5, { duration: 0.3 })\n        animate(mouseY, 0.5, { duration: 0.3 })\n    }\n\n    useEffect(() => {\n        animate(rotateYOffset, flipped ? '180deg' : '0deg', { duration: 0.5 })\n    }, [rotateYOffset, flipped])\n\n    return (\n        <motion.div\n            ref={container}\n            className={classNames('overflow-hidden', MaybeCardVariants[variant])}\n            onPointerMove={mouseMove}\n            onPointerLeave={mouseLeave}\n            style={\n                {\n                    ...(variant === 'settings'\n                        ? {\n                              backgroundImage: `\n                                  radial-gradient(150% 150% at 50% 100%, #1C1C20, transparent 100%),\n                                  repeating-linear-gradient(to right, transparent 0, #232428 1px, transparent 1px, transparent 20px),\n                                  repeating-linear-gradient(to bottom, transparent 0, #232428 1px, transparent 1px, transparent 20px)\n                              `,\n                              backgroundSize: '100% 100%',\n                              //backgroundPosition: '-10px -10px',\n                          }\n                        : undefined),\n\n                    '--mx': mouseX,\n                    '--my': mouseY,\n                    '--rx': rotateX,\n                    '--ry': rotateY,\n                    '--ryo': rotateYOffset,\n                    perspective: '1000px',\n                } as CSSProperties\n            }\n        >\n            <div className=\"relative w-[278px] h-[400px]\" style={{ transformStyle: 'preserve-3d' }}>\n                {['front', 'back'].map((face) => (\n                    <div\n                        key={face}\n                        className=\"absolute inset-0 p-px rounded-2xl shadow-2xl bg-gray-800\"\n                        style={{\n                            backgroundImage: `\n                                radial-gradient(107% 89% at calc((var(--mx) - 0.5) * 50% - 5%) calc((var(--my) - 0.5) * 50% - 25%), #4CC9F0FF, transparent 100%),\n                                radial-gradient(87% 71% at calc((var(--mx) - 0.5) * 50% + 90%) calc((var(--my) - 0.5) * 50% + 125%), #F72585FF, transparent 100%)\n                            `,\n                            transform: `rotateY(calc(var(--ry) + var(--ryo) + ${\n                                face === 'front' ? '0deg' : '180deg'\n                            })) rotateX(var(--rx))`,\n                            backfaceVisibility: 'hidden',\n                        }}\n                    >\n                        <div className=\"relative flex flex-col items-center justify-end p-6 w-full h-full rounded-2xl bg-black overflow-hidden\">\n                            {/* Logo */}\n                            <div\n                                className={classNames(\n                                    \"absolute bg-[url('/assets/maybe-black.svg')] bg-no-repeat bg-contain\",\n                                    details === undefined && 'hidden',\n                                    face === 'front'\n                                        ? 'w-[130%] h-full top-[10%] left-[22%] opacity-30 mix-blend-multiply'\n                                        : 'w-[15%] h-full bottom-8 left-[50%] -translate-x-1/2 bg-bottom opacity-[7%] invert'\n                                )}\n                            ></div>\n\n                            {/* Colored Lights */}\n                            <div\n                                className={classNames(\n                                    'absolute inset-0 opacity-50',\n                                    face === 'back' && 'scale-x-[-1]'\n                                )}\n                                style={{\n                                    backgroundImage: `\n                                        radial-gradient(150% 100% at calc((var(--mx) - 0.5) * 25% - 55%) calc((var(--my) - 0.5) * 25% - 55%), #4CC9F0ff, transparent 100%),\n                                        radial-gradient(100% 66% at calc((var(--mx) - 0.5) * 25% + 70%) calc((var(--my) - 0.5) * 15% + 140%), #F72585ff, transparent 100%)\n                                    `,\n                                }}\n                            ></div>\n\n                            {/* Shine */}\n                            <div\n                                className={classNames(\n                                    'absolute inset-0 mix-blend-hard-light',\n                                    face === 'front' ? 'opacity-40' : 'opacity-30 scale-x-[-1]'\n                                )}\n                                style={{\n                                    backgroundImage: `\n                                        linear-gradient(20deg, #000a, transparent 50%),\n                                        linear-gradient(135deg, #fff0 27%, #fff2 36%, #fff3 39%, #fff3 41%, #fff2 44%, #fff0 53%),\n                                        linear-gradient(135deg, #fff0 47%, #fff2 56%, #fff3 59%, #fff3 61%, #fff2 64%, #fff0 73%)\n                                    `,\n                                    backgroundPosition:\n                                        'calc((var(--mx) - 0.5) * 25% + 50%) calc((var(--my) - 0.5) * 25% + 50%)',\n                                    backgroundSize: '200% 200%',\n                                }}\n                            ></div>\n\n                            {details === undefined ? (\n                                <div className=\"grow w-full flex justify-center items-center\">\n                                    <LoadingSpinner variant=\"secondary\" />\n                                </div>\n                            ) : face === 'front' ? (\n                                <>\n                                    <span className=\"text-sm text-gray-100\">\n                                        #{(details?.memberNumber ?? 0).toString().padStart(3, '0')}\n                                    </span>\n                                    <span className=\"mt-1 text-lg text-white\">\n                                        {details?.name ?? 'Maybe User'}\n                                    </span>\n                                    <span className=\"text-lg text-gray-100\">\n                                        {details?.title ?? <>&nbsp;</>}\n                                    </span>\n                                    <span className=\"mt-1.5 text-sm text-gray-100\">\n                                        Joined{' '}\n                                        {DateTime.fromJSDate(details?.joinDate ?? now).toFormat(\n                                            'LL.dd.yy'\n                                        )}\n                                    </span>\n                                </>\n                            ) : (\n                                <div className=\"grow w-full text-left\">\n                                    <div className=\"text-sm text-gray-100\">Your Maybe</div>\n                                    <div className=\"mt-3 text-lg text-white\">{details?.maybe}</div>\n                                </div>\n                            )}\n                        </div>\n                    </div>\n                ))}\n            </div>\n        </motion.div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/cards/MaybeCardShareModal.tsx",
    "content": "import { Fragment } from 'react'\nimport { Dialog, Transition } from '@headlessui/react'\nimport { toast } from 'react-hot-toast'\nimport { RiLink, RiTwitterFill } from 'react-icons/ri'\nimport { Button } from '@maybe-finance/design-system'\nimport { MaybeCard, type MaybeCardProps } from './MaybeCard'\n\nexport type MaybeCardShareModalProps = {\n    isOpen: boolean\n    onClose: () => void\n    cardUrl: string\n    card: Omit<MaybeCardProps, 'flipped'>\n}\n\nexport function MaybeCardShareModal({ isOpen, onClose, cardUrl, card }: MaybeCardShareModalProps) {\n    return (\n        <Transition appear show={isOpen} as={Fragment}>\n            <Dialog as=\"div\" className=\"relative z-10\" onClose={onClose}>\n                <Transition.Child\n                    as={Fragment}\n                    enter=\"ease-out duration-300\"\n                    enterFrom=\"opacity-0\"\n                    enterTo=\"opacity-100\"\n                    leave=\"ease-in duration-200\"\n                    leaveFrom=\"opacity-100\"\n                    leaveTo=\"opacity-0\"\n                >\n                    {/* Backdrop */}\n                    <div\n                        className=\"fixed inset-0 bg-gray-800 bg-opacity-80 transition-opacity\"\n                        aria-hidden=\"true\"\n                    />\n                </Transition.Child>\n\n                <div className=\"fixed inset-0 overflow-y-auto\">\n                    <div className=\"flex min-h-full items-center justify-center p-4 text-center\">\n                        <Transition.Child\n                            as={Fragment}\n                            enter=\"ease-out duration-300\"\n                            enterFrom=\"opacity-0 scale-95\"\n                            enterTo=\"opacity-100 scale-100\"\n                            leave=\"ease-in duration-200\"\n                            leaveFrom=\"opacity-100 scale-100\"\n                            leaveTo=\"opacity-0 scale-95\"\n                        >\n                            <Dialog.Panel className=\"relative w-full sm:max-w-md p-4 sm:p-6 sm:my-8 bg-gray-700 rounded text-left shadow-md shadow-black transform transition-all\">\n                                <div className=\"rounded-lg overflow-hidden\">\n                                    <MaybeCard variant=\"settings\" flipped={false} {...card} />\n                                </div>\n                                <div className=\"mt-6 flex flex-col space-y-3\">\n                                    <Button\n                                        as=\"a\"\n                                        variant=\"primary\"\n                                        fullWidth\n                                        href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(\n                                            `I'm user #${card.details?.memberNumber} on @Maybe and I'm using it to manage my finances and investing!\\n\\nGive it a go: https://maybe.co\\n\\n${cardUrl}`\n                                        )}`}\n                                        target=\"_blank\"\n                                        onClick={() => onClose()}\n                                    >\n                                        Tweet\n                                        <RiTwitterFill className=\"ml-2 w-5 h-5\" />\n                                    </Button>\n                                    <Button\n                                        type=\"button\"\n                                        variant=\"secondary\"\n                                        fullWidth\n                                        onClick={() => {\n                                            navigator.clipboard.writeText(cardUrl)\n                                            toast('Link copied to clipboard')\n                                            onClose()\n                                        }}\n                                    >\n                                        Share link\n                                        <RiLink className=\"ml-2 w-5 h-5 text-gray-50\" />\n                                    </Button>\n                                </div>\n                            </Dialog.Panel>\n                        </Transition.Child>\n                    </div>\n                </div>\n            </Dialog>\n        </Transition>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/cards/index.ts",
    "content": "export * from './MaybeCard'\nexport * from './MaybeCardShareModal'\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/index.ts",
    "content": "export * as TSeries from './time-series'\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/AxisBottom.tsx",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport type { SharedAxisProps } from '@visx/axis'\nimport type { ValidXScaleTypes } from './types'\n\nimport { AxisBottom as VisxAxisBottom } from '@visx/axis'\nimport { DateTime } from 'luxon'\nimport { useChartData } from './BaseChart'\nimport { useMemo } from 'react'\n\ntype Props = {\n    interval?: SharedType.TimeSeriesInterval\n}\n\n/** X-axis: shows start, middle, and end dates of domain */\nexport function AxisBottom({\n    interval = 'days',\n    ...rest\n}: Props & Omit<SharedAxisProps<ValidXScaleTypes>, 'scale'>) {\n    const { xScale, height, margin, width } = useChartData()\n\n    const ticks = useMemo(() => {\n        const start = DateTime.fromJSDate(xScale.invert(0), { zone: 'utc' })\n        const end = xScale.invert(width)\n        const diff = DateTime.fromJSDate(end, { zone: 'utc' }).diff(start, 'days').days\n        const middle = start.plus({ days: diff / 2 }).toJSDate()\n\n        return [start.toJSDate(), middle, end]\n    }, [width, xScale])\n\n    return (\n        <VisxAxisBottom\n            scale={xScale}\n            top={height - margin.bottom + 15}\n            hideTicks\n            hideAxisLine\n            axisClassName=\"text-gray-100\"\n            numTicks={3}\n            tickValues={ticks}\n            tickFormat={(date) => {\n                if (!date) return ''\n                const zone = { zone: 'utc' }\n                const fmt = interval === 'days' || interval === 'weeks' ? 'MMM d, yyyy' : 'MMM yyyy'\n\n                return date instanceof Date\n                    ? DateTime.fromJSDate(date, zone).toFormat(fmt)\n                    : DateTime.fromMillis(date?.valueOf(), zone).toFormat(fmt)\n            }}\n            tickLabelProps={(_data, index, arr) => {\n                return {\n                    fill: 'currentColor',\n                    textAnchor: index === arr.length - 1 ? 'end' : index === 0 ? 'start' : 'middle',\n                    verticalAnchor: 'middle',\n                    fontSize: 12,\n                }\n            }}\n            {...rest}\n        />\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/AxisLeft.tsx",
    "content": "import type { SharedAxisProps } from '@visx/axis'\nimport type { ValidYScaleTypes } from './types'\n\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { AxisLeft as VisxAxisLeft } from '@visx/axis'\nimport { useChartData } from './BaseChart'\n\nexport function AxisLeft(props: Omit<SharedAxisProps<ValidYScaleTypes>, 'scale'>) {\n    const { y1Scale, margin } = useChartData()\n\n    return (\n        <VisxAxisLeft\n            scale={y1Scale}\n            left={margin.left - 15}\n            hideTicks\n            hideAxisLine\n            numTicks={5}\n            axisClassName=\"text-gray-100\"\n            tickFormat={(datum) => NumberUtil.format(datum.valueOf(), 'short-currency')}\n            tickLabelProps={() => {\n                return {\n                    fill: 'currentColor',\n                    textAnchor: 'end',\n                    verticalAnchor: 'middle',\n                    fontSize: 12,\n                }\n            }}\n            {...props}\n        />\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/BaseChart.tsx",
    "content": "import type { ChartContext, ChartData, ChartDataContext, ChartProps, Datum, Spacing } from './types'\n\nimport type { SharedType } from '@maybe-finance/shared'\nimport { DateUtil } from '@maybe-finance/shared'\nimport { GridRows } from '@visx/grid'\nimport { scaleLinear, scaleUtc } from '@visx/scale'\nimport { Line } from '@visx/shape'\nimport mapValues from 'lodash/mapValues'\nimport { createContext, useContext, useMemo } from 'react'\nimport { AxisBottom } from './AxisBottom'\nimport { AxisLeft } from './AxisLeft'\nimport { useTooltip } from './useTooltip'\n\nconst Context = createContext<ChartDataContext | undefined>(undefined)\n\nexport const useChartData = () => {\n    const ctx = useContext(Context)\n\n    if (!ctx) throw new Error('Must place useChartData() inside <Chart /> component')\n\n    return ctx\n}\n\nexport function BaseChart<TDatum extends Datum>({\n    children,\n    id: chartId,\n    width,\n    height,\n    dateRange,\n    series,\n    data,\n    interval = 'days',\n    xAxis,\n    y1Axis,\n    xScale: _xScale,\n    y1Scale: _y1Scale,\n    margin,\n    padding,\n    tooltipOptions,\n    renderTooltip,\n}: Omit<ChartProps<TDatum>, 'data' | 'margin' | 'dateRange' | 'renderOverlay'> & {\n    data: ChartData<TDatum>\n    margin: Spacing\n    dateRange: SharedType.DateRange<string>\n} & { width: number; height: number }) {\n    if (!Array.isArray(data) && series.some((s) => s.dataKey === undefined)) {\n        throw new Error(\n            'When data is provided in key:value format, `dataKey` must be provided for each series'\n        )\n    }\n\n    const enhancedSeries = useMemo(() => {\n        return series.map((s) => ({\n            ...s,\n            isActive: s.isActive ?? true,\n            showVariance:\n                s.showVariance ?? series.filter((s) => s.isActive ?? true).length > 1\n                    ? false\n                    : true,\n        }))\n    }, [series])\n\n    const enhancedData = useMemo(() => {\n        if (Array.isArray(data)) {\n            return data.map((d) => ({ ...d, dateJS: DateUtil.strToDate(d.date) }))\n        }\n\n        return mapValues(data, (arr) =>\n            arr?.map((d) => ({\n                ...d,\n                dateJS: DateUtil.strToDate(d.date), // added for convenience\n            }))\n        )\n    }, [data])\n\n    // Find min and max y values for all series\n    const { minY, maxY } = useMemo(() => {\n        const values = enhancedSeries\n            .filter((s) => s.isActive)\n            .map((s) => {\n                return Array.isArray(data)\n                    ? data.map((d) => s.accessorFn(d))\n                    : data[s.dataKey!].map((d) => s.accessorFn(d))\n            })\n            .flat()\n            .filter((v): v is number => v != null)\n\n        const minY = Math.min(...values)\n        const maxY = Math.max(...values)\n\n        // Adds % padding if specified (i.e. if max = 100 and padding is 10%, max value is 100 + (100 * 0.10) = 110 units)\n        return {\n            minY: minY - Math.abs(minY) * (padding?.bottom ?? 0),\n            maxY: maxY + Math.abs(maxY) * (padding?.top ?? 0),\n        }\n    }, [data, enhancedSeries, padding?.bottom, padding?.top])\n\n    // Default configurations for scales\n    const { y1Scale, xScale } = useMemo(() => {\n        // https://observablehq.com/@d3/margin-convention\n        const xRange = [margin.left, width - margin.right]\n        const yRange = [height - margin.bottom, margin.top]\n\n        return {\n            y1Scale:\n                _y1Scale ??\n                scaleLinear<number>({\n                    domain: [minY, maxY],\n                    range: yRange,\n                    nice: true,\n                    clamp: true,\n                }),\n            xScale:\n                _xScale ??\n                scaleUtc<number>({\n                    domain: [\n                        DateUtil.strToDate(dateRange.start),\n                        DateUtil.strToDate(dateRange.end),\n                    ],\n                    range: xRange,\n                    nice: {\n                        interval: DateUtil.toD3Interval(interval),\n                        step: interval === 'quarters' ? 3 : 1,\n                    },\n                    clamp: true,\n                }),\n        }\n    }, [\n        _xScale,\n        _y1Scale,\n        dateRange.start,\n        dateRange.end,\n        interval,\n        minY,\n        maxY,\n        height,\n        width,\n        margin,\n    ])\n\n    const chartCtx: ChartContext = {\n        chartId,\n        xScale,\n        y1Scale,\n        margin,\n        width,\n        height,\n        data: enhancedData,\n        series: enhancedSeries,\n    }\n\n    const {\n        tooltipData,\n        tooltipLeft,\n        tooltipTop,\n        tooltipOpen,\n        tooltipHandler,\n        defaultStyles,\n        tooltipPortalRef,\n        TooltipWrapper,\n        DefaultTooltip,\n    } = useTooltip(chartCtx, tooltipOptions)\n\n    return (\n        <Context.Provider\n            value={{ ...chartCtx, tooltipData, tooltipLeft, tooltipTop, tooltipOpen }}\n        >\n            {/* Important: this is the ref element for tooltips, must be set to relative */}\n            <div\n                ref={tooltipPortalRef}\n                className=\"relative w-full h-full\"\n                onPointerMove={tooltipHandler}\n            >\n                <svg width={width} height={height}>\n                    {/* Primary y axis */}\n                    {y1Axis ?? <AxisLeft />}\n\n                    {/* Date axis  */}\n                    {xAxis ?? <AxisBottom interval={interval} />}\n\n                    {/* Horizontal, dotted lines at each y value tick mark */}\n                    <GridRows\n                        width={width - margin.left - margin.right}\n                        left={margin.left}\n                        scale={y1Scale}\n                        numTicks={11}\n                        stroke=\"currentColor\"\n                        strokeDasharray=\"1 8\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        className=\"text-gray-400\"\n                    />\n\n                    {/* Vertical line for tooltip hover */}\n                    {tooltipOpen && tooltipData && (\n                        <Line\n                            x1={tooltipLeft}\n                            x2={tooltipLeft}\n                            y1={margin.top}\n                            y2={height - margin.bottom}\n                            strokeWidth={2}\n                            stroke=\"currentColor\"\n                            strokeOpacity={0.5}\n                            className=\"text-gray-200\"\n                        />\n                    )}\n\n                    {/* Pass chart context as render props so arbitrary SVG elements can be passed as children and use ctx as inputs */}\n                    {typeof children === 'function'\n                        ? children({\n                              ...chartCtx,\n                              tooltipOpen,\n                              tooltipData,\n                              tooltipLeft,\n                              tooltipTop,\n                          })\n                        : children}\n                </svg>\n\n                {/* Must render tooltip outside of SVG since it renders as div  */}\n                {tooltipOpen && tooltipData && (\n                    <TooltipWrapper\n                        key={Math.random()} // needed for bounds to update correctly (see - https://airbnb.io/visx/tooltip)\n                        left={tooltipLeft}\n                        top={tooltipTop}\n                        style={{\n                            ...defaultStyles,\n                            backgroundColor: 'transparent',\n                        }}\n                        offsetLeft={tooltipOptions?.offsetX}\n                        offsetTop={tooltipOptions?.offsetY}\n                    >\n                        {renderTooltip ? (\n                            renderTooltip(tooltipData)\n                        ) : (\n                            <DefaultTooltip title={tooltipOptions?.tooltipTitle} />\n                        )}\n                    </TooltipWrapper>\n                )}\n            </div>\n        </Context.Provider>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/Chart.tsx",
    "content": "import type { ChartProps, Datum } from './types'\nimport type { FallbackProps } from 'react-error-boundary'\n\nimport { useMemo } from 'react'\nimport { ParentSize } from '@visx/responsive'\nimport { LoadingChart } from './LoadingChart'\nimport { ErrorBoundary } from 'react-error-boundary'\nimport { BaseChart } from './BaseChart'\n\nimport * as Sentry from '@sentry/react'\n\nconst defaultMargin = { top: 20, left: 75, bottom: 55, right: 10 }\n\nexport function Chart<TDatum extends Datum>({\n    data,\n    isLoading,\n    isError,\n    dateRange,\n    margin: _margin,\n    renderOverlay,\n    ...rest\n}: ChartProps<TDatum>) {\n    const margin = useMemo(() => ({ ...defaultMargin, ..._margin }), [_margin])\n\n    if (!dateRange?.start || !dateRange?.end) {\n        if (!isLoading) {\n            console.warn('No date range provided to chart')\n        }\n\n        return <LoadingChart margin={margin} />\n    }\n\n    if (isLoading || isError || renderOverlay != null) {\n        return (\n            <LoadingChart\n                margin={margin}\n                animate={!isError}\n                isError={isError}\n                renderOverlay={renderOverlay}\n            />\n        )\n    }\n\n    function ErrorFallback({ resetErrorBoundary: _ }: FallbackProps) {\n        return <LoadingChart animate={false} margin={margin} isError />\n    }\n\n    return (\n        // Prevent chart errors from crashing entire UI\n        <ParentSize>\n            {({ width, height }) => {\n                if (!width || !height || width <= 0 || height <= 0) return null\n                return (\n                    // Set to relative for error boundary overlay\n                    <div className=\"relative w-full h-full\">\n                        <ErrorBoundary\n                            FallbackComponent={ErrorFallback}\n                            onError={(err) => {\n                                Sentry.captureException(err)\n                                console.error('Chart crashed', err)\n                            }}\n                        >\n                            <BaseChart<TDatum>\n                                data={data!} // if not loading or error, assume there is data\n                                margin={margin}\n                                width={width}\n                                height={height}\n                                isLoading={isLoading}\n                                isError={isError}\n                                dateRange={{ start: dateRange.start!, end: dateRange.end! }}\n                                {...rest}\n                            />\n                        </ErrorBoundary>\n                    </div>\n                )\n            }}\n        </ParentSize>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/DefaultTooltip.tsx",
    "content": "import type { SeriesDatum, TooltipOptions } from './types'\n\nimport { NumberUtil } from '@maybe-finance/shared'\nimport { DateTime } from 'luxon'\nimport { useMemo } from 'react'\nimport { useChartData } from './BaseChart'\nimport { tailwindScale } from './colorScales'\nimport classNames from 'classnames'\n\ntype Props = {\n    title: TooltipOptions['tooltipTitle']\n}\n\n/**\n * Default tooltip will loop through all of the active series and print the value of the datum\n *\n * This is rendered outside SVG, so wrap in normal div container\n */\nexport function DefaultTooltip({ title: _title }: Props) {\n    const { series, data, tooltipData } = useChartData()\n\n    const title = useMemo(() => {\n        if (!tooltipData) return ''\n        if (_title) return _title(tooltipData)\n\n        return DateTime.fromISO(tooltipData.date, { zone: 'utc' }).toFormat('MMM dd, yyyy')\n    }, [_title, tooltipData])\n\n    const seriesWithVariance = useMemo(() => {\n        if (!tooltipData) return []\n\n        return series\n            .filter((s) => s.isActive) // by default, don't show non-active series in the tooltip\n            .map(\n                ({\n                    key: seriesKey,\n                    accessorFn,\n                    showVariance,\n                    color,\n                    dataKey,\n                    negative,\n                    ...rest\n                }) => {\n                    const seriesData = Array.isArray(data) ? data : data[dataKey!]\n                    let trend: SeriesDatum['trend']\n\n                    const curr = tooltipData.series?.[seriesKey].value\n\n                    // If enabled, calculate the trend and populate\n                    if (showVariance && curr && tooltipData) {\n                        const nonNullValues = seriesData.filter((v) => accessorFn(v) !== undefined)\n\n                        const first = accessorFn(nonNullValues[0])\n\n                        if (first) {\n                            const diff = curr - first\n                            const percentage = NumberUtil.calculatePercentChange(first, curr)\n\n                            const up = negative ? 'down' : 'up'\n                            const down = negative ? 'up' : 'down'\n\n                            trend = {\n                                amount: diff,\n                                percentage,\n                                direction:\n                                    percentage > 0.01 ? up : percentage < -0.01 ? down : 'flat',\n                            }\n                        }\n                    }\n\n                    return {\n                        accessorFn,\n                        value: curr,\n                        trend,\n                        color:\n                            (color && typeof color === 'function'\n                                ? tooltipData.series?.[seriesKey]\n                                    ? color(tooltipData.series[seriesKey].originalDatum)\n                                    : tailwindScale('cyan')\n                                : color) ?? tailwindScale('cyan'),\n                        ...rest,\n                    }\n                }\n            )\n    }, [series, tooltipData, data])\n\n    if (!title) return null\n\n    return (\n        <div className=\"flex flex-col gap-1 bg-gray-700 rounded border border-gray-600 text-base text-gray-50 p-2 min-w-[225px]\">\n            <span>{title}</span>\n\n            {seriesWithVariance\n                .filter((s) => s.isActive)\n                .map((s, idx) => (\n                    <div key={idx} className=\"flex items-center gap-2 text-white\">\n                        <span\n                            className={classNames(\n                                'inline-block w-1 rounded-lg leading-none bg-current'\n                            )}\n                            style={{ color: s.color }}\n                            role=\"presentation\"\n                        >\n                            &nbsp;\n                        </span>\n                        {s.label && <span>{s.label}</span>}\n                        <span className={classNames('font-medium', s.label && 'ml-auto')}>\n                            {NumberUtil.format(s.value, s.format ?? 'currency')}\n                        </span>\n                        {s.trend && (\n                            <div\n                                className={classNames(\n                                    'flex items-center gap-1 font-medium',\n                                    s.trend.direction === 'up'\n                                        ? 'text-teal'\n                                        : s.trend.direction === 'down'\n                                        ? 'text-red'\n                                        : 'text-gray-50',\n                                    !s.label && 'ml-auto'\n                                )}\n                            >\n                                <span className=\"ml-auto\">\n                                    {s.trend.amount && (\n                                        <span>\n                                            {NumberUtil.format(\n                                                s.trend.amount,\n                                                s?.format ?? 'currency',\n                                                {\n                                                    signDisplay: 'exceptZero',\n                                                }\n                                            )}\n                                        </span>\n                                    )}\n                                </span>\n                                <span>({NumberUtil.format(s.trend.percentage, 'percent')})</span>\n                            </div>\n                        )}\n                    </div>\n                ))}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/FloatingIcon.tsx",
    "content": "import type { IconType } from 'react-icons'\nimport type { SVGProps } from 'react'\nimport { Circle } from '@visx/shape'\nimport { Fragment } from 'react'\n\ninterface Props extends SVGProps<SVGCircleElement> {\n    icon: IconType\n    iconColor: string\n    left: number\n    top: number\n\n    /* The index of this icon in relation to other icons with the same y coordinate */\n    stackIdx: number\n    verticalOffset?: number\n}\n\n/** Icon that floats above a chart element */\n// eslint-disable-next-line no-empty-pattern\nexport function FloatingIcon({ left, top, stackIdx, icon: Icon, iconColor, ...rest }: Props) {\n    return (\n        <Fragment>\n            <Circle cx={left} cy={top - stackIdx * 35} r={16} {...rest} />\n            <Icon size={16} x={left - 8} y={top - 8 - stackIdx * 35} fill={iconColor} />\n        </Fragment>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/Line.tsx",
    "content": "import type { Series, TooltipData, TSeriesDatumEnhanced } from './types'\nimport type { SVGProps, ReactNode } from 'react'\n\nimport { useChartData } from './BaseChart'\nimport { AreaClosed, LinePath } from '@visx/shape'\nimport { Group } from '@visx/group'\nimport { GlyphCircle } from '@visx/glyph'\nimport { MultiColorGradient } from './MultiColorGradient'\nimport { ZeroPointGradient } from './ZeroPointGradient'\nimport { useSeries } from './useSeries'\nimport classNames from 'classnames'\n\nexport function Line({\n    seriesKey,\n    renderGlyph,\n    gradientOpacity = 0.2,\n    interpolateLineProps,\n    ...rest\n}: {\n    seriesKey: Series['key']\n\n    /* Default line and gradient color class */\n    gradientOpacity?: number\n\n    /* Overrides the default circle glyph on hover */\n    renderGlyph?: (tooltipData: TooltipData, left: number, top: number) => ReactNode\n\n    /* SVG LinePath props for the interpolated line */\n    interpolateLineProps?: Omit<SVGProps<SVGPathElement>, 'x' | 'y' | 'children'>\n} & Omit<SVGProps<SVGPathElement>, 'x' | 'y' | 'children'>) {\n    const { xScale, y1Scale, tooltipLeft, tooltipData } = useChartData()\n\n    const {\n        data,\n        dataKey,\n        color,\n        accessorFn,\n        isActive,\n        isDefinedAccessor,\n        hasUndefinedSegments,\n        lineColor,\n        seriesPrefix,\n        currentDatum,\n    } = useSeries(seriesKey)\n\n    if (!isActive) return null\n\n    return (\n        <Group color={typeof color === 'string' ? color : undefined}>\n            <>\n                {/* Displays a dashed line to represent missing data (null, undefined, Infinity, or NaN values)  */}\n                {hasUndefinedSegments && (\n                    <LinePath\n                        data={data.filter(isDefinedAccessor)}\n                        x={(datum) => xScale(datum.dateJS)}\n                        y={(datum) => {\n                            const value = accessorFn(datum)\n                            return y1Scale(value!)\n                        }}\n                        className={classNames('fill-transparent stroke-2 opacity-50')}\n                        stroke={lineColor}\n                        strokeLinejoin=\"round\"\n                        {...interpolateLineProps}\n                    />\n                )}\n\n                {/* Primary line path  */}\n                <LinePath\n                    data={data}\n                    // Some sort of bug here - must define the datum type otherwise TS compiler throws error\n                    x={(datum: TSeriesDatumEnhanced) => xScale(datum.dateJS)}\n                    y={(datum) => {\n                        const value = accessorFn(datum)\n                        return y1Scale(value ?? 0)\n                    }}\n                    className={classNames('fill-transparent stroke-2')}\n                    stroke={lineColor}\n                    // Leave a blank space for data gaps (will be represented as dashed stroke above)\n                    defined={isDefinedAccessor}\n                    strokeLinejoin=\"round\"\n                    {...rest}\n                />\n\n                {gradientOpacity && !hasUndefinedSegments && (\n                    <Group>\n                        <AreaClosed\n                            yScale={y1Scale}\n                            data={data}\n                            // Fill will start where the y axis equals 0 and move *towards* the line\n                            y0={y1Scale(0)}\n                            x={(datum) => xScale(datum.dateJS)}\n                            y={(datum) => {\n                                const value = accessorFn(datum)\n                                return y1Scale(value ?? 0)\n                            }}\n                            fillOpacity={0.5}\n                            fill={`url(#${seriesPrefix}-zero-point-gradient)`}\n                        />\n                        <ZeroPointGradient\n                            id={`${seriesPrefix}-zero-point-gradient`}\n                            opacity={gradientOpacity}\n                        />\n                    </Group>\n                )}\n\n                {/* On hover, this glyph displays over the data point on the line, OR renders a custom glyph */}\n                {/* Keep this at bottom to ensure proper stacking context */}\n                {tooltipLeft && tooltipData && currentDatum ? (\n                    renderGlyph ? (\n                        renderGlyph(tooltipData, tooltipLeft, y1Scale(currentDatum.value))\n                    ) : (\n                        <GlyphCircle\n                            left={tooltipLeft}\n                            top={y1Scale(currentDatum.value)}\n                            size={60}\n                            strokeWidth={2}\n                            fill={typeof color === 'function' ? color(currentDatum.datum) : color}\n                        />\n                    )\n                ) : null}\n\n                {/* Multi colored line gradient (optional)  */}\n                {/* Pass an accessor that evaluates each data point and determines what color to display around that point  */}\n                {typeof color === 'function' && (\n                    <MultiColorGradient\n                        id={`${seriesPrefix}-color-gradient`}\n                        accessorFn={color}\n                        dataKey={dataKey}\n                    />\n                )}\n            </>\n        </Group>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/LineRange.tsx",
    "content": "import type { Series, TooltipData } from './types'\nimport type { ReactNode } from 'react'\n\nimport { useMemo } from 'react'\nimport { useChartData } from './BaseChart'\nimport { Group } from '@visx/group'\nimport { LinePath } from '@visx/shape'\nimport { Threshold } from '@visx/threshold'\nimport { curveNatural } from '@visx/curve'\nimport { GlyphCircle } from '@visx/glyph'\nimport { MultiColorGradient } from './MultiColorGradient'\nimport { useSeries } from './useSeries'\n\nexport function LineRange({\n    mainSeriesKey,\n    lowerSeriesKey,\n    upperSeriesKey,\n    renderGlyph,\n}: {\n    mainSeriesKey: Series['key']\n    lowerSeriesKey: Series['key']\n    upperSeriesKey: Series['key']\n    renderGlyph?: (tooltipData: TooltipData, left: number, top: number) => ReactNode\n}) {\n    const { xScale, y1Scale, tooltipData, tooltipLeft, margin, height } = useChartData()\n\n    const mainSeries = useSeries(mainSeriesKey)\n\n    const lowerSeries = useSeries(lowerSeriesKey)\n    const upperSeries = useSeries(upperSeriesKey)\n\n    const combinedData = useMemo(() => {\n        return mainSeries.data.map((mainSeriesData) => {\n            return {\n                date: mainSeriesData.date,\n                dateJS: mainSeriesData.dateJS,\n                mainData: mainSeriesData,\n                upperData: upperSeries.data.find((u) => u.date === mainSeriesData.date),\n                lowerData: lowerSeries.data.find((l) => l.date === mainSeriesData.date),\n            }\n        })\n    }, [mainSeries, lowerSeries, upperSeries])\n\n    if (!mainSeries.isActive) return null\n\n    if (\n        mainSeries.isActive !== lowerSeries.isActive ||\n        mainSeries.isActive !== upperSeries.isActive\n    )\n        throw new Error('All series in threshold must be active or inactive')\n\n    return (\n        <Group color={typeof mainSeries.color === 'string' ? mainSeries.color : undefined}>\n            {/* Primary line path  */}\n            <LinePath\n                data={mainSeries.data}\n                x={(datum) => xScale(datum.dateJS)}\n                y={(datum) => {\n                    const value = mainSeries.accessorFn(datum)\n                    return y1Scale(value ?? 0)\n                }}\n                className=\"fill-transparent stroke-2\"\n                curve={curveNatural}\n                stroke={mainSeries.lineColor}\n                strokeLinejoin=\"round\"\n            />\n\n            <Threshold\n                id={Math.random().toString()}\n                data={combinedData}\n                x={(datum) => xScale(datum.dateJS)}\n                y0={(datum) =>\n                    y1Scale((datum.lowerData ? lowerSeries.accessorFn(datum.lowerData) : 0) ?? 0)\n                }\n                y1={(datum) =>\n                    y1Scale((datum.upperData ? upperSeries.accessorFn(datum.upperData) : 0) ?? 0)\n                }\n                // Set to the entire vertical range of the chart (i.e. no clipping)\n                clipAboveTo={0}\n                clipBelowTo={height - margin.bottom}\n                curve={curveNatural}\n                className=\"opacity-10\"\n                aboveAreaProps={{ fill: mainSeries.lineColor }}\n                belowAreaProps={{ fill: mainSeries.lineColor }}\n            />\n\n            {/* On hover, this glyph displays over the data point on the line, OR renders a custom glyph */}\n            {/* Keep this at bottom to ensure proper stacking context */}\n            {tooltipData && tooltipLeft && mainSeries.currentDatum ? (\n                renderGlyph ? (\n                    renderGlyph(tooltipData, tooltipLeft, y1Scale(mainSeries.currentDatum.value))\n                ) : (\n                    <GlyphCircle\n                        left={tooltipLeft}\n                        top={y1Scale(mainSeries.currentDatum.value)}\n                        size={60}\n                        strokeWidth={2}\n                        fill={\n                            typeof mainSeries.color === 'function'\n                                ? mainSeries.color(mainSeries.currentDatum.datum)\n                                : mainSeries.color\n                        }\n                    />\n                )\n            ) : null}\n\n            {/* Multi colored line gradient (optional)  */}\n            {/* Pass an accessor that evaluates each data point and determines what color to display around that point  */}\n            {typeof mainSeries.color === 'function' && (\n                <MultiColorGradient\n                    id={`${mainSeries.seriesPrefix}-color-gradient`}\n                    accessorFn={mainSeries.color}\n                    dataKey={mainSeries.dataKey}\n                />\n            )}\n        </Group>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/LoadingChart.tsx",
    "content": "import type { Spacing, RenderOverlay } from './types'\n\nimport { Button } from '@maybe-finance/design-system'\nimport { DateUtil } from '@maybe-finance/shared'\nimport { AxisBottom, AxisLeft } from '@visx/axis'\nimport { GridRows } from '@visx/grid'\nimport { ParentSize } from '@visx/responsive'\nimport { scaleLinear, scaleUtc } from '@visx/scale'\nimport { AreaClosed } from '@visx/shape'\nimport { motion } from 'framer-motion'\nimport { DateTime } from 'luxon'\n\ntype Props = {\n    margin: Spacing\n    renderOverlay?: RenderOverlay\n    animate?: boolean\n    isError?: boolean\n}\n\nconst dates = DateUtil.generateDailySeries(\n    DateTime.utc().toISODate(),\n    DateTime.utc().plus({ months: 2 }).toISODate()\n)\n\nconst values = [\n    59, 61, 62, 60, 65, 68, 68, 61, 52, 55, 56, 51, 48, 50, 44, 41, 48, 50, 50, 52, 60, 61, 62, 63,\n    62, 63, 70, 71, 67, 67, 65, 67, 65, 62, 57, 61, 63, 58, 61, 58, 59, 58, 56, 58, 55, 60, 58, 67,\n    71, 76, 74, 78, 77, 79, 75, 79, 79, 81, 87, 89, 88, 89, 90,\n]\n\nconst data = dates.map((date, idx) => ({\n    date: DateTime.fromISO(date).toJSDate(),\n    value: values[idx],\n}))\n\nexport function LoadingChart({ margin, animate = true, isError = false, renderOverlay }: Props) {\n    return (\n        <ParentSize className=\"relative\">\n            {({ width, height }) => {\n                const xScale = scaleUtc({\n                    domain: [\n                        DateTime.fromISO(dates[0]).toJSDate(),\n                        DateTime.fromISO(dates[dates.length - 1]).toJSDate(),\n                    ],\n                    range: [margin.left, width - margin.right],\n                })\n                const yScale = scaleLinear({\n                    domain: [0, 100],\n                    range: [height - margin.bottom, margin.top],\n                })\n\n                return (\n                    <>\n                        {(isError || renderOverlay != null) && (\n                            <div\n                                className=\"absolute bg-black w-full h-full inset-0 bg-opacity-80 rounded flex gap-3 flex-col items-center justify-center\"\n                                style={{\n                                    left: margin.left,\n                                    top: margin.top,\n                                    width: width - margin.right - margin.left,\n                                    height: height - margin.top - margin.bottom,\n                                }}\n                            >\n                                {renderOverlay != null ? (\n                                    renderOverlay()\n                                ) : (\n                                    <>\n                                        <h2>Oops!</h2>\n                                        <p className=\"text-gray-100\">\n                                            Something went wrong. Try again?\n                                        </p>\n                                        <Button onClick={() => window.location.reload()}>\n                                            Reload\n                                        </Button>\n                                    </>\n                                )}\n                            </div>\n                        )}\n                        <svg width={width} height={height}>\n                            <AxisLeft\n                                scale={yScale}\n                                left={margin.left}\n                                hideAxisLine\n                                hideTicks\n                                numTicks={1}\n                                axisClassName=\"text-gray-100\"\n                                tickFormat={(d, idx) => (idx === 0 ? '$0.00' : '∞')}\n                                tickLabelProps={() => ({\n                                    fill: 'currentColor',\n                                    textAnchor: 'end',\n                                    verticalAnchor: 'middle',\n                                    fontSize: 12,\n                                })}\n                            />\n\n                            <AxisBottom\n                                scale={xScale}\n                                top={height - margin.bottom}\n                                hideTicks\n                                hideAxisLine\n                                axisClassName=\"text-gray-100\"\n                                tickValues={[data[0].date, data[data.length - 1].date]}\n                                tickFormat={(date) =>\n                                    DateTime.fromJSDate(date as Date).toFormat('MMM d, yyyy')\n                                }\n                                tickLabelProps={(_data, index, arr) => {\n                                    return {\n                                        fill: 'currentColor',\n                                        textAnchor:\n                                            index === arr.length - 1\n                                                ? 'end'\n                                                : index === 0\n                                                ? 'start'\n                                                : 'middle',\n                                        verticalAnchor: 'middle',\n                                        fontSize: 12,\n                                    }\n                                }}\n                            />\n\n                            <GridRows\n                                width={width - margin.left - margin.right}\n                                left={margin.left}\n                                scale={yScale}\n                                numTicks={10}\n                                stroke=\"currentColor\"\n                                strokeDasharray=\"1 8\"\n                                strokeLinecap=\"round\"\n                                strokeLinejoin=\"round\"\n                                className=\"text-gray-500\"\n                            />\n\n                            <AreaClosed\n                                yScale={yScale}\n                                data={data}\n                                x={(datum) => xScale(datum.date)}\n                                y={(datum) => yScale(datum.value)}\n                                className=\"text-gray-700\"\n                                fill=\"currentColor\"\n                            />\n\n                            <AreaClosed\n                                yScale={yScale}\n                                data={data}\n                                x={(datum) => xScale(datum.date)}\n                                y={(datum) => yScale(datum.value)}\n                                fill={\n                                    animate && renderOverlay == null\n                                        ? 'url(#placeholder-gradient)'\n                                        : '#232428'\n                                }\n                            />\n\n                            {/* Animated shine gradient (should match 'bg-shine animate-shine') */}\n                            <motion.linearGradient\n                                className=\"text-white\"\n                                id=\"placeholder-gradient\"\n                                animate={{\n                                    gradientTransform: ['translate(-1, 0)', 'translate(1, 0)'],\n                                }}\n                                transition={{\n                                    duration: 1.8,\n                                    repeat: Infinity,\n                                }}\n                            >\n                                <stop offset=\"0%\" stopColor=\"currentColor\" stopOpacity=\"0\" />\n                                <stop offset=\"25%\" stopColor=\"currentColor\" stopOpacity=\"0\" />\n                                <stop offset=\"50%\" stopColor=\"currentColor\" stopOpacity=\"0.07\" />\n                                <stop offset=\"75%\" stopColor=\"currentColor\" stopOpacity=\"0\" />\n                                <stop offset=\"100%\" stopColor=\"currentColor\" stopOpacity=\"0\" />\n                            </motion.linearGradient>\n                        </svg>\n                    </>\n                )\n            }}\n        </ParentSize>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/MultiColorGradient.tsx",
    "content": "import type { AccessorFn } from './types'\n\nimport { LinearGradient } from '@visx/gradient'\nimport { Fragment } from 'react'\nimport { useChartData } from './BaseChart'\n\ntype Props = {\n    id: string\n    dataKey: string\n    accessorFn: AccessorFn<any, string>\n}\n\nexport function MultiColorGradient({ id, dataKey, accessorFn }: Props) {\n    const { data, width, xScale } = useChartData()\n\n    const dataArr = Array.isArray(data) ? data : data[dataKey]\n\n    return (\n        <LinearGradient id={id} gradientUnits=\"userSpaceOnUse\" x1={0} x2={width}>\n            {dataArr.map((datum, idx, arr) => {\n                if (idx > 0) {\n                    const currColor = accessorFn(datum)\n                    const prevColor = accessorFn(arr[idx - 1])\n                    const prevDate = arr[idx - 1].dateJS\n                    const offset = xScale(prevDate) / width\n\n                    if (!Number.isFinite(offset)) return null\n\n                    /**\n                     * In order to make \"hard color stops\", there needs to be 2 stops\n                     * for each color (start, end).\n                     *\n                     * Ex: Red line from 0-20%, blue line from 20-100%\n                     * <stop offset=\"0%\" stopColor=\"red\" />\n                     * <stop offset=\"20%\" stopColor=\"red\" />\n                     * <stop offset=\"20%\" stopColor=\"blue\" />\n                     * <stop offset=\"100%\" stopColor=\"blue\" />\n                     */\n\n                    return (\n                        <Fragment key={idx}>\n                            <stop offset={offset} stopColor={prevColor} />\n                            <stop offset={offset} stopColor={currColor} />\n                        </Fragment>\n                    )\n                } else return null\n            })}\n        </LinearGradient>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/PlusCircleGlyph.tsx",
    "content": "import type { SVGProps } from 'react'\n\nimport { Glyph } from '@visx/glyph'\nimport { Circle, Line } from '@visx/shape'\nimport { Group } from '@visx/group'\n\ninterface Props extends SVGProps<SVGGElement> {\n    left: number\n    top: number\n}\n\nexport function PlusCircleGlyph({ left, top, ...rest }: Props) {\n    return (\n        // Wrap in group to allow click handler\n        <Group {...rest}>\n            <Glyph left={left} top={top}>\n                <Circle r={12} stroke=\"transparent\" />\n                <Line x1={-5} x2={5} y1={0} y2={0} strokeWidth={2} />\n                <Line x1={0} x2={0} y1={-5} y2={5} strokeWidth={2} />\n            </Glyph>\n        </Group>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/ZeroPointGradient.tsx",
    "content": "import { LinearGradient } from '@visx/gradient'\nimport { useChartData } from './BaseChart'\n\ntype Props = {\n    id: string\n    opacity: number\n}\n\n// Vertical gradient that converges towards the \"zero point\"\nexport function ZeroPointGradient({ id, opacity }: Props) {\n    const { y1Scale, height } = useChartData()\n\n    return (\n        <LinearGradient id={id} gradientUnits=\"userSpaceOnUse\" y1={0} y2={height} x1={0} x2={0}>\n            <stop stopColor=\"currentColor\" offset={0} stopOpacity={opacity}></stop>\n            <stop stopColor=\"currentColor\" offset={y1Scale(0) / height} stopOpacity={0}></stop>\n            <stop stopColor=\"currentColor\" offset={1} stopOpacity={opacity}></stop>\n        </LinearGradient>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/colorScales.ts",
    "content": "import { scaleOrdinal } from '@visx/scale'\n\nexport const tailwindScale = scaleOrdinal({\n    domain: [\n        'white',\n        'gray',\n        'gray-100',\n        'cyan',\n        'red',\n        'teal',\n        'yellow',\n        'blue',\n        'orange',\n        'pink',\n        'grape',\n        'indigo',\n        'green',\n    ],\n    range: [\n        '#F8F9FA',\n        '#34363C',\n        '#868E96',\n        '#3BC9DB',\n        '#FF8787',\n        '#38D9A9',\n        '#FFCA28',\n        '#4DABF7',\n        '#FFA94D',\n        '#F783AC',\n        '#DA77F2',\n        '#748FFC',\n        '#66BB6A',\n    ],\n}).unknown('#3BC9DB') // default to cyan\n\nexport const tailwindBgScale = scaleOrdinal({\n    domain: ['cyan', 'grape', 'red', 'gray'],\n    range: ['#1A282D', '#2A2030', '#2D2125', '#232428'],\n}).unknown('#1A282D') // default to cyan\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/index.ts",
    "content": "export * from './Chart'\nexport * from './types'\nexport * from './FloatingIcon'\nexport * from './Line'\nexport * from './PlusCircleGlyph'\nexport * from './LineRange'\nexport * from './AxisBottom'\nexport * from './AxisLeft'\nexport * from './colorScales'\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/types.ts",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport type { ScaleTypeToD3Scale } from '@visx/scale'\nimport type { ReactNode } from 'react'\nimport type { WithChildrenRenderProps } from '../../../types'\nimport type { O } from 'ts-toolbelt'\n\n/**\n * Assumptions\n *\n * - Time series data uses Date in UTC timezone\n * - Accessor fns always return a number for y-axes\n */\n\nexport type Spacing = {\n    top: number\n    left: number\n    bottom: number\n    right: number\n}\n\nexport type Datum = Record<string, any>\n\nexport type TSeriesDatum<TDatum extends Datum = any> = {\n    date: string\n    values: Partial<TDatum>\n}\n\nexport type TSeriesDatumEnhanced<TDatum extends Datum = any> = TSeriesDatum<TDatum> & {\n    dateJS: Date\n}\n\nexport type AccessorFn<TDatum extends Datum, TValue = number> = (\n    datum: TSeriesDatum<TDatum>\n) => TValue | undefined\n\nexport type Series<TDatum extends Datum = any, TValue = number> = {\n    key: string\n    accessorFn: AccessorFn<TDatum, TValue>\n    /* hex color value per datum or per series (defaults to cyan) */\n    color?: string | AccessorFn<TDatum, string>\n    format?: SharedType.FormatString\n    isActive?: boolean\n    dataKey?: string /* Required if data is provided in key:value format */\n    label?: string /* A user-friendly label shown on default tooltips for this series */\n    showVariance?: boolean /* Defaults to false, whether to show a percentage variance for this datum */\n    negative?: boolean /* Whether to negate sentiment/colors for changes (up = red, down = green) */\n}\n\nexport type SeriesEnhanced<TDatum extends Datum = any> = O.Required<\n    Series<TDatum>,\n    'isActive' | 'showVariance'\n>\n\nexport type SeriesDatum = Omit<Series, 'color'> & { color: string } & {\n    value?: number | SharedType.Decimal\n    trend?: Pick<SharedType.Trend, 'direction'> & { amount: number; percentage: number }\n}\n\nexport type TooltipOptions = {\n    /**\n     * If rendered in portal, will float outside chart bounds, otherwise stays within chart.\n     * @see https://airbnb.io/visx/tooltip\n     */\n    renderInPortal?: boolean\n    offsetX?: number\n    offsetY?: number\n\n    /* If specified, tooltip will snap to this series.  Otherwise, it snaps to closest point relative to the cursor. */\n    referenceSeriesKey?: Series['key']\n\n    /* Specify title for default tooltip component.  Defaults to date formatted as MMM dd, yyyy */\n    tooltipTitle?: (data: TooltipData) => ReactNode\n}\n\n// For now, assume all charts will have time-based x-axis and linear y-axes that only deal with number values\nexport type ValidXScaleTypes = ScaleTypeToD3Scale<number>['utc']\nexport type ValidYScaleTypes = ScaleTypeToD3Scale<number>['linear']\n\nexport type ChartData<TDatum extends Datum> =\n    | Record<string, TSeriesDatum<TDatum>[]>\n    | TSeriesDatum<TDatum>[]\n\nexport type ChartDataEnhanced<TDatum extends Datum> =\n    | Record<string, TSeriesDatumEnhanced<TDatum>[]>\n    | TSeriesDatumEnhanced<TDatum>[]\n\nexport type RenderOverlay = () => ReactNode\n\ntype ChartPropsBase<TDatum extends Datum> = {\n    id: string\n    isLoading: boolean\n    isError: boolean\n    series: Series<TDatum>[]\n    dateRange: Partial<SharedType.DateRange>\n    data?: ChartData<TDatum>\n    xAxis?: ReactNode\n    y1Axis?: ReactNode\n    xScale?: ValidXScaleTypes\n    y1Scale?: ValidYScaleTypes\n    interval?: SharedType.TimeSeriesInterval\n    margin?: Partial<Spacing>\n    padding?: Partial<Pick<Spacing, 'top' | 'bottom'>>\n    tooltipOptions?: TooltipOptions\n    renderTooltip?: (tooltipData: TooltipData<TDatum>) => ReactNode // Default tooltip rendered if not specified\n    renderOverlay?: RenderOverlay\n}\n\ntype TooltipSeriesData<TDatum extends Datum = any> =\n    | Record<\n          string,\n          {\n              originalSeries: SeriesEnhanced<TDatum>\n              originalDatum: TSeriesDatumEnhanced<TDatum>\n              value: number | null\n          }\n      >\n    | undefined // If series are changed while user is hovering the chart, this will be undefined for a moment\n\nexport type TooltipData<TDatum extends Datum = any> = {\n    date: string\n    dateJS: Date\n    series: TooltipSeriesData<TDatum> // original series and datum wrapped under a series key\n    values: Array<number | null> // extracted values in arr format for convenience\n}\n\nexport type ChartContext<TDatum extends Datum = any> = {\n    /* A unique id for the chart which is used for internal SVG elements to avoid collisions */\n    chartId: string\n    xScale: ValidXScaleTypes\n    y1Scale: ValidYScaleTypes\n    margin: Spacing\n    width: number\n    height: number\n    series: SeriesEnhanced[]\n    data: ChartDataEnhanced<TDatum>\n}\n\nexport type TooltipContext<TDatum extends Datum = any> = {\n    tooltipOpen: boolean\n    tooltipLeft?: number\n    tooltipTop?: number\n    tooltipData?: TooltipData<TDatum>\n}\n\nexport type ChartDataContext<TDatum extends Datum = any> = ChartContext<TDatum> &\n    TooltipContext<TDatum>\n\nexport type ChartProps<TDatum extends Datum> = WithChildrenRenderProps<\n    ChartPropsBase<TDatum>,\n    ChartDataContext<TDatum>\n>\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/useSeries.ts",
    "content": "import type { TSeriesDatumEnhanced } from './types'\n\nimport { useCallback, useMemo } from 'react'\nimport { useChartData } from './BaseChart'\nimport { tailwindScale } from './colorScales'\n\nexport function useSeries(seriesKey: string) {\n    const { series: chartSeries, data, chartId, tooltipOpen, tooltipData } = useChartData()\n\n    const seriesPrefix = useMemo(() => `${chartId}-${seriesKey}`, [chartId, seriesKey])\n\n    const series = useMemo(() => {\n        const series = chartSeries.find((s) => s.key === seriesKey)\n\n        if (!series) throw new Error(`Invalid series key: ${seriesKey}`)\n\n        const { accessorFn, color, isActive } = series\n\n        return {\n            data: Array.isArray(data) ? data : data[series.dataKey!],\n            dataKey: series.dataKey!,\n            color: color ?? tailwindScale('cyan'), // default to cyan color\n            accessorFn,\n            isActive: isActive ?? true, // if not specified by chart, series will show\n        }\n    }, [data, chartSeries, seriesKey])\n\n    const isDefinedAccessor = useCallback(\n        (datum: TSeriesDatumEnhanced): boolean => {\n            return Number.isFinite(series.accessorFn(datum))\n        },\n        [series]\n    )\n\n    const hasUndefinedSegments = useMemo(\n        () => series.data.filter(isDefinedAccessor).length !== series.data.length,\n        [series.data, isDefinedAccessor]\n    )\n\n    const lineColor = useMemo(\n        () =>\n            typeof series.color === 'function'\n                ? `url(#${seriesPrefix}-color-gradient)`\n                : 'currentColor',\n        [series.color, seriesPrefix]\n    )\n\n    const currentDatum = useMemo(() => {\n        if (!tooltipData || !tooltipOpen) return null\n        const data = tooltipData.series?.[seriesKey]\n        if (!data) return null\n\n        const value = series.accessorFn(data.originalDatum)\n\n        if (value == null) return null\n\n        return {\n            datum: data.originalDatum,\n            value,\n        }\n    }, [tooltipOpen, tooltipData, seriesKey, series])\n\n    return {\n        ...series,\n        isDefinedAccessor,\n        hasUndefinedSegments,\n        lineColor,\n        seriesPrefix,\n        currentDatum,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/charts/time-series/useTooltip.ts",
    "content": "import type { ChartContext, TooltipData, TooltipOptions, TSeriesDatumEnhanced } from './types'\nimport type { PointerEvent } from 'react'\n\nimport { localPoint } from '@visx/event'\nimport {\n    TooltipWithBounds,\n    useTooltip as useVisxTooltip,\n    useTooltipInPortal,\n    defaultStyles,\n} from '@visx/tooltip'\nimport { bisector } from 'd3-array'\nimport { useCallback } from 'react'\nimport { DefaultTooltip } from './DefaultTooltip'\n\nfunction getClosestDatum(data: TSeriesDatumEnhanced[], date: Date) {\n    const bisect = bisector<TSeriesDatumEnhanced, Date>((d) => d.dateJS).center\n    const datumIdx = bisect(data, date)\n\n    return data[datumIdx]\n}\n\n/* Wraps Visx tooltip hook with a default handler for all series */\nexport function useTooltip(\n    chartContext: Omit<ChartContext, 'tooltipOpen' | 'tooltipData' | 'tooltipLeft' | 'tooltipTop'>,\n    options?: TooltipOptions\n) {\n    const { height, width, margin, series, xScale, data: chartData, y1Scale } = chartContext\n\n    const visxTooltipInPortal = useTooltipInPortal({\n        scroll: true,\n        detectBounds: true,\n        debounce: 200,\n    })\n\n    const visxTooltip = useVisxTooltip<TooltipData>()\n\n    const tooltipHandler = useCallback(\n        (event: PointerEvent<HTMLDivElement>) => {\n            // Throw if tooltip config is invalid\n            if (\n                options &&\n                options.referenceSeriesKey &&\n                !series.find((s) => s.key === options.referenceSeriesKey)\n            ) {\n                throw new Error(\n                    'Invalid series key specified for tooltip positioning.  Make sure series is present in Chart props.'\n                )\n            }\n\n            // Bail early if not able to get mouse coordinates\n            const point = localPoint(event)\n            if (!point?.x || !point?.y) return\n            const { x, y } = point\n\n            const cursorDatetime = xScale.invert(x)\n\n            // Derived x and y tooltip coordinates\n            let cx: number | undefined\n            let cy: number | undefined\n\n            const datumByKey = series.reduce((prevTooltipDatum, currSeries) => {\n                const data = Array.isArray(chartData) ? chartData : chartData[currSeries.dataKey!]\n\n                const datum = getClosestDatum(data, cursorDatetime)\n\n                if (!datum) {\n                    return prevTooltipDatum\n                }\n\n                const datumYValue = currSeries.accessorFn(datum)\n                const datumYCoord = datumYValue != null ? y1Scale(datumYValue) : undefined\n\n                if (datumYCoord != null) {\n                    if (\n                        options?.referenceSeriesKey &&\n                        currSeries.key === options.referenceSeriesKey\n                    ) {\n                        cy = datumYCoord\n                    } else {\n                        const distanceFromCursor = Math.abs(y - datumYCoord)\n\n                        // If this is the closest coordinate so far, set it\n                        if (!cy || (cy && distanceFromCursor < cy)) {\n                            cy = datumYCoord\n                        }\n                    }\n                }\n\n                const datumXCoord = xScale(datum.dateJS)\n                if (!cx || (cx && datumXCoord < cx)) {\n                    cx = datumXCoord\n                }\n\n                return {\n                    date: datum.date,\n                    dateJS: datum.dateJS,\n                    series: {\n                        ...prevTooltipDatum.series,\n                        [currSeries.key]: {\n                            originalSeries: currSeries,\n                            originalDatum: datum,\n                            // Always return null values for inactive series\n                            value: currSeries.isActive ? datumYValue ?? null : null,\n                        },\n                    },\n                    values: [\n                        ...(prevTooltipDatum.values ?? []),\n\n                        // Always return null values for inactive series\n                        currSeries.isActive ? datumYValue ?? null : null,\n                    ],\n                }\n            }, {} as TooltipData)\n\n            // Tells us whether mouse is within chart and NOT hovering an axis\n            const isHoveringInnerChart =\n                y < height - margin.bottom &&\n                y > margin.top &&\n                x > margin.left &&\n                x < width - margin.right\n\n            if (isHoveringInnerChart && cx && cy) {\n                visxTooltip.showTooltip({\n                    tooltipLeft: cx,\n                    tooltipTop: cy,\n                    tooltipData: datumByKey,\n                })\n            } else {\n                visxTooltip.hideTooltip()\n            }\n        },\n        [visxTooltip, height, width, margin, chartData, options, series, xScale, y1Scale]\n    )\n\n    const TooltipWrapper = options?.renderInPortal\n        ? visxTooltipInPortal.TooltipInPortal\n        : TooltipWithBounds\n\n    return {\n        tooltipHandler,\n        TooltipWrapper,\n        DefaultTooltip,\n        tooltipPortalRef: visxTooltipInPortal.containerRef,\n        defaultStyles,\n        ...visxTooltip,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/dialogs/NonUSDDialog.tsx",
    "content": "import { Button, Dialog } from '@maybe-finance/design-system'\n\nexport interface NonUSDDialogProps {\n    isOpen: boolean\n    onClose: () => void\n}\n\nexport function NonUSDDialog({ isOpen, onClose }: NonUSDDialogProps) {\n    return (\n        <Dialog isOpen={isOpen} onClose={onClose}>\n            <Dialog.Title>Connection Aborted</Dialog.Title>\n            <Dialog.Content>\n                <p>\n                    Unfortunately, we&apos;re currently only supporting connections to USD accounts\n                    for the duration of this beta. Once we&apos;re out of beta, our goal is to\n                    support as many institutions, in as many different regions as possible.\n                </p>\n                <Button className=\"transform translate-y-8 mt-8\" fullWidth onClick={onClose}>\n                    Got it\n                </Button>\n            </Dialog.Content>\n        </Dialog>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/dialogs/index.ts",
    "content": "export * from './NonUSDDialog'\n"
  },
  {
    "path": "libs/client/shared/src/components/explainers/ExplainerExternalLink.tsx",
    "content": "import Link from 'next/link'\nimport type { ReactNode } from 'react'\nimport type { IconType } from 'react-icons'\n\nexport type ExplainerExternalLinkProps = {\n    icon: IconType\n    href: string\n    children: ReactNode\n}\n\nexport function ExplainerExternalLink({\n    icon: Icon,\n    href,\n    children,\n}: ExplainerExternalLinkProps): JSX.Element {\n    return (\n        <Link\n            href={href}\n            rel=\"noreferrer\"\n            target=\"_blank\"\n            className=\"flex gap-2 my-2 p-2 rounded-lg bg-gray-600 text-base hover:bg-gray-500 transition-color\"\n        >\n            <Icon className=\"shrink-0 w-6 h-6 text-gray-100\" />\n            <span className=\"text-gray-25\">{children}</span>\n        </Link>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/explainers/ExplainerInfoBlock.tsx",
    "content": "import type { ReactNode } from 'react'\n\nexport type ExplainerInfoBlockProps = {\n    title: string | ReactNode\n    children: ReactNode\n}\n\nexport function ExplainerInfoBlock({ title, children }: ExplainerInfoBlockProps): JSX.Element {\n    return (\n        <div className=\"my-3 py-2 px-3 rounded-lg bg-gray-600\">\n            <span className=\"font-medium text-sm text-gray-100 uppercase\">{title}</span>\n            <div className=\"mt-1 text-base text-gray-25\">{children}</div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/explainers/ExplainerPerformanceBlock.tsx",
    "content": "import classNames from 'classnames'\nimport type { ReactNode } from 'react'\n\nconst variants = {\n    teal: {\n        color: 'text-teal',\n        className: 'from-teal/[0.15]',\n    },\n    red: {\n        color: 'text-red',\n        className: 'from-red/[0.1]',\n    },\n    yellow: {\n        color: 'text-yellow',\n        className: 'from-yellow/[0.1]',\n    },\n}\n\nexport type ExplainerPerformanceBlockProps = {\n    variant: keyof typeof variants\n    children: ReactNode | ((color: string) => ReactNode)\n}\n\nexport function ExplainerPerformanceBlock({\n    variant,\n    children,\n}: ExplainerPerformanceBlockProps): JSX.Element {\n    return (\n        <div\n            className={classNames(\n                'mt-3 mb-5 p-3 rounded-lg text-white bg-gradient-to-b',\n                variants[variant].className\n            )}\n        >\n            {typeof children === 'function' ? children(variants[variant].color) : children}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/explainers/ExplainerSection.tsx",
    "content": "import { forwardRef } from 'react'\nimport type { ReactNode, Ref } from 'react'\nimport classNames from 'classnames'\n\nexport type ExplainerSectionProps = {\n    title: string | ReactNode\n    children: ReactNode\n    className?: string\n}\n\nfunction ExplainerSection(\n    { title, children, className }: ExplainerSectionProps,\n    ref: Ref<HTMLDivElement>\n): JSX.Element {\n    return (\n        <div className=\"pt-1 pb-5 text-base\" ref={ref}>\n            <h6 className=\"font-display font-bold uppercase\">{title}</h6>\n            <div className={classNames('mt-2 text-gray-50', className)}>{children}</div>\n        </div>\n    )\n}\n\nexport default forwardRef(ExplainerSection)\n"
  },
  {
    "path": "libs/client/shared/src/components/explainers/index.ts",
    "content": "export * from './ExplainerExternalLink'\nexport * from './ExplainerInfoBlock'\nexport * from './ExplainerPerformanceBlock'\nexport { default as ExplainerSection } from './ExplainerSection'\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/BoxIcon.tsx",
    "content": "import type { IconType } from 'react-icons'\n\nimport classNames from 'classnames'\n\nconst Size = {\n    sm: 'w-6 h-6 rounded-sm',\n    md: 'w-[36px] h-[36px] rounded-lg',\n    lg: 'w-12 h-12 rounded-xl',\n}\n\nconst Variant = {\n    cyan: 'bg-cyan text-cyan',\n    orange: 'bg-orange text-orange',\n    teal: 'bg-teal text-teal',\n    grape: 'bg-grape text-grape',\n    pink: 'bg-pink text-pink',\n    yellow: 'bg-yellow text-yellow',\n    blue: 'bg-blue text-blue',\n    red: 'bg-red text-red',\n    indigo: 'bg-indigo text-indigo',\n}\n\nexport type BoxIconVariant = keyof typeof Variant\nexport type BoxIconSize = keyof typeof Size\n\nexport type BoxIconProps = {\n    icon: IconType\n    variant?: BoxIconVariant\n    size?: BoxIconSize\n}\n\nexport function BoxIcon({ icon: Icon, size = 'lg', variant = 'cyan' }: BoxIconProps) {\n    return (\n        <div\n            className={classNames(\n                'flex items-center justify-center bg-opacity-10 shrink-0',\n                Variant[variant],\n                Size[size]\n            )}\n        >\n            <Icon className={size === 'lg' ? 'w-6 h-6' : 'w-4 h-4'} />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/Confetti.tsx",
    "content": "import React, { useRef, useEffect, useCallback, memo } from 'react'\nimport ReactDOM from 'react-dom'\nimport classNames from 'classnames'\nimport { useFrame } from '../../hooks/useFrame'\n\nexport type Particle = {\n    color: string\n    x: number\n    y: number\n    width: number\n    height: number\n    scaleY: number\n    rotation: number\n    velocityX: number\n    velocityY: number\n}\n\nexport type ConfettiProps = {\n    /** Number of confetti particles */\n    amount?: number\n\n    /** Whether to continue respawning particles at the top of the screen */\n    respawn?: boolean\n\n    /** Array of particle colors */\n    colors?: string[]\n\n    /** Amount of resistance slowing down horizontal velocity */\n    drag?: number\n\n    /** Rate at which vertical velocity increases */\n    gravity?: number\n\n    /** Amount of back-and-forth horizontal motion */\n    sway?: number\n\n    /** Rate at which particles rotate along their x-axes */\n    flutter?: number\n\n    /** Whether to randomly rotate particles along their z-axes */\n    rotate?: boolean\n\n    extendGenerateParticle?: (particle: Particle, context: CanvasRenderingContext2D) => Particle\n    renderParticle?: (particle: Particle, context: CanvasRenderingContext2D) => void\n    resizeDebounceTimeout?: number\n    className?: string\n}\n\nfunction ConfettiBase({\n    amount = 100,\n    respawn = true,\n    colors = ['#4cc9f0', '#4361ee', '#7209b7', '#f12980'],\n    drag = 0.05,\n    gravity = 0.015,\n    sway = 1,\n    flutter = 0.05,\n    rotate = true,\n    extendGenerateParticle,\n    renderParticle,\n    resizeDebounceTimeout = 250,\n    className,\n    ...rest\n}: ConfettiProps): JSX.Element {\n    const canvasRef = useRef<HTMLCanvasElement>(null)\n\n    const particles = useRef<Particle[]>([])\n\n    const generateParticle = useCallback(\n        (context: CanvasRenderingContext2D): Particle => {\n            const particle = {\n                color: colors[Math.floor(Math.random() * colors.length)],\n                x: Math.random() * context.canvas.width,\n                y:\n                    gravity > 0\n                        ? -Math.random() * context.canvas.height\n                        : Math.random() * context.canvas.height + context.canvas.height,\n                width: Math.random() * 10 + 5,\n                height: Math.random() * 10 + 5,\n                scaleY: 1,\n                rotation: rotate ? Math.random() * 2 * Math.PI : 0,\n                velocityX: Math.random() * sway * 50 - sway * 25,\n                velocityY: Math.random() * gravity * 500,\n            }\n\n            return extendGenerateParticle ? extendGenerateParticle(particle, context) : particle\n        },\n        [colors, gravity, rotate, sway, extendGenerateParticle]\n    )\n\n    const generateParticles = useCallback(\n        (context: CanvasRenderingContext2D, amount: number): Particle[] => {\n            const particles: Particle[] = []\n\n            for (let i = 0; i < amount; ++i) {\n                particles.push(generateParticle(context))\n            }\n\n            return particles\n        },\n        [generateParticle]\n    )\n\n    useFrame((_, rawDeltaTime) => {\n        const context = canvasRef.current?.getContext('2d')\n        if (!context) return\n\n        const deltaTime = rawDeltaTime / 16.7 // Normalize to 60 FPS\n\n        context.clearRect(0, 0, context.canvas.width, context.canvas.height)\n        particles.current = particles.current.map((particle) => {\n            // Slow down x-velocity over time\n            particle.velocityX -= particle.velocityX * drag * deltaTime\n\n            // Add randomly positive/negative value to make the particle sway\n            particle.velocityX += (Math.random() > 0.5 ? 1 : -1) * Math.random() * sway * deltaTime\n\n            // Increase y-velocity with gravity\n            particle.velocityY += gravity * deltaTime\n\n            // Spin/flutter particle as it falls\n            particle.scaleY = Math.cos(particle.y * flutter * deltaTime)\n\n            particle.x += particle.velocityX * deltaTime\n            particle.y += particle.velocityY * deltaTime\n\n            const width = particle.width,\n                height = particle.height * particle.scaleY\n\n            context.save()\n            context.translate(particle.x, particle.y)\n            context.rotate(particle.rotation)\n            context.fillStyle = particle.color\n            renderParticle\n                ? renderParticle(particle, context)\n                : context.fillRect(-width / 2, -height / 2, width, height)\n            context.restore()\n\n            const buffer = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2)) / 2\n            const outOfBounds =\n                gravity > 0 ? particle.y - buffer > context.canvas.height : particle.y + buffer < 0\n\n            return respawn && outOfBounds ? generateParticle(context) : particle\n        })\n    })\n\n    const updateCanvasSize = React.useCallback(() => {\n        if (canvasRef.current) {\n            canvasRef.current.width = window.innerWidth\n            canvasRef.current.height = window.innerHeight\n        }\n    }, [])\n\n    // Keep canvas size updated with window\n    useEffect(() => {\n        updateCanvasSize()\n\n        let timeout: number | null = null\n        const handleResize = () => {\n            timeout !== null && window.clearTimeout(timeout)\n            timeout = window.setTimeout(updateCanvasSize, resizeDebounceTimeout)\n        }\n\n        window.addEventListener('resize', handleResize)\n        return () => window.removeEventListener('resize', handleResize)\n    }, [resizeDebounceTimeout, updateCanvasSize])\n\n    // Generate particles\n    useEffect(() => {\n        if (canvasRef.current) {\n            const context = canvasRef.current?.getContext('2d')\n            if (context) particles.current = generateParticles(context, amount)\n        }\n    }, [amount, generateParticles])\n\n    return ReactDOM.createPortal(\n        <canvas\n            ref={canvasRef}\n            className={classNames(\n                'z-50 fixed inset-0 w-screen h-screen pointer-events-none',\n                className\n            )}\n            {...rest}\n        ></canvas>,\n        document.body\n    )\n}\n\nexport const Confetti = memo(ConfettiBase)\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/InfiniteScroll.tsx",
    "content": "import type { FC, PropsWithChildren } from 'react'\nimport InfiniteScroll from 'react-infinite-scroller'\n\n/**\n * The react-infinite-scroller package is no longer actively maintained and does\n * not expose these props, so restating them here and wrapping the component in a\n * React 18 type-safe way.\n *\n * @see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/56210\n */\nexport type InfiniteScrollProps = {\n    /**\n     * Name of the element that the component should render as.\n     * Defaults to 'div'.\n     */\n    element?: React.ReactNode | string | undefined\n    /**\n     * Whether there are more items to be loaded. Event listeners are removed if false.\n     * Defaults to false.\n     */\n    hasMore?: boolean | undefined\n    /**\n     * Whether the component should load the first set of items.\n     * Defaults to true.\n     */\n    initialLoad?: boolean | undefined\n    /**\n     * Whether new items should be loaded when user scrolls to the top of the scrollable area.\n     * Default to false.\n     */\n    isReverse?: boolean | undefined\n    /**\n     * A callback for when more items are requested by the user.\n     * Page param is next page index.\n     */\n    loadMore(page: number): void\n    /**\n     * The number of the first page to load, with the default of 0, the first page is 1.\n     * Defaults to 0.\n     */\n    pageStart?: number | undefined\n    /**\n     * The distance in pixels before the end of the items that will trigger a call to loadMore.\n     * Defaults to 250.\n     */\n    threshold?: number | undefined\n    /**\n     * Proxy to the useCapture option of the added event listeners.\n     * Defaults to false.\n     */\n    useCapture?: boolean | undefined\n    /**\n     * Add scroll listeners to the window, or else, the component's parentNode.\n     * Defaults to true.\n     */\n    useWindow?: boolean | undefined\n    /**\n     * Loader component for indicating \"loading more\".\n     */\n    loader?: React.ReactElement | undefined\n    /**\n     * Override method to return a different scroll listener if it's not the immediate parent of InfiniteScroll.\n     */\n    getScrollParent?(): HTMLElement | null\n}\n\nexport default InfiniteScroll as unknown as FC<PropsWithChildren<InfiniteScrollProps>>\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/InsightGroup.tsx",
    "content": "import type { PropsWithChildren, ReactNode } from 'react'\nimport type { ClientType } from '../..'\nimport { useLocalStorage } from '../..'\n\nimport { createContext, useMemo, useContext } from 'react'\nimport { Badge, Listbox, LoadingPlaceholder, Tooltip } from '@maybe-finance/design-system'\nimport { RiQuestionLine, RiSettings4Line } from 'react-icons/ri'\nimport { AnimatePresence, motion } from 'framer-motion'\nimport groupBy from 'lodash/groupBy'\nimport random from 'lodash/random'\nimport classNames from 'classnames'\n\nexport type InsightCardOption = {\n    id: string\n    display: string\n    category: string\n    tooltip: string\n}\n\nconst InsightGroupContext = createContext<{ selectedInsights: InsightCardOption[] } | undefined>(\n    undefined\n)\n\nexport type InsightGroupProps = PropsWithChildren<{\n    id: string\n    options: InsightCardOption[]\n    initialInsights: InsightCardOption['id'][]\n}>\n\nfunction InsightGroup({ id, options, initialInsights, children }: InsightGroupProps) {\n    const [selectedInsights, setSelectedInsights] = useLocalStorage(\n        `SELECTED_INSIGHTS_GROUP_${id}`,\n        initialInsights\n    )\n\n    /**\n     * Returns grouped arrays of insight cards\n     *\n     * ['key', [<InsightCard1 />, <InsightCard2 />]]\n     */\n    const insights = useMemo<[string, InsightCardOption[]][]>(() => {\n        const groups: { [key: string]: InsightCardOption[] } = groupBy(options, 'category')\n        return Object.entries(groups)\n    }, [options])\n\n    return (\n        <InsightGroupContext.Provider\n            value={{ selectedInsights: options.filter((o) => selectedInsights.includes(o.id)) }}\n        >\n            <div className=\"mb-8\">\n                <div className=\"flex items-center justify-between mb-4\">\n                    <h5 className=\"uppercase\">highlights</h5>\n                    <Listbox value={selectedInsights} onChange={setSelectedInsights} multiple>\n                        <Listbox.Button icon={RiSettings4Line} hideRightIcon>\n                            <span className=\"text-white text-base font-medium\">Customize</span>\n                        </Listbox.Button>\n                        <Listbox.Options placement=\"bottom-end\" className=\"min-w-[210px]\">\n                            {insights.map(([category, insights]) => (\n                                <div key={category}>\n                                    <span className=\"text-sm text-gray-100 font-medium inline-block\">\n                                        {category}\n                                    </span>\n                                    {insights.map((insight) => (\n                                        <Listbox.Option\n                                            key={insight.id}\n                                            value={insight.id}\n                                            className=\"my-2\"\n                                        >\n                                            {insight.display}\n                                        </Listbox.Option>\n                                    ))}\n                                </div>\n                            ))}\n                        </Listbox.Options>\n                    </Listbox>\n                </div>\n                {selectedInsights.length ? (\n                    <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3\">\n                        {children}\n                    </div>\n                ) : (\n                    <p className=\"text-gray-100\">Please select insights to show</p>\n                )}\n            </div>\n        </InsightGroupContext.Provider>\n    )\n}\n\ntype InsightCardProps = PropsWithChildren<{\n    id: string\n    isLoading: boolean\n    status?: ClientType.MetricStatus\n    headerRight?: ReactNode\n    placeholder?: ReactNode\n    onClick?: () => void\n}>\n\nfunction Card({\n    id,\n    children,\n    isLoading,\n    status,\n    headerRight,\n    placeholder,\n    onClick,\n}: InsightCardProps) {\n    const ctx = useContext(InsightGroupContext)\n\n    if (!ctx) throw new Error('Must use Insight Card within group')\n\n    const card = useMemo(() => ctx.selectedInsights.find((insight) => insight.id === id), [ctx, id])\n\n    return (\n        <AnimatePresence>\n            {card && (\n                <motion.div\n                    layout\n                    initial={{ opacity: 0 }}\n                    animate={{ opacity: 1 }}\n                    exit={{ opacity: 0 }}\n                    transition={{ duration: 0.2 }}\n                    className={classNames(\n                        'flex flex-col h-[140px] gap-4 p-4 bg-gray-800 rounded-lg shadow-md',\n                        onClick && status === 'active' && 'hover:bg-gray-700 cursor-pointer'\n                    )}\n                    onClick={status === 'active' ? onClick : undefined}\n                >\n                    <div className=\"h-8 flex items-center\">\n                        <p className=\"text-base text-gray-100\">{card.display}</p>\n                        <Tooltip\n                            content={<div className=\"text-base text-gray-50\">{card.tooltip}</div>}\n                            className=\"max-w-[350px]\"\n                        >\n                            <span>\n                                <RiQuestionLine className=\"w-5 h-5 text-gray-50 mx-1.5\" />\n                            </span>\n                        </Tooltip>\n                        <div className=\"ml-auto whitespace-nowrap\">\n                            {status === 'under-construction' ? (\n                                <Badge children=\"Unavailable\" variant=\"gray\" />\n                            ) : status === 'coming-soon' ? (\n                                <Badge children=\"Soon\" variant=\"gray\" />\n                            ) : (\n                                headerRight\n                            )}\n                        </div>\n                    </div>\n                    <div className=\"grow\">\n                        {!status || status === 'under-construction' ? (\n                            <p className=\"text-gray-100 text-base\">\n                                We're currently fixing this to make sure we show you accurate\n                                figures.\n                            </p>\n                        ) : status === 'active' && !isLoading ? (\n                            children\n                        ) : (\n                            <LoadingPlaceholder isLoading={isLoading}>\n                                <div className=\"relative h-full\">\n                                    <div className=\"absolute inset-0 bg-gray-800 bg-opacity-70 backdrop-blur-sm\" />\n\n                                    <div className=\"ml-0.5\">\n                                        {placeholder ? (\n                                            placeholder\n                                        ) : (\n                                            <>\n                                                <h3>{random(10, 100, true).toFixed(2)}</h3>\n                                                <p>Placeholder subtext overlay</p>\n                                            </>\n                                        )}\n                                    </div>\n                                </div>\n                            </LoadingPlaceholder>\n                        )}\n                    </div>\n                </motion.div>\n            )}\n        </AnimatePresence>\n    )\n}\n\nexport default Object.assign(InsightGroup, { Card })\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/InsightPopout.tsx",
    "content": "import { Button } from '@maybe-finance/design-system'\nimport type { ReactNode } from 'react'\nimport { RiCloseFill } from 'react-icons/ri'\nimport { usePopoutContext } from '../../providers'\n\nexport type InsightPopoutProps = {\n    children: ReactNode\n}\n\nexport function InsightPopout({ children }: InsightPopoutProps) {\n    const { close } = usePopoutContext()\n\n    return (\n        <div className=\"flex flex-col h-full overflow-hidden w-full lg:w-96\">\n            <div className=\"p-4\">\n                <Button variant=\"icon\" title=\"Close\" onClick={close}>\n                    <RiCloseFill className=\"w-6 h-6\" />\n                </Button>\n            </div>\n            <div className=\"grow\">{children}</div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/ProfileCircle.tsx",
    "content": "import classNames from 'classnames'\nimport { useUserApi } from '../../api'\n\nconst getProfileInitials = (nameString: string): string => {\n    const parts = nameString.split(' ')\n    if (parts.length === 1) {\n        return parts[0].charAt(0)\n    }\n\n    return parts[0].charAt(0) + parts[1].charAt(0)\n}\n\nexport type ProfileCircleProps = {\n    interactive?: boolean\n    className?: string\n}\n\nexport function ProfileCircle({ interactive = true, className }: ProfileCircleProps) {\n    const { useProfile } = useUserApi()\n    const profile = useProfile()\n\n    const firstName = profile.data?.firstName\n    const lastName = profile.data?.lastName\n    const name = !firstName ? lastName : !lastName ? firstName : `${firstName} ${lastName}`\n\n    return (\n        <div\n            className={classNames(\n                'flex items-center justify-center w-12 h-12 text-base font-semibold rounded-full text-cyan bg-cyan bg-opacity-10',\n                interactive && 'hover:bg-opacity-20 cursor-pointer',\n                className\n            )}\n        >\n            {getProfileInitials(name ?? 'M')}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/RelativeTime.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport { DateTime } from 'luxon'\n\nexport default function RelativeTime({ time }: { time: Date | DateTime }) {\n    const dateTime = DateTime.isDateTime(time) ? time : DateTime.fromJSDate(time)\n\n    const [now, setNow] = useState(DateTime.now())\n\n    useEffect(() => {\n        const interval = setInterval(() => setNow(DateTime.now()), 60_000)\n        return () => clearInterval(interval)\n    }, [])\n\n    return (\n        <time dateTime={dateTime.toISO()} title={dateTime.toLocaleString(DateTime.DATETIME_FULL)}>\n            {Math.abs(dateTime.diff(now, 'minutes').as('minutes')) < 1\n                ? 'Just now'\n                : dateTime.toRelative({ base: now })}\n        </time>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/TakeoverBackground.tsx",
    "content": "export function TakeoverBackground({ className }: { className: string }) {\n    return (\n        <svg\n            className={className}\n            width=\"2601\"\n            height=\"665\"\n            viewBox=\"0 0 2601 665\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n        >\n            <g clipPath=\"url(#clip0_15_32)\">\n                <g opacity=\"0.3\">\n                    <mask\n                        id=\"mask0_15_32\"\n                        style={{ maskType: 'alpha' }}\n                        maskUnits=\"userSpaceOnUse\"\n                        x=\"0\"\n                        y=\"11\"\n                        width=\"2601\"\n                        height=\"456\"\n                    >\n                        <rect\n                            y=\"11.5703\"\n                            width=\"2601\"\n                            height=\"455\"\n                            fill=\"url(#paint0_linear_15_32)\"\n                        />\n                    </mask>\n                    <g mask=\"url(#mask0_15_32)\">\n                        <g filter=\"url(#filter0_f_15_32)\">\n                            <rect\n                                x=\"31.4849\"\n                                y=\"369.57\"\n                                width=\"2621.99\"\n                                height=\"97\"\n                                fill=\"url(#paint1_linear_15_32)\"\n                            />\n                        </g>\n                    </g>\n                </g>\n                <path\n                    d=\"M1073.5 198.5L1043.5 144H1142L1162 199L1073.5 198.5Z\"\n                    fill=\"url(#paint2_linear_15_32)\"\n                />\n                <path\n                    d=\"M1515 146L1539.5 80H1649L1612.5 147L1515 146Z\"\n                    fill=\"url(#paint3_linear_15_32)\"\n                />\n                <path\n                    d=\"M1684.5 275.5L1718 238L1799.5 238.5L1758.5 276L1684.5 275.5Z\"\n                    fill=\"url(#paint4_linear_15_32)\"\n                />\n                <path d=\"M794 81L731.5 12H852L901 80L794 81Z\" fill=\"url(#paint5_linear_15_32)\" />\n                <path\n                    d=\"M771.5 411.56L1329 664.057M1329 664.057L771.5 284.927M1329 664.057L1886.5 411.56M1329 664.057L1886.5 284.927M771.888 388.781L1329 664.056M771.5 361.372L1329 664.059M1329 664.059L771.5 327.396M1329 664.059L771.5 230.878M1329 664.059L771.5 158.293M1329 664.059L771.5 56.3675M1329 664.059L842.539 0M1329 664.059L1333.63 0M1329 664.059L1886.5 361.372M1329 664.059L1886.5 327.396M1329 664.059L1886.5 230.878M1329 664.059L1886.5 158.293M1329 664.059L1886.5 56.3675M966.085 0L1329 664.058M1329 664.058L1088.86 0M1329 664.058L1446.37 0M1211.63 0L1329 664.058M1329 664.058L1815.46 0M1329 664.058L1691.91 0M1329 664.058L1569.14 0M1886.11 388.781L1329 664.056M771.5 81.0771H1883.41M771.5 145.166H1883.41M771.5 197.673H1883.41M771.5 239.371H1883.41M771.5 274.889H1883.41M771.5 304.233H1883.41M771.5 329.712H1883.41M771.5 352.105H1883.41M771.5 372.181H1883.41M771.5 389.169H1883.41M771.5 403.839H1883.41M771.5 417.737H1883.41\"\n                    stroke=\"url(#paint6_radial_15_32)\"\n                />\n                <path\n                    d=\"M850.5 12L1303.8 630.795\"\n                    stroke=\"url(#paint7_linear_15_32)\"\n                    strokeWidth=\"2.15\"\n                />\n                <path\n                    d=\"M1806.8 12L1353.5 630.795\"\n                    stroke=\"url(#paint8_linear_15_32)\"\n                    strokeWidth=\"2.15\"\n                />\n                <path\n                    d=\"M803.5 146L1839.62 146\"\n                    stroke=\"url(#paint9_linear_15_32)\"\n                    strokeWidth=\"2.15\"\n                />\n                <path\n                    d=\"M803.5 80L1839.62 80\"\n                    stroke=\"url(#paint10_linear_15_32)\"\n                    strokeWidth=\"2.15\"\n                />\n                <g filter=\"url(#filter1_f_15_32)\">\n                    <path\n                        d=\"M879.676 299.517C1028.4 299.517 1196.19 304.586 1335.2 281.886C1433.21 265.88 1499.92 231.904 1504.81 193.166C1505.74 185.728 1498.53 178.159 1500.42 170.788C1501.2 167.718 1508.12 164.969 1513.11 162.763C1530.7 154.994 1550.02 147.755 1567.82 140.047C1590.71 130.131 1614 120.187 1623.94 107.61C1633.79 95.1433 1646.75 83.832 1665.63 72.687C1694.23 55.8104 1735.83 43.3107 1779.75 32\"\n                        stroke=\"url(#paint11_linear_15_32)\"\n                        strokeWidth=\"9\"\n                        strokeLinecap=\"round\"\n                    />\n                </g>\n            </g>\n            <defs>\n                <filter\n                    id=\"filter0_f_15_32\"\n                    x=\"-244.515\"\n                    y=\"93.5703\"\n                    width=\"3173.99\"\n                    height=\"649\"\n                    filterUnits=\"userSpaceOnUse\"\n                    colorInterpolationFilters=\"sRGB\"\n                >\n                    <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                    <feBlend\n                        mode=\"normal\"\n                        in=\"SourceGraphic\"\n                        in2=\"BackgroundImageFix\"\n                        result=\"shape\"\n                    />\n                    <feGaussianBlur stdDeviation=\"138\" result=\"effect1_foregroundBlur_15_32\" />\n                </filter>\n                <filter\n                    id=\"filter1_f_15_32\"\n                    x=\"787.176\"\n                    y=\"-60.5011\"\n                    width=\"1085.08\"\n                    height=\"453.003\"\n                    filterUnits=\"userSpaceOnUse\"\n                    colorInterpolationFilters=\"sRGB\"\n                >\n                    <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n                    <feBlend\n                        mode=\"normal\"\n                        in=\"SourceGraphic\"\n                        in2=\"BackgroundImageFix\"\n                        result=\"shape\"\n                    />\n                    <feGaussianBlur stdDeviation=\"44\" result=\"effect1_foregroundBlur_15_32\" />\n                </filter>\n                <linearGradient\n                    id=\"paint0_linear_15_32\"\n                    x1=\"1280.57\"\n                    y1=\"350.045\"\n                    x2=\"1280.57\"\n                    y2=\"487.255\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#D9D9D9\" />\n                    <stop offset=\"0.0666667\" stopColor=\"#D9D9D9\" stopOpacity=\"0.991353\" />\n                    <stop offset=\"0.133333\" stopColor=\"#D9D9D9\" stopOpacity=\"0.96449\" />\n                    <stop offset=\"0.2\" stopColor=\"#D9D9D9\" stopOpacity=\"0.91834\" />\n                    <stop offset=\"0.266667\" stopColor=\"#D9D9D9\" stopOpacity=\"0.852589\" />\n                    <stop offset=\"0.333333\" stopColor=\"#D9D9D9\" stopOpacity=\"0.768225\" />\n                    <stop offset=\"0.4\" stopColor=\"#D9D9D9\" stopOpacity=\"0.668116\" />\n                    <stop offset=\"0.466667\" stopColor=\"#D9D9D9\" stopOpacity=\"0.557309\" />\n                    <stop offset=\"0.533333\" stopColor=\"#D9D9D9\" stopOpacity=\"0.442691\" />\n                    <stop offset=\"0.6\" stopColor=\"#D9D9D9\" stopOpacity=\"0.331884\" />\n                    <stop offset=\"0.666667\" stopColor=\"#D9D9D9\" stopOpacity=\"0.231775\" />\n                    <stop offset=\"0.733333\" stopColor=\"#D9D9D9\" stopOpacity=\"0.147411\" />\n                    <stop offset=\"0.8\" stopColor=\"#D9D9D9\" stopOpacity=\"0.0816599\" />\n                    <stop offset=\"0.866667\" stopColor=\"#D9D9D9\" stopOpacity=\"0.03551\" />\n                    <stop offset=\"0.933333\" stopColor=\"#D9D9D9\" stopOpacity=\"0.01\" />\n                    <stop offset=\"1\" stopColor=\"#D9D9D9\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint1_linear_15_32\"\n                    x1=\"2441.11\"\n                    y1=\"427.014\"\n                    x2=\"560.007\"\n                    y2=\"1142.08\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#F72585\" />\n                    <stop offset=\"0.299586\" stopColor=\"#7209B7\" />\n                    <stop offset=\"0.648552\" stopColor=\"#4361EE\" />\n                    <stop offset=\"1\" stopColor=\"#4CC9F0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint2_linear_15_32\"\n                    x1=\"1098.5\"\n                    y1=\"141\"\n                    x2=\"1103\"\n                    y2=\"198\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#1C1C20\" />\n                    <stop offset=\"1\" stopColor=\"#1C1C20\" stopOpacity=\"0\" />\n                    <stop offset=\"1\" stopColor=\"#1C1C20\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint3_linear_15_32\"\n                    x1=\"1594.5\"\n                    y1=\"77\"\n                    x2=\"1599\"\n                    y2=\"134\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#1C1C20\" />\n                    <stop offset=\"1\" stopColor=\"#1C1C20\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint4_linear_15_32\"\n                    x1=\"1773\"\n                    y1=\"235\"\n                    x2=\"1777.5\"\n                    y2=\"292\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#1C1C20\" />\n                    <stop offset=\"1\" stopColor=\"#1C1C20\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint5_linear_15_32\"\n                    x1=\"827\"\n                    y1=\"73.5\"\n                    x2=\"816\"\n                    y2=\"18\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#1C1C20\" />\n                    <stop offset=\"1\" stopColor=\"#1C1C20\" stopOpacity=\"0\" />\n                </linearGradient>\n                <radialGradient\n                    id=\"paint6_radial_15_32\"\n                    cx=\"0\"\n                    cy=\"0\"\n                    r=\"1\"\n                    gradientUnits=\"userSpaceOnUse\"\n                    gradientTransform=\"translate(1329 208.57) rotate(90) scale(333.5 571.143)\"\n                >\n                    <stop stopColor=\"#1C1C20\" />\n                    <stop offset=\"1\" stopColor=\"#34363C\" stopOpacity=\"0\" />\n                </radialGradient>\n                <linearGradient\n                    id=\"paint7_linear_15_32\"\n                    x1=\"1042.35\"\n                    y1=\"272.615\"\n                    x2=\"912.699\"\n                    y2=\"101.847\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#3BC9DB\" stopOpacity=\"0\" />\n                    <stop offset=\"0.424496\" stopColor=\"#3BC9DB\" />\n                    <stop offset=\"1\" stopColor=\"#3BC9DB\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint8_linear_15_32\"\n                    x1=\"1567.5\"\n                    y1=\"342.998\"\n                    x2=\"1712.8\"\n                    y2=\"139.838\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#49136F\" stopOpacity=\"0\" />\n                    <stop offset=\"0.424496\" stopColor=\"#7D0FC5\" />\n                    <stop offset=\"1\" stopColor=\"#49136F\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint9_linear_15_32\"\n                    x1=\"1397.5\"\n                    y1=\"145.598\"\n                    x2=\"1671.23\"\n                    y2=\"145.598\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#F52685\" stopOpacity=\"0\" />\n                    <stop offset=\"0.424496\" stopColor=\"#F52685\" />\n                    <stop offset=\"1\" stopColor=\"#F52685\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint10_linear_15_32\"\n                    x1=\"925\"\n                    y1=\"79.9999\"\n                    x2=\"1268\"\n                    y2=\"79.5982\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#0673F2\" stopOpacity=\"0\" />\n                    <stop offset=\"0.424496\" stopColor=\"#0673F2\" />\n                    <stop offset=\"1\" stopColor=\"#0673F2\" stopOpacity=\"0\" />\n                </linearGradient>\n                <linearGradient\n                    id=\"paint11_linear_15_32\"\n                    x1=\"937.806\"\n                    y1=\"275.71\"\n                    x2=\"1785.34\"\n                    y2=\"31.8395\"\n                    gradientUnits=\"userSpaceOnUse\"\n                >\n                    <stop stopColor=\"#4CC9F0\" />\n                    <stop offset=\"0.28684\" stopColor=\"#4361EE\" />\n                    <stop offset=\"0.679646\" stopColor=\"#7209B7\" />\n                    <stop offset=\"0.83892\" stopColor=\"#F72585\" />\n                </linearGradient>\n                <clipPath id=\"clip0_15_32\">\n                    <rect width=\"2601\" height=\"664.059\" fill=\"white\" />\n                </clipPath>\n            </defs>\n        </svg>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/Toaster.tsx",
    "content": "import type { ToastVariant } from '@maybe-finance/design-system'\nimport { Toast } from '@maybe-finance/design-system'\nimport { Toaster as ReactToaster, resolveValue } from 'react-hot-toast'\nimport classNames from 'classnames'\n\nconst toastTypeMap: { [type: string]: ToastVariant } = Object.freeze({\n    success: 'success',\n    error: 'error',\n})\n\nexport interface ToasterProps {\n    mobile: boolean\n    sidebarOffset?: string\n}\n\nexport function Toaster({ mobile, sidebarOffset }: ToasterProps) {\n    return (\n        <ReactToaster\n            position=\"bottom-center\"\n            toastOptions={{\n                duration: 6000,\n            }}\n            containerClassName={classNames(mobile && 'mb-16')}\n        >\n            {(toastData) => (\n                <Toast\n                    variant={toastData.type in toastTypeMap ? toastTypeMap[toastData.type] : 'info'}\n                    className={classNames(\n                        'max-w-[320px] transition-opacity',\n                        toastData.visible ? 'animate-appearUp' : 'opacity-0',\n                        mobile ? 'w-full' : sidebarOffset\n                    )}\n                >\n                    {resolveValue(toastData.message, toastData)}\n                </Toast>\n            )}\n        </ReactToaster>\n    )\n}\n\nexport default Toaster\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/TrendBadge.tsx",
    "content": "import { Badge, type BadgeVariant as BadgeVariantType } from '@maybe-finance/design-system'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport cn from 'classnames'\n\ntype TrendBadgeProps = {\n    trend: SharedType.Trend\n    negative?: boolean\n    badgeSize?: 'sm' | 'md'\n    amountSize?: 'sm' | 'md'\n    label?: string\n    displayAmount?: boolean\n}\n\nconst BadgeVariant = (\n    negative: boolean\n): Record<SharedType.Trend['direction'], BadgeVariantType> => ({\n    down: negative ? 'teal' : 'red',\n    up: negative ? 'red' : 'teal',\n    flat: 'gray',\n})\n\nconst AmountVariant = (negative: boolean): Record<SharedType.Trend['direction'], string> => ({\n    down: negative ? 'text-teal' : 'text-red',\n    up: negative ? 'text-red' : 'text-teal',\n    flat: 'text-white',\n})\n\nconst AmountSizeVariant = Object.freeze({\n    sm: 'text-sm',\n    md: 'text-base',\n})\n\nexport function TrendBadge({\n    trend,\n    negative = false,\n    label,\n    badgeSize = 'md',\n    amountSize,\n    displayAmount = false,\n}: TrendBadgeProps) {\n    return (\n        <div className=\"flex items-center space-x-2\">\n            <Badge variant={BadgeVariant(negative)[trend.direction]} size={badgeSize}>\n                {NumberUtil.format(trend.percentage, 'percent', {\n                    maximumFractionDigits: 2,\n                })}\n            </Badge>\n\n            {displayAmount && (\n                <span\n                    className={cn(\n                        AmountVariant(negative)[trend.direction],\n                        AmountSizeVariant[amountSize ?? badgeSize]\n                    )}\n                >\n                    {NumberUtil.format(trend.amount, 'currency', {\n                        minimumFractionDigits: 2,\n                        maximumFractionDigits: 2,\n                        signDisplay: 'exceptZero',\n                    })}\n                </span>\n            )}\n\n            {label && <span className={AmountSizeVariant[amountSize ?? badgeSize]}>{label}</span>}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/index.ts",
    "content": "export * from './TrendBadge'\nexport * from './Toaster'\nexport * from './BoxIcon'\nexport * from './small-decimals'\nexport * from './InsightPopout'\nexport * from './TakeoverBackground'\nexport * from './ProfileCircle'\nexport * from './Confetti'\nexport type { InsightCardOption } from './InsightGroup'\nexport { default as InsightGroup } from './InsightGroup'\nexport { default as RelativeTime } from './RelativeTime'\nexport type { InfiniteScrollProps } from './InfiniteScroll'\nexport { default as InfiniteScroll } from './InfiniteScroll'\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/small-decimals/SmallDecimals.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport SmallDecimals from './SmallDecimals'\n\ndescribe('SmallDecimals', () => {\n    describe('when using the correct value format', () => {\n        it('formats decimals', () => {\n            render(<SmallDecimals value=\"$123,456.13\" />)\n\n            expect(screen.getByTestId('decimals').textContent).toBe('.13')\n        })\n    })\n    describe('when not using the correct value format', () => {\n        it('does not apply decimals formatting but render value #1', () => {\n            render(<SmallDecimals value=\"something\" />)\n\n            expect(screen.queryByTestId('decimals')).toBeFalsy()\n            expect(screen.getByText('something')).toBeTruthy()\n        })\n\n        it('does not apply decimals formatting but render value #2', () => {\n            render(<SmallDecimals value=\"100\" />)\n\n            expect(screen.queryByTestId('decimals')).toBeFalsy()\n            expect(screen.getByText('100')).toBeTruthy()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/small-decimals/SmallDecimals.tsx",
    "content": "type SmallDecimalsProps = {\n    value?: string\n    className?: string\n}\n\nexport const SmallDecimals = ({\n    value = '',\n    className = 'text-gray-100 text-2xl',\n}: SmallDecimalsProps): JSX.Element => {\n    const isValidValue = /(\\.\\d\\d)$/.test(value)\n\n    if (!isValidValue) {\n        return (\n            <>\n                {value}\n                <span></span>\n            </>\n        )\n    }\n\n    const [integer, decimal] = value.split('.')\n\n    return (\n        <>\n            {integer}\n            <span className={className} data-testid=\"decimals\">\n                .{decimal}\n            </span>\n        </>\n    )\n}\n\nexport default SmallDecimals\n"
  },
  {
    "path": "libs/client/shared/src/components/generic/small-decimals/index.ts",
    "content": "export * from './SmallDecimals'\n"
  },
  {
    "path": "libs/client/shared/src/components/index.ts",
    "content": "export * from './overlays'\nexport * from './generic'\nexport * from './dialogs'\nexport * from './loaders'\nexport * from './charts'\nexport * from './tables'\nexport * from './explainers'\nexport * from './cards'\n"
  },
  {
    "path": "libs/client/shared/src/components/loaders/MainContentLoader.tsx",
    "content": "import { LoadingSpinner } from '@maybe-finance/design-system'\nimport { Overlay } from '../overlays'\n\nexport interface MainContentLoaderProps {\n    message?: string\n}\n\nexport function MainContentLoader({ message }: MainContentLoaderProps) {\n    return (\n        <Overlay>\n            <div className=\"absolute inset-0 flex flex-col items-center justify-center h-full transform -translate-y-16\">\n                <LoadingSpinner />\n                {message && <p className=\"text-gray-50 text-base mt-2\">{message}</p>}\n            </div>\n        </Overlay>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/loaders/index.ts",
    "content": "export * from './MainContentLoader'\n"
  },
  {
    "path": "libs/client/shared/src/components/overlays/BlurredContentOverlay.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport { motion } from 'framer-motion'\nimport type { IconType } from 'react-icons'\nimport { Overlay } from './Overlay'\nimport classNames from 'classnames'\n\nexport type BlurredContentOverlayProps = PropsWithChildren<{\n    title: string\n    icon?: IconType\n    className?: string\n}>\n\nexport function BlurredContentOverlay({\n    title,\n    icon: Icon,\n    children,\n    className,\n}: BlurredContentOverlayProps) {\n    return (\n        <Overlay>\n            <div\n                className={classNames(\n                    'absolute -inset-2 flex flex-col pt-48 lg:pt-72 items-center bg-black bg-opacity-10 backdrop-blur-sm',\n                    className\n                )}\n            >\n                <motion.div\n                    key={title}\n                    initial={{ opacity: 0.5, scale: 0.95 }}\n                    animate={{ opacity: 1, scale: 1 }}\n                    className=\"max-w-sm flex flex-col items-center p-6 bg-gray-700 rounded\"\n                >\n                    {Icon && (\n                        <div className=\"mb-4 p-3 bg-cyan bg-opacity-10 rounded-xl\">\n                            <Icon className=\"w-6 h-6 text-cyan\" />\n                        </div>\n                    )}\n                    <h4 className=\"text-center\">{title}</h4>\n                    <div className=\"mt-2 text-base text-gray-100 text-center\">{children}</div>\n                </motion.div>\n            </div>\n        </Overlay>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/overlays/ErrorFallbackOverlay.tsx",
    "content": "import type { FallbackProps } from 'react-error-boundary'\nimport { Button } from '@maybe-finance/design-system'\nimport { Overlay } from './Overlay'\n\nexport function ErrorFallback({ resetErrorBoundary: _ }: FallbackProps) {\n    return (\n        <Overlay>\n            <div\n                role=\"alert\"\n                className=\"absolute inset-0 py-10 flex items-center transform -translate-y-24\"\n            >\n                <div className=\"p-4 xs:p-6 tracking-wide flex items-center justify-center space-y-4 flex-col mx-auto max-w-md\">\n                    <img src=\"/assets/maybe.svg\" alt=\"Maybe Finance Logo\" height={96} width={96} />\n                    <p>Oops! Something went wrong.</p>\n                    <Button onClick={() => window.location.reload()}>Try Again</Button>\n                </div>\n            </div>\n        </Overlay>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/overlays/MainContentOverlay.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport { Button } from '@maybe-finance/design-system'\nimport { Overlay } from './Overlay'\n\nexport type MainContentOverlayProps = PropsWithChildren<{\n    title: string\n    actionText: string\n    onAction: () => void\n}>\n\nexport function MainContentOverlay({\n    children,\n    title,\n    actionText,\n    onAction,\n}: MainContentOverlayProps) {\n    return (\n        <Overlay>\n            <div className=\"absolute inset-0 flex flex-col items-center justify-center h-full\">\n                <h4 className=\"mb-2\">{title}</h4>\n                <div className=\"text-base text-gray-100 max-w-sm text-center mb-4\">{children}</div>\n                <Button onClick={onAction}>{actionText}</Button>\n            </div>\n        </Overlay>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/overlays/Overlay.tsx",
    "content": "import type { ReactNode } from 'react'\nimport { createPortal } from 'react-dom'\nimport { useLayoutContext } from '../../providers'\n\nexport function Overlay({ children }: { children: ReactNode }) {\n    const { overlayContainer } = useLayoutContext()\n\n    return overlayContainer?.current ? (\n        createPortal(children, overlayContainer.current)\n    ) : (\n        // eslint-disable-next-line react/jsx-no-useless-fragment\n        <>{children}</>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/overlays/index.ts",
    "content": "export * from './BlurredContentOverlay'\nexport * from './ErrorFallbackOverlay'\nexport * from './MainContentOverlay'\nexport * from './Overlay'\n"
  },
  {
    "path": "libs/client/shared/src/components/tables/data-table/DataTable.tsx",
    "content": "import type { TableMeta } from './types'\nimport type { ColumnDef, PaginationState, OnChangeFn } from '@tanstack/react-table'\nimport { useMemo } from 'react'\nimport { getCoreRowModel, useReactTable, flexRender } from '@tanstack/react-table'\nimport { DefaultCell } from './DefaultCell'\nimport { Button } from '@maybe-finance/design-system'\nimport { RiArrowLeftLine, RiArrowRightLine } from 'react-icons/ri'\n\nexport interface DataTableProps {\n    data: any[]\n    columns: Array<ColumnDef<any>>\n    mutateFn: TableMeta<any>['mutateFn']\n    autoResetPage: boolean\n    defaultColumn?: Partial<ColumnDef<any>>\n    paginationOpts?: {\n        pagination: PaginationState\n        pageCount: number\n        onChange: OnChangeFn<PaginationState>\n    }\n}\n\nexport function DataTable({\n    columns,\n    data,\n    mutateFn,\n    defaultColumn,\n    paginationOpts,\n}: DataTableProps) {\n    // Properties defined here will be shared across all columns\n    const defaultColumnInternal = useMemo<Partial<ColumnDef<any>>>(\n        () => ({\n            cell: DefaultCell,\n            enableResizing: true,\n            minSize: 175,\n            ...defaultColumn,\n        }),\n        [defaultColumn]\n    )\n\n    const table = useReactTable({\n        data,\n        columns,\n        defaultColumn: defaultColumnInternal,\n        getCoreRowModel: getCoreRowModel(),\n        meta: { mutateFn } as TableMeta<any>,\n        ...(paginationOpts && {\n            state: {\n                pagination: paginationOpts.pagination,\n            },\n            manualPagination: true,\n            onPaginationChange: paginationOpts.onChange,\n            pageCount: paginationOpts.pageCount,\n        }),\n    })\n\n    return (\n        <div className=\"py-6 custom-gray-scroll\">\n            <table style={{ width: table.getCenterTotalSize() }}>\n                <thead>\n                    {table.getHeaderGroups().map((headerGroup) => (\n                        <tr key={headerGroup.id}>\n                            {headerGroup.headers.map((header) => (\n                                <th\n                                    key={header.id}\n                                    colSpan={header.colSpan}\n                                    className=\"text-base text-left p-2\"\n                                    style={{\n                                        width: header.getSize(),\n                                    }}\n                                >\n                                    {header.isPlaceholder\n                                        ? null\n                                        : flexRender(\n                                              header.column.columnDef.header,\n                                              header.getContext()\n                                          )}\n                                </th>\n                            ))}\n                        </tr>\n                    ))}\n                </thead>\n\n                <tbody>\n                    {table.getRowModel().rows.map((row) => (\n                        <tr key={row.id} className=\"border-t last:border-b border-gray-700\">\n                            {row.getVisibleCells().map((cell) => (\n                                <td\n                                    key={cell.id}\n                                    className=\"border-l last:border-r border-gray-700\"\n                                >\n                                    {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                                </td>\n                            ))}\n                        </tr>\n                    ))}\n                </tbody>\n            </table>\n\n            {paginationOpts && (\n                <div className=\"flex items-center gap-2 mt-4\">\n                    <Button\n                        variant=\"link\"\n                        disabled={!table.getCanPreviousPage()}\n                        onClick={() => table.previousPage()}\n                    >\n                        <RiArrowLeftLine className=\"mr-1\" />\n                        Back\n                    </Button>\n                    <span className=\"text-gray-50 font-semibold\">\n                        {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}\n                    </span>\n                    <Button\n                        variant=\"link\"\n                        disabled={!table.getCanNextPage()}\n                        onClick={() => table.nextPage()}\n                    >\n                        Next\n                        <RiArrowRightLine className=\"ml-1\" />\n                    </Button>\n                </div>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/tables/data-table/DefaultCell.tsx",
    "content": "import type { CellProps } from './types'\n\nexport function DefaultCell({ getValue }: CellProps) {\n    return (\n        <div className=\"p-2 text-base text-gray-50\">\n            <p>{getValue()}</p>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/tables/data-table/EditableBooleanCell.tsx",
    "content": "import { useEffect, useState } from 'react'\n\nexport type EditableBooleanCellProps = {\n    initialValue: boolean\n    onSubmit(value: boolean): void\n}\n\nexport function EditableBooleanCell({ initialValue, onSubmit }: EditableBooleanCellProps) {\n    const [value, setValue] = useState(initialValue)\n\n    useEffect(() => {\n        setValue(initialValue)\n    }, [initialValue])\n\n    return (\n        <div className=\"flex items-center justify-center\">\n            <input\n                type=\"checkbox\"\n                className=\"h-5 w-5 bg-transparent border-gray-200 text-gray-200 rounded focus:ring-1 focus:ring-gray-200 focus:ring-offset-black\"\n                value=\"checked\"\n                checked={value}\n                onChange={(e) => onSubmit(e.currentTarget.checked)}\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/tables/data-table/EditableCell.tsx",
    "content": "import type { TableMeta, ColumnMeta, CellProps } from './types'\nimport { useMemo } from 'react'\nimport { EditableStringCell } from './EditableStringCell'\nimport { EditableDateCell } from './EditableDateCell'\nimport { EditableDropdownCell } from './EditableDropdownCell'\nimport { EditableBooleanCell } from './EditableBooleanCell'\n\nexport function EditableCell({ table, row, column, getValue }: CellProps) {\n    const tableMeta = useMemo(() => table.options.meta as TableMeta, [table])\n    const columnMeta = useMemo(() => column.columnDef.meta as ColumnMeta, [column])\n\n    switch (columnMeta?.type) {\n        case 'string': {\n            return (\n                <EditableStringCell\n                    initialValue={getValue()}\n                    onSubmit={(value) => tableMeta.mutateFn(row, column.id, value)}\n                />\n            )\n        }\n        case 'date': {\n            return (\n                <EditableDateCell\n                    initialValue={getValue()}\n                    onSubmit={(value) => tableMeta.mutateFn(row, column.id, value)}\n                />\n            )\n        }\n        case 'dropdown': {\n            return (\n                <EditableDropdownCell\n                    initialValue={getValue()}\n                    options={\n                        Array.isArray(columnMeta.options)\n                            ? columnMeta.options\n                            : columnMeta.options(row)\n                    }\n                    formatFn={columnMeta.formatFn}\n                    onSubmit={(value) => tableMeta.mutateFn(row, column.id, value)}\n                />\n            )\n        }\n        case 'boolean':\n            return (\n                <EditableBooleanCell\n                    initialValue={getValue()}\n                    onSubmit={(value) => tableMeta.mutateFn(row, column.id, value)}\n                />\n            )\n        default: {\n            throw new Error('Invalid editable cell configuration')\n        }\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/tables/data-table/EditableDateCell.tsx",
    "content": "import { useCallback } from 'react'\nimport cn from 'classnames'\nimport { DateTime } from 'luxon'\nimport { Controller, useForm } from 'react-hook-form'\nimport { RiCheckLine, RiCloseLine } from 'react-icons/ri'\nimport { PatternFormat } from 'react-number-format'\n\n// This component interacts with date values as strings in the format YYYY-MM-DD\nexport type EditableDateCellProps = {\n    initialValue?: string\n    onSubmit(value: string): void\n}\n\nexport function EditableDateCell({ initialValue, onSubmit }: EditableDateCellProps) {\n    const fromFormattedDate = useCallback((value: string) => {\n        return DateTime.fromFormat(value, 'MM / dd / yyyy')\n    }, [])\n\n    const { control, handleSubmit, reset } = useForm({\n        defaultValues: { cellValue: initialValue },\n        reValidateMode: 'onSubmit',\n    })\n\n    return (\n        <div className=\"group text-white-300 focus-within:bg-gray-800\">\n            <form\n                className=\"flex items-center\"\n                onSubmit={handleSubmit(({ cellValue }) => {\n                    if (cellValue) {\n                        onSubmit(fromFormattedDate(cellValue).toFormat('yyyy-MM-dd'))\n                    }\n                })}\n            >\n                <Controller\n                    control={control}\n                    name=\"cellValue\"\n                    rules={{\n                        validate: (v) => {\n                            if (!v) return false\n\n                            const dateObj = fromFormattedDate(v)\n                            const minDate = DateTime.fromISO('1980-01-01')\n                            const maxDate = DateTime.local()\n\n                            if (!dateObj.isValid) return false\n                            if (dateObj < minDate) return false\n                            if (dateObj > maxDate) return false\n\n                            return true\n                        },\n                    }}\n                    render={({ field: { onChange, ...field }, fieldState }) => (\n                        <PatternFormat\n                            {...field}\n                            onValueChange={(v) => onChange(v.formattedValue)}\n                            format=\"## / ## / ####\"\n                            mask={['M', 'M', 'D', 'D', 'Y', 'Y', 'Y', 'Y']}\n                            placeholder=\"MM / DD / YYYY\"\n                            className={cn(\n                                'w-full bg-transparent text-base border-0 focus:ring-0',\n                                fieldState.invalid && 'text-red'\n                            )}\n                            autoComplete=\"off\"\n                        />\n                    )}\n                />\n\n                <div className=\"hidden pr-1 group-focus-within:flex\">\n                    <button\n                        type=\"button\"\n                        onClick={(e) => {\n                            reset({ cellValue: initialValue })\n                            e.currentTarget.blur()\n                        }}\n                    >\n                        <RiCloseLine className=\"w-5 h-5 hover:opacity-80\" />\n                    </button>\n                    <button type=\"submit\" onClick={(e) => e.currentTarget.blur()}>\n                        <RiCheckLine className=\"w-5 h-5 hover:opacity-80\" />\n                    </button>\n                </div>\n            </form>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/tables/data-table/EditableDropdownCell.tsx",
    "content": "import type { Modifier } from 'react-popper'\nimport { useEffect, useMemo, useState } from 'react'\nimport { Listbox } from '@headlessui/react'\nimport cn from 'classnames'\nimport { RiArrowDownSLine } from 'react-icons/ri'\nimport { usePopper } from 'react-popper'\n\nexport type EditableDropdownCellProps = {\n    initialValue: string\n    options: string[]\n    onSubmit: (value: string) => void\n    formatFn?: (option: string) => string\n}\n\nexport function EditableDropdownCell({\n    initialValue,\n    options,\n    onSubmit,\n    formatFn,\n}: EditableDropdownCellProps) {\n    const [value, setValue] = useState(initialValue)\n\n    const popperSameWidth = useMemo<Modifier<'sameWidth'>>(\n        () => ({\n            name: 'sameWidth',\n            enabled: true,\n            phase: 'beforeWrite',\n            requires: ['computeStyles'],\n            fn: ({ state }) => {\n                state.styles.popper.width = `${state.rects.reference.width}px`\n            },\n            effect: ({ state }) => {\n                state.elements.popper.style.width = `${\n                    (state.elements.reference as HTMLElement).offsetWidth\n                }px`\n            },\n        }),\n        []\n    )\n\n    useEffect(() => {\n        setValue(initialValue)\n    }, [initialValue])\n\n    const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>()\n    const [popperElement, setPopperElement] = useState<HTMLUListElement | null>()\n    const { styles, attributes } = usePopper(referenceElement, popperElement, {\n        placement: 'bottom-start',\n        modifiers: [popperSameWidth],\n    })\n\n    return (\n        <div ref={setReferenceElement} className=\"text-white-300\">\n            <Listbox\n                value={value}\n                onChange={(value) => {\n                    setValue(value)\n                    onSubmit(value)\n                }}\n            >\n                <Listbox.Button\n                    placeholder=\"Select\"\n                    className=\"w-full p-2 text-base flex items-center justify-between\"\n                >\n                    {formatFn ? formatFn(value) : value}\n                    <RiArrowDownSLine className=\"w-4 h-4\" />\n                </Listbox.Button>\n\n                <Listbox.Options\n                    ref={setPopperElement}\n                    className=\"w-full bg-gray-700 border border-gray-500 shadow-xl shadow-black z-10 translate-y-10\"\n                    style={styles.popper}\n                    {...attributes.popper}\n                >\n                    {options.map((option) => (\n                        <Listbox.Option key={option} value={option} className=\"w-full text-base\">\n                            {({ selected }) => (\n                                <button\n                                    type=\"button\"\n                                    className={cn(\n                                        'text-left py-1 px-3 w-full inline-block',\n                                        selected\n                                            ? 'bg-gray-500 text-white'\n                                            : 'hover:bg-gray-600 text-gray-25'\n                                    )}\n                                >\n                                    {formatFn ? formatFn(option) : option}\n                                </button>\n                            )}\n                        </Listbox.Option>\n                    ))}\n                </Listbox.Options>\n            </Listbox>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/tables/data-table/EditableStringCell.tsx",
    "content": "import { useEffect, useState } from 'react'\nimport { RiCheckLine, RiCloseLine } from 'react-icons/ri'\n\nexport type EditableStringCellProps = {\n    initialValue: string\n    onSubmit: (value: string) => void\n}\n\nexport function EditableStringCell({ initialValue, onSubmit }: EditableStringCellProps) {\n    const [value, setValue] = useState(initialValue)\n\n    useEffect(() => {\n        setValue(initialValue)\n    }, [initialValue])\n\n    return (\n        <div className=\"group text-white-300 focus-within:bg-gray-800\">\n            <div className=\"flex items-center\">\n                <input\n                    value={value}\n                    type=\"text\"\n                    className=\"text-base bg-transparent w-full border-0 focus:ring-0\"\n                    onChange={(e) => setValue(e.target.value)}\n                />\n\n                <div className=\"hidden pr-1 group-focus-within:flex\">\n                    <button\n                        type=\"button\"\n                        onClick={(e) => {\n                            setValue(initialValue)\n                            e.currentTarget.blur()\n                        }}\n                    >\n                        <RiCloseLine className=\"w-5 h-5 hover:opacity-80\" />\n                    </button>\n                    <button\n                        type=\"button\"\n                        onClick={(e) => {\n                            onSubmit(value)\n                            e.currentTarget.blur()\n                        }}\n                    >\n                        <RiCheckLine className=\"w-5 h-5 hover:opacity-80\" />\n                    </button>\n                </div>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/tables/data-table/index.ts",
    "content": "export * from './DataTable'\nexport * from './DefaultCell'\nexport * from './EditableStringCell'\nexport * from './EditableDropdownCell'\nexport * from './EditableDateCell'\nexport * from './EditableCell'\nexport * as TableType from './types'\n"
  },
  {
    "path": "libs/client/shared/src/components/tables/data-table/types.ts",
    "content": "import type { Cell, Column, Row, Table } from '@tanstack/react-table'\n\nexport type TableMeta<TData extends {} = any> = {\n    mutateFn: (row: Row<TData>, key: string, value: any) => void\n}\n\nexport type ColumnMeta<TData extends {} = any> =\n    | {\n          type: 'string' | 'date' | 'boolean'\n      }\n    | {\n          type: 'dropdown'\n          options: string[] | ((row: Row<TData>) => string[])\n          formatFn?: (option: string) => string\n      }\n    | undefined\n\nexport type CellProps = {\n    table: Table<any>\n    row: Row<any>\n    column: Column<any, any>\n    cell: Cell<any, any>\n    getValue: () => any\n}\n"
  },
  {
    "path": "libs/client/shared/src/components/tables/index.ts",
    "content": "export * from './data-table'\n"
  },
  {
    "path": "libs/client/shared/src/hooks/index.ts",
    "content": "export * from './useAxiosWithAuth'\nexport * from './useDebounce'\nexport * from './useInterval'\nexport * from './useLastUpdated'\nexport * from './useLocalStorage'\nexport * from './useLogger'\nexport * from './useQueryParam'\nexport * from './useScreenSize'\nexport * from './useAccountNotifications'\nexport * from './useTeller'\nexport * from './useProviderStatus'\nexport * from './useModalManager'\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useAccountNotifications.ts",
    "content": "import { useMemo } from 'react'\nimport { useAccountApi } from '../api'\n\nexport function useAccountNotifications() {\n    const { useAccounts } = useAccountApi()\n    const accountsQuery = useAccounts()\n\n    const accountsNotification = useMemo(() => {\n        if (!accountsQuery.data) return null\n\n        if (accountsQuery.data.connections.some((connection) => connection.status === 'ERROR')) {\n            return 'error'\n        }\n\n        if (\n            accountsQuery.data.connections.some(\n                (connection) => connection.plaidNewAccountsAvailable\n            )\n        ) {\n            return 'update'\n        }\n\n        return null\n    }, [accountsQuery])\n\n    return accountsNotification\n}\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useAxiosWithAuth.ts",
    "content": "import { useContext } from 'react'\nimport { AxiosContext, type AxiosContextValue } from '../providers/AxiosProvider'\n\nexport const useAxiosWithAuth: () => AxiosContextValue = () => {\n    const axiosInstance = useContext(AxiosContext)\n\n    if (!axiosInstance) {\n        throw new Error('Axios provider configured incorrectly')\n    }\n\n    return axiosInstance\n}\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useDebounce.ts",
    "content": "import { useEffect, useState } from 'react'\n\n/**\n * Returns a value updated to match the provided value after it hasn't changed for the specified delay\n */\nexport function useDebounce<T>(value: T, delayMilliseconds: number) {\n    const [debouncedValue, setDebouncedValue] = useState(value)\n\n    useEffect(() => {\n        const timeout = setTimeout(() => {\n            setDebouncedValue(value)\n        }, delayMilliseconds)\n\n        return () => clearTimeout(timeout)\n    }, [value, delayMilliseconds])\n\n    return debouncedValue\n}\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useFrame.ts",
    "content": "import { useEffect, useRef } from 'react'\n\nexport function useFrame(callback: (time: DOMHighResTimeStamp, deltaTime: number) => void) {\n    const frameRequest = useRef<number>()\n    const previousTimeRef = useRef<DOMHighResTimeStamp>()\n\n    const animate = (time: DOMHighResTimeStamp) => {\n        if (previousTimeRef.current !== undefined) {\n            const deltaTime = time - previousTimeRef.current\n            callback(time, deltaTime)\n        }\n        previousTimeRef.current = time\n        frameRequest.current = requestAnimationFrame(animate)\n    }\n\n    useEffect(() => {\n        frameRequest.current = requestAnimationFrame(animate)\n        return () => {\n            if (frameRequest.current !== undefined) cancelAnimationFrame(frameRequest.current)\n        }\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [])\n}\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useInterval.ts",
    "content": "import { useEffect, useLayoutEffect, useRef } from 'react'\n\nexport function useInterval(callback: () => any, delay?: number | false | null) {\n    const savedCallback = useRef(callback)\n\n    // remember the latest callback if it changes.\n    useLayoutEffect(() => {\n        savedCallback.current = callback\n    }, [callback])\n\n    useEffect(() => {\n        // don't schedule if no delay is specified\n        if (!delay) return\n\n        const id = setInterval(() => savedCallback.current(), delay)\n        return () => clearInterval(id)\n    }, [delay])\n}\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useLastUpdated.ts",
    "content": "import { DateTime } from 'luxon'\nimport { useEffect, useState } from 'react'\n\nexport const useLastUpdated: (lastUpdated?: DateTime | Date, showPrefix?: boolean) => string = (\n    lastUpdated,\n    showPrefix = true\n) => {\n    const lastUpdatedNormalized = DateTime.isDateTime(lastUpdated)\n        ? lastUpdated\n        : lastUpdated\n        ? DateTime.fromJSDate(lastUpdated)\n        : null\n\n    const [lastUpdateString, setLastUpdateString] = useState(lastUpdatedNormalized?.toRelative())\n\n    useEffect(() => {\n        const initialVal = lastUpdatedNormalized?.toRelative()\n        setLastUpdateString(\n            initialVal === '0 seconds ago'\n                ? 'just now'\n                : lastUpdatedNormalized?.toRelative() ?? 'Never'\n        )\n\n        const intervalRef = setInterval(() => {\n            setLastUpdateString(lastUpdatedNormalized?.toRelative() ?? 'Never')\n        }, 60000)\n\n        return () => clearInterval(intervalRef)\n    }, [lastUpdatedNormalized])\n\n    return (showPrefix ? 'Updated ' : '') + lastUpdateString || ''\n}\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useLocalStorage.ts",
    "content": "import type { Dispatch, SetStateAction } from 'react'\nimport { useEffect, useState } from 'react'\n\nexport function useLocalStorage<T>(key: string, initialValue: T): [T, Dispatch<SetStateAction<T>>] {\n    const [value, setValue] = useState<T>(() => {\n        if (typeof window !== 'undefined') {\n            try {\n                const storedValue = localStorage.getItem(key)\n\n                if (storedValue) {\n                    return JSON.parse(storedValue)\n                }\n            } catch (e) {\n                console.warn(`Failed to get ${key} from local storage`, e)\n            }\n        }\n\n        return initialValue\n    })\n\n    useEffect(() => {\n        if (typeof window !== 'undefined') {\n            try {\n                localStorage.setItem(key, JSON.stringify(value))\n            } catch (e) {\n                console.warn(`Failed to set ${key} in local storage`, e)\n            }\n        }\n    }, [key, value])\n\n    return [value, setValue]\n}\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useLogger.ts",
    "content": "import { useContext } from 'react'\nimport { LogProviderContext } from '../providers/LogProvider'\n\nexport const useLogger = () => {\n    const logger = useContext(LogProviderContext)\n\n    if (!logger) {\n        throw new Error('Logger configured incorrectly')\n    }\n\n    return logger.logger\n}\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useModalManager.ts",
    "content": "import { createContext, useContext } from 'react'\n\nexport type ModalKey = 'linkAccounts'\nexport type ModalManagerAction =\n    | { type: 'open'; key: ModalKey; props: any }\n    | { type: 'close'; key: ModalKey }\nexport type ModalManagerContext = {\n    dispatch(action: ModalManagerAction): void\n}\nexport const ModalManagerContext = createContext<ModalManagerContext | null>(null)\n\nexport function useModalManager() {\n    const ctx = useContext(ModalManagerContext)\n\n    if (!ctx) throw new Error('useModalManager must be used from within <ModalManager />')\n\n    return ctx\n}\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useProviderStatus.ts",
    "content": "import { useState } from 'react'\n\nexport function useProviderStatus() {\n    const [isCollapsed, setIsCollapsed] = useState(false)\n\n    return {\n        isCollapsed,\n        statusMessage: '',\n        dismiss: () => setIsCollapsed(true),\n        expand: () => setIsCollapsed(false),\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useQueryParam.ts",
    "content": "import { DateTime } from 'luxon'\nimport { useRouter } from 'next/router'\n\nexport function useQueryParam(key: string, type: 'string'): string | undefined\nexport function useQueryParam(key: string, type: 'string[]'): string[]\nexport function useQueryParam(key: string, type: 'number'): number | undefined\nexport function useQueryParam(key: string, type: 'date'): Date | undefined\nexport function useQueryParam(key: string, type: 'boolean'): boolean\nexport function useQueryParam(\n    key: string,\n    type: 'string' | 'string[]' | 'number' | 'date' | 'boolean'\n): string | string[] | number | Date | boolean | undefined {\n    const { query, isReady } = useRouter()\n\n    if (!isReady) return undefined\n\n    const value = query[key]\n\n    switch (type) {\n        case 'string':\n            return Array.isArray(value) ? value[0] : value\n        case 'string[]':\n            return Array.isArray(value) ? value : value ? [value] : []\n        case 'number':\n            if (!value || typeof value !== 'string') return undefined\n            return value ? parseInt(value) : undefined\n        case 'date': {\n            if (!value || typeof value !== 'string') return undefined\n            const date = DateTime.fromISO(value)\n            return date.isValid ? date.toISODate() : undefined\n        }\n        case 'boolean':\n            if (!value || typeof value !== 'string') return false\n            return value ? ['y', 'yes', 'true', '1'].includes(value.toLowerCase().trim()) : false\n        default:\n            throw Error(`unhandled param type: ${type}`)\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useScreenSize.ts",
    "content": "import { useMediaQuery } from 'react-responsive'\n\n// Starting very simple, will likely add a Tablet designation in the future\nexport enum SCREEN {\n    MOBILE = 'MOBILE',\n    DESKTOP = 'DESKTOP',\n}\n\nexport const useScreenSize = (cb?: () => void): SCREEN => {\n    // TODO: find a way to get breakpoint from Tailwind config\n    const isDesktop = useMediaQuery({ query: `(min-width: 1024px)` }, undefined, cb)\n\n    return isDesktop ? SCREEN.DESKTOP : SCREEN.MOBILE\n}\n"
  },
  {
    "path": "libs/client/shared/src/hooks/useTeller.ts",
    "content": "import { useEffect, useState } from 'react'\nimport * as Sentry from '@sentry/react'\nimport type { Logger } from '../providers/LogProvider'\nimport toast from 'react-hot-toast'\nimport { useAccountContext } from '../providers'\nimport { useTellerApi } from '../api'\nimport type {\n    TellerConnectEnrollment,\n    TellerConnectFailure,\n    TellerConnectOptions,\n    TellerConnectInstance,\n} from 'teller-connect-react'\nimport useScript from 'react-script-hook'\ntype TellerEnvironment = 'sandbox' | 'development' | 'production' | undefined\ntype TellerAccountSelection = 'disabled' | 'single' | 'multiple' | undefined\nconst TC_JS = 'https://cdn.teller.io/connect/connect.js'\n\n// Create the base configuration for Teller Connect\nexport const useTellerConfig = (logger: Logger) => {\n    return {\n        applicationId: process.env.NEXT_PUBLIC_TELLER_APP_ID ?? 'ADD_TELLER_APP_ID',\n        environment: (process.env.NEXT_PUBLIC_TELLER_ENV as TellerEnvironment) ?? 'sandbox',\n        selectAccount: 'disabled' as TellerAccountSelection,\n        onInit: () => {\n            logger.debug(`Teller Connect has initialized`)\n        },\n        onSuccess: {},\n        onExit: () => {\n            logger.debug(`Teller Connect exited`)\n        },\n        onFailure: (failure: TellerConnectFailure) => {\n            logger.error(`Teller Connect exited with error`, failure)\n            Sentry.captureEvent({\n                level: 'error',\n                message: 'TELLER_CONNECT_ERROR',\n                tags: {\n                    'teller.error.code': failure.code,\n                    'teller.error.message': failure.message,\n                },\n            })\n        },\n    } as TellerConnectOptions\n}\n\n// Custom implementation of useTellerHook to handle institution id being passed in\nexport const useTellerConnect = (options: TellerConnectOptions, logger: Logger) => {\n    const { useHandleEnrollment } = useTellerApi()\n    const handleEnrollment = useHandleEnrollment()\n    const { setAccountManager } = useAccountContext()\n    const [loading, error] = useScript({\n        src: TC_JS,\n        checkForExisting: true,\n    })\n\n    const [teller, setTeller] = useState<TellerConnectInstance | null>(null)\n    const [iframeLoaded, setIframeLoaded] = useState(false)\n\n    const createTellerInstance = (institutionId: string) => {\n        return createTeller(\n            {\n                ...options,\n                onSuccess: async (enrollment: TellerConnectEnrollment) => {\n                    logger.debug('User enrolled successfully')\n                    try {\n                        await handleEnrollment.mutateAsync({\n                            institution: {\n                                id: institutionId!,\n                                name: enrollment.enrollment.institution.name,\n                            },\n                            enrollment,\n                        })\n                    } catch (error) {\n                        toast.error(`Failed to add account`)\n                    }\n                },\n                institution: institutionId,\n                onInit: () => {\n                    setIframeLoaded(true)\n                    options.onInit && options.onInit()\n                },\n            },\n            window.TellerConnect.setup\n        )\n    }\n\n    useEffect(() => {\n        if (loading) {\n            return\n        }\n\n        if (!options.applicationId) {\n            return\n        }\n\n        if (error || !window.TellerConnect) {\n            console.error('Error loading TellerConnect:', error)\n            return\n        }\n\n        if (teller != null) {\n            teller.destroy()\n        }\n\n        return () => teller?.destroy()\n    }, [\n        loading,\n        error,\n        options.applicationId,\n        options.enrollmentId,\n        options.connectToken,\n        options.products,\n    ])\n\n    const ready = teller != null && (!loading || iframeLoaded)\n\n    const logIt = () => {\n        if (!options.applicationId) {\n            console.error('teller-connect-react: open() called without a valid applicationId.')\n        }\n    }\n\n    return {\n        error,\n        ready,\n        open: (institutionId: string) => {\n            logIt()\n            const tellerInstance = createTellerInstance(institutionId)\n            tellerInstance.open()\n            setAccountManager({ view: 'idle' })\n        },\n    }\n}\n\ninterface ManagerState {\n    teller: TellerConnectInstance | null\n    open: boolean\n}\n\nexport const createTeller = (\n    config: TellerConnectOptions,\n    creator: (config: TellerConnectOptions) => TellerConnectInstance\n) => {\n    const state: ManagerState = {\n        teller: null,\n        open: false,\n    }\n\n    if (typeof window === 'undefined' || !window.TellerConnect) {\n        throw new Error('TellerConnect is not loaded')\n    }\n\n    state.teller = creator({\n        ...config,\n        onExit: () => {\n            state.open = false\n            config.onExit && config.onExit()\n        },\n    })\n\n    const open = () => {\n        if (!state.teller) {\n            return\n        }\n\n        state.open = true\n        state.teller.open()\n    }\n\n    const destroy = () => {\n        if (!state.teller) {\n            return\n        }\n\n        state.teller.destroy()\n        state.teller = null\n    }\n\n    return {\n        open,\n        destroy,\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/index.ts",
    "content": "export * from './hooks'\nexport * from './providers'\nexport * from './components'\nexport * from './api'\nexport * as ClientType from './types'\nexport * as BrowserUtil from './utils'\n"
  },
  {
    "path": "libs/client/shared/src/providers/AccountContextProvider.tsx",
    "content": "import type { SetStateAction, Dispatch, ReactNode, PropsWithChildren } from 'react'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type { DateRange } from '@maybe-finance/design-system'\nimport { createContext, useState, useContext } from 'react'\nimport { DateTime } from 'luxon'\nimport type { AccountCategory } from '@prisma/client'\n\nexport type AccountValuationFields = {\n    startDate: string | null\n    originalBalance: number | null\n    currentBalance: number | null\n}\n\n// Stock\nexport type StockValuationFields = {\n    startDate: string | null\n    originalBalance: number | null\n    shares: number | null\n}\n\ntype StockMetadataValues = {\n    account_id: number | null\n    stock: string | null\n}\n\nexport type UpdateStockFields = StockMetadataValues & StockValuationFields\n\n// Property\ntype PropertyMetadataValues = {\n    line1: string\n    city: string\n    state: string\n    country: string\n    zip: string\n}\nexport type CreatePropertyFields = PropertyMetadataValues & AccountValuationFields\nexport type UpdatePropertyFields = PropertyMetadataValues\n\n// Vehicle\ntype VehicleValues = { make: string; model: string; year: string }\n\nexport type CreateVehicleFields = VehicleValues & AccountValuationFields\nexport type UpdateVehicleFields = VehicleValues\n\n// Other\ntype AssetValues = { name: string; categoryUser: AccountCategory }\nexport type CreateAssetFields = AssetValues & AccountValuationFields\nexport type UpdateAssetFields = AssetValues\n\n// Loan\ntype LiabilityValues = { name: string; categoryUser: AccountCategory }\ntype LoanValues = {\n    name: string\n    maturityDate: string\n    interestType: SharedType.Loan['interestRate']['type'] | null\n    loanType: SharedType.Loan['loanDetail']['type'] | null\n    interestRate: number | null\n}\nexport type CreateLiabilityFields = LiabilityValues & LoanValues & AccountValuationFields\nexport type UpdateLiabilityFields = CreateLiabilityFields\n\ntype AccountManager =\n    | { view: 'idle' }\n    | { view: 'add-teller' }\n    | { view: 'add-account' }\n    | { view: 'add-property'; defaultValues: Partial<CreatePropertyFields> }\n    // STOCKTODO - Create the necessary stock types here\n    | { view: 'add-stock'; defaultValues: Partial<UpdateStockFields> }\n    | { view: 'add-vehicle'; defaultValues: Partial<CreateVehicleFields> }\n    | { view: 'add-asset'; defaultValues: Partial<CreateAssetFields> }\n    | { view: 'add-liability'; defaultValues: Partial<CreateLiabilityFields> }\n    | { view: 'edit-account'; accountId: number }\n    | { view: 'delete-account'; accountId: number; accountName: string; onDelete?: () => void }\n    | { view: 'custom'; component: ReactNode }\n\nexport interface AccountContext {\n    accountManager: AccountManager\n    setAccountManager: Dispatch<SetStateAction<AccountManager>>\n    addAccount(): void\n    editAccount(account: SharedType.Account): void\n    deleteAccount(account: SharedType.Account, onDelete?: () => void): void\n    dateRange: DateRange\n    setDateRange(newDateRange: DateRange | ((prevDateRange: DateRange) => DateRange)): void\n}\n\nexport const AccountContext = createContext<AccountContext | undefined>(undefined)\n\nexport function useAccountContext() {\n    const context = useContext(AccountContext)\n\n    if (!context) {\n        throw new Error('useAccountContext() must be used within <AccountContextProvider>')\n    }\n\n    return context\n}\n\nexport function AccountContextProvider({ children }: PropsWithChildren<{}>) {\n    const [accountManager, setAccountManager] = useState<AccountManager>({\n        view: 'idle',\n    })\n\n    // Homepage and sidebar shared date range (defaults to \"Prior month\")\n    const [dateRange, setDateRange] = useState<DateRange>({\n        start: DateTime.now().minus({ days: 30 }).toISODate(),\n        end: DateTime.now().toISODate(),\n    })\n\n    return (\n        <AccountContext.Provider\n            value={{\n                dateRange,\n                setDateRange,\n                accountManager,\n                setAccountManager,\n                addAccount: () => setAccountManager({ view: 'add-account' }),\n                editAccount: (account) =>\n                    setAccountManager({ view: 'edit-account', accountId: account.id }),\n                deleteAccount: (account, onDelete) =>\n                    setAccountManager({\n                        view: 'delete-account',\n                        accountId: account.id,\n                        accountName: account.name,\n                        onDelete,\n                    }),\n            }}\n        >\n            {children}\n        </AccountContext.Provider>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/providers/AxiosProvider.tsx",
    "content": "import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { superjson } from '@maybe-finance/shared'\nimport { createContext, type PropsWithChildren, useMemo } from 'react'\nimport Axios from 'axios'\n\ntype CreateInstanceOptions = {\n    getToken?: () => Promise<string | null>\n    axiosOptions?: AxiosRequestConfig\n    serialize?: boolean\n    deserialize?: boolean\n}\n\nexport type AxiosContextValue = {\n    defaultBaseUrl: string\n    axios: AxiosInstance\n    createInstance: (options?: CreateInstanceOptions) => AxiosInstance\n}\n\nexport const AxiosContext = createContext<AxiosContextValue | undefined>(undefined)\n\n// Factory fn to create an instance for ad-hoc request types (e.g. multipart/form-data)\nfunction createInstance(options?: CreateInstanceOptions) {\n    const instance = Axios.create(options?.axiosOptions)\n\n    instance.interceptors.request.use(async (config) => {\n        if (options?.getToken) {\n            const token = await options.getToken()\n\n            if (token) {\n                if (config.headers) {\n                    config.headers.Authorization = `Bearer ${token}`\n                }\n\n                // For local testing convenience\n                if (process.env.NODE_ENV === 'development') {\n                    ;(window as any).JWT = token\n                }\n            }\n        }\n\n        if (options?.serialize) {\n            return { ...config, data: superjson.serialize(config.data) }\n        } else {\n            return config\n        }\n    })\n\n    if (options?.deserialize) {\n        instance.interceptors.response.use((response: AxiosResponse<SharedType.BaseResponse>) => {\n            if (response.data) {\n                const payload = response.data\n\n                if ('data' in payload) {\n                    return { ...response, data: superjson.deserialize(payload.data) }\n                } else {\n                    // Don't deserialize an error response\n                    return response\n                }\n            } else {\n                // Don't deserialize a No Content response (i.e. 204)\n                return response\n            }\n        })\n    }\n\n    return instance\n}\n\nexport function AxiosProvider({ children, baseUrl }: PropsWithChildren<{ baseUrl?: string }>) {\n    const API_URL = baseUrl || 'http://localhost:3333'\n\n    // Expose a default instance with auth, superjson, headers\n    const defaultInstance = useMemo(() => {\n        const defaultHeaders = {\n            'Content-Type': 'application/json',\n            'Access-Control-Allow-Credentials': true,\n        }\n        return createInstance({\n            axiosOptions: {\n                baseURL: `${API_URL}/v1`,\n                headers: defaultHeaders,\n                withCredentials: true,\n            },\n            serialize: true,\n            deserialize: true,\n        })\n    }, [API_URL])\n\n    return (\n        <AxiosContext.Provider\n            value={{\n                defaultBaseUrl: `${API_URL}/v1`,\n                axios: defaultInstance,\n                createInstance,\n            }}\n        >\n            {children}\n        </AxiosContext.Provider>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/providers/LayoutContextProvider.tsx",
    "content": "import type { PropsWithChildren, RefObject } from 'react'\nimport { createContext, useContext } from 'react'\n\nconst LayoutContext = createContext<{ overlayContainer: RefObject<HTMLDivElement> | undefined }>({\n    overlayContainer: undefined,\n})\n\nexport function useLayoutContext() {\n    return useContext(LayoutContext)\n}\n\nexport function LayoutContextProvider({\n    overlayContainer,\n    children,\n}: PropsWithChildren<{\n    overlayContainer: RefObject<HTMLDivElement>\n}>) {\n    return <LayoutContext.Provider value={{ overlayContainer }}>{children}</LayoutContext.Provider>\n}\n"
  },
  {
    "path": "libs/client/shared/src/providers/LogProvider.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport { createContext } from 'react'\n\nexport type Logger = Pick<Console, 'log' | 'info' | 'error' | 'warn' | 'debug'>\n\nexport const LogProviderContext = createContext<{ logger: Logger }>({ logger: console })\n\nexport function LogProvider({ logger, children }: PropsWithChildren<{ logger: Logger }>) {\n    return <LogProviderContext.Provider value={{ logger }}>{children}</LogProviderContext.Provider>\n}\n"
  },
  {
    "path": "libs/client/shared/src/providers/PopoutProvider.tsx",
    "content": "import type { PropsWithChildren, ReactNode } from 'react'\nimport { createContext, useContext, useState } from 'react'\n\nexport interface PopoutContext {\n    popoutContents: ReactNode | null\n    open(component: ReactNode | null): void\n    close(): void\n}\n\nexport const PopoutContext = createContext<PopoutContext | undefined>(undefined)\n\nexport function usePopoutContext() {\n    const context = useContext(PopoutContext)\n\n    if (!context) throw new Error('usePopoutContext() must be used within <PopoutProvider />')\n\n    return context\n}\n\nexport function PopoutProvider({ children }: PropsWithChildren<{}>) {\n    const [popoutContents, setPopoutContents] = useState<ReactNode | null>(null)\n\n    return (\n        <PopoutContext.Provider\n            value={{\n                popoutContents,\n                open: setPopoutContents,\n                close: () => setPopoutContents(null),\n            }}\n        >\n            {children}\n        </PopoutContext.Provider>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/providers/QueryProvider.tsx",
    "content": "import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport { ReactQueryDevtools } from '@tanstack/react-query-devtools'\nimport * as Sentry from '@sentry/react'\nimport Axios from 'axios'\n\nexport interface QueryClientProviderProps {\n    children: React.ReactNode\n}\n\nconst queryClient = new QueryClient({\n    queryCache: new QueryCache({\n        onError: (error, query) => {\n            if (Axios.isAxiosError(error)) {\n                const axiosMessage = Axios.isAxiosError(error)\n                    ? error.response?.data?.errors?.[0]?.title\n                    : null\n\n                Sentry.captureException(\n                    axiosMessage ? new Error(axiosMessage, { cause: error.cause }) : error,\n                    (scope) => {\n                        scope.setTransactionName(\n                            `Query with key: [ ${query.queryKey.join(' > ')} ] failed`\n                        )\n                        scope.setContext('errors', {\n                            errors: JSON.stringify(error.response?.data?.errors),\n                        })\n\n                        return scope\n                    }\n                )\n            } else {\n                Sentry.captureException(error)\n            }\n        },\n    }),\n})\n\nexport function QueryProvider({ children }: QueryClientProviderProps) {\n    return (\n        <QueryClientProvider client={queryClient}>\n            {children}\n            <ReactQueryDevtools position=\"bottom-right\" />\n        </QueryClientProvider>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/providers/UserAccountContextProvider.tsx",
    "content": "import type { PropsWithChildren, SetStateAction, Dispatch } from 'react'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { createContext, useState, useContext, useEffect, useMemo } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { useAccountApi } from '../api'\nimport toast from 'react-hot-toast'\nimport uniqBy from 'lodash/uniqBy'\nimport { useInterval } from '../hooks'\nimport { invalidateAccountQueries } from '../utils'\n\nexport interface UserAccountContext {\n    isReady: boolean\n    noAccounts: boolean\n    allAccountsDisabled: boolean\n    someConnectionsSyncing: boolean\n    someAccountsSyncing: boolean\n    accountSyncing: (accountId: SharedType.Account['id']) => boolean\n    accountsSyncing: SharedType.Account[]\n    connectionsSyncing: SharedType.ConnectionWithAccounts[]\n    syncProgress?: SharedType.AccountSyncProgress\n    expectingAccounts: boolean\n    setExpectingAccounts: Dispatch<SetStateAction<boolean>>\n}\n\nexport const UserAccountContext = createContext<UserAccountContext | undefined>(undefined)\n\nexport function useUserAccountContext() {\n    const context = useContext(UserAccountContext)\n\n    if (!context) {\n        throw new Error('useUserAccountContext() must be used within <UserAccountContextProvider>')\n    }\n\n    return context\n}\n\nexport function UserAccountContextProvider({ children }: PropsWithChildren<{}>) {\n    const queryClient = useQueryClient()\n\n    const { useAccounts } = useAccountApi()\n\n    const [noAccounts, setNoAccounts] = useState(false)\n    const [allAccountsDisabled, setAllAccountsDisabled] = useState(false)\n    const [someConnectionsSyncing, setSomeConnectionsSyncing] = useState(false)\n    const [someAccountsSyncing, setSomeAccountsSyncing] = useState(false)\n    const [accountsSyncing, setAccountsSyncing] = useState<SharedType.Account[]>([])\n    const [connectionsSyncing, setConnectionsSyncing] = useState<\n        (SharedType.ConnectionWithAccounts & SharedType.ConnectionWithSyncProgress)[]\n    >([])\n    const [expectingAccounts, setExpectingAccounts] = useState(false)\n\n    const syncProgress: SharedType.AccountSyncProgress | undefined = useMemo(() => {\n        if (expectingAccounts) {\n            return { description: 'Importing accounts', progress: 0 }\n        }\n\n        if (!connectionsSyncing.length) {\n            return undefined\n        }\n\n        const allSyncProgress = connectionsSyncing\n            .map(({ syncProgress }) => syncProgress)\n            .filter((sp): sp is SharedType.AccountSyncProgress => sp != null)\n            .sort((a, b) => Number(a.progress) - Number(b.progress) ?? 0)\n\n        return allSyncProgress.length\n            ? allSyncProgress[0]\n            : { description: 'Importing accounts', progress: 0.1 }\n    }, [connectionsSyncing, expectingAccounts])\n\n    const accountsQuery = useAccounts({\n        onSuccess: ({ accounts, connections }) => {\n            // determine connections to show \"successfully synced\" notification for\n            accounts\n                .filter(\n                    (a) => a.syncStatus === 'IDLE' && accountsSyncing.some((sc) => sc.id === a.id)\n                )\n                .forEach((account) => {\n                    toast.success(`${account.name} synced`)\n                })\n\n            // An account is \"syncing\" if the account itself is syncing OR its parent connection is syncing (\"implicitly syncing\")\n            const individualAccountsSyncing = accounts.filter((a) => a.syncStatus !== 'IDLE')\n            const childAccountsSyncing = connections\n                .filter((c) => c.syncStatus !== 'IDLE')\n                .flatMap((c) => c.accounts)\n\n            const newAccountsSyncing = uniqBy(\n                [...individualAccountsSyncing, ...childAccountsSyncing],\n                'id'\n            )\n\n            setAccountsSyncing(newAccountsSyncing)\n\n            connections\n                .filter(\n                    (c) =>\n                        c.syncStatus === 'IDLE' && connectionsSyncing.some((sc) => sc.id === c.id)\n                )\n                .forEach((connection) => {\n                    connection.status === 'ERROR'\n                        ? toast.error(`${connection.name} failed to sync`)\n                        : toast.success(`${connection.name} synced`)\n                })\n\n            setConnectionsSyncing(\n                connections.filter((c) => c.syncStatus === 'PENDING' || c.syncStatus === 'SYNCING')\n            )\n\n            setNoAccounts(\n                accounts.length === 0 && connections.every((c) => c.accounts.length === 0)\n            )\n\n            const allAccountsDisabled = accounts.every((a) => !a.isActive)\n            const allConnectionsDisabled = connections.every((c) =>\n                c.accounts.every((a) => !a.isActive)\n            )\n\n            setAllAccountsDisabled(allAccountsDisabled && allConnectionsDisabled)\n\n            const someConnectionsSyncing = connections.some((c) => c.syncStatus !== 'IDLE')\n            const someAccountsSyncing =\n                accounts.some((a) => a.syncStatus !== 'IDLE') ||\n                connections.some(\n                    (c) => c.syncStatus !== 'IDLE' && c.accounts.some((a) => a.isActive)\n                )\n\n            setSomeConnectionsSyncing(someConnectionsSyncing)\n            setSomeAccountsSyncing(someAccountsSyncing)\n        },\n    })\n\n    useInterval(\n        () => invalidateAccountQueries(queryClient),\n        someAccountsSyncing || someConnectionsSyncing || expectingAccounts ? 2_000 : undefined\n    )\n\n    useEffect(() => {\n        if (!someAccountsSyncing) invalidateAccountQueries(queryClient)\n    }, [queryClient, someAccountsSyncing])\n\n    useEffect(\n        () => setExpectingAccounts(false),\n        [accountsQuery.data?.connections.length, accountsQuery.data?.accounts.length]\n    )\n\n    return (\n        <UserAccountContext.Provider\n            value={{\n                isReady: !accountsQuery.isLoading,\n                noAccounts,\n                allAccountsDisabled,\n                someConnectionsSyncing,\n                someAccountsSyncing,\n                accountSyncing: (accountId: SharedType.Account['id']) => {\n                    return !!accountsSyncing.find((a) => a.id === accountId)\n                },\n                accountsSyncing,\n                connectionsSyncing,\n                syncProgress,\n                expectingAccounts,\n                setExpectingAccounts,\n            }}\n        >\n            {children}\n        </UserAccountContext.Provider>\n    )\n}\n"
  },
  {
    "path": "libs/client/shared/src/providers/index.ts",
    "content": "export * from './AxiosProvider'\nexport * from './LayoutContextProvider'\nexport * from './LogProvider'\nexport * from './QueryProvider'\nexport * from './AccountContextProvider'\nexport * from './UserAccountContextProvider'\nexport * from './PopoutProvider'\n"
  },
  {
    "path": "libs/client/shared/src/types/client-side-feature-flags.ts",
    "content": "export type MetricStatus = 'coming-soon' | 'under-construction' | 'active'\n"
  },
  {
    "path": "libs/client/shared/src/types/index.ts",
    "content": "export * from './client-side-feature-flags'\nexport * from './react-types'\n"
  },
  {
    "path": "libs/client/shared/src/types/react-types.ts",
    "content": "import type { ReactElement, ReactHTML, ReactNode } from 'react'\n\nexport type WithChildrenRenderProps<Props, RenderProps> = Props & {\n    children?: ReactNode | ((props: RenderProps) => ReactElement)\n}\n"
  },
  {
    "path": "libs/client/shared/src/utils/account-utils.ts",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport type { AccountCategory } from '@prisma/client'\nimport type { QueryClient } from '@tanstack/react-query'\nimport { DateTime } from 'luxon'\nimport {\n    RiBankCard2Line,\n    RiBankLine,\n    RiBitCoinLine,\n    RiCarLine,\n    RiFolderLine,\n    RiHandCoinLine,\n    RiHomeLine,\n    RiLineChartLine,\n    RiVipDiamondLine,\n} from 'react-icons/ri'\n\nexport function getCategoryColorClassName(category: AccountCategory) {\n    return (\n        (\n            {\n                cash: 'text-blue',\n                investment: 'text-teal',\n                crypto: 'text-orange',\n                property: 'text-pink',\n                vehicle: 'text-grape',\n                valuable: 'text-green',\n                loan: 'text-red',\n                credit: 'text-indigo',\n                other: 'text-cyan',\n            } as Record<AccountCategory, any>\n        )[category] ?? 'text-cyan'\n    )\n}\n\nexport function getCategoryIcon(category: AccountCategory) {\n    return (\n        (\n            {\n                cash: RiBankLine,\n                investment: RiLineChartLine,\n                crypto: RiBitCoinLine,\n                property: RiHomeLine,\n                vehicle: RiCarLine,\n                valuable: RiVipDiamondLine,\n                loan: RiHandCoinLine,\n                credit: RiBankCard2Line,\n                other: RiFolderLine,\n            } as Record<AccountCategory, any>\n        )[category] ?? RiFolderLine\n    )\n}\n\n/**\n * Invalidates account queries and optionally account aggregate queries (i.e. net worth, insights)\n */\nexport function invalidateAccountQueries(queryClient: QueryClient, aggregates = true) {\n    queryClient.invalidateQueries(['accounts'])\n    queryClient.invalidateQueries(['users', 'onboarding'])\n\n    if (aggregates) {\n        queryClient.invalidateQueries(['users', 'net-worth'])\n        queryClient.invalidateQueries(['users', 'insights'])\n    }\n}\n\nexport function formatLoanTerm({\n    originationDate,\n    maturityDate,\n}: Pick<SharedType.Loan, 'originationDate' | 'maturityDate'>) {\n    const start = DateTime.fromISO(originationDate!)\n    const end = DateTime.fromISO(maturityDate!)\n    const months = end.diff(start, 'months').toObject().months!\n\n    // if the total months is within 1 month of a year, display years\n    return months % 12 < 1 || months % 12 > 11\n        ? `${Math.round(months / 12)} years`\n        : `${Math.round(months)} months`\n}\n"
  },
  {
    "path": "libs/client/shared/src/utils/browser-utils.ts",
    "content": "export async function copyToClipboard(text: string) {\n    if ('clipboard' in navigator) {\n        return navigator.clipboard.writeText(text)\n    } else {\n        return document.execCommand('copy', true, text)\n    }\n}\n\nexport function getLocalStorageSession<TData>(key: string, initialValue: TData) {\n    return {\n        getLocalStorageItem: () => {\n            if (typeof window === 'undefined') return initialValue\n\n            const item = window.localStorage.getItem(key)\n\n            return item ? (JSON.parse(item) as TData) : initialValue\n        },\n        setLocalStorageItem: (data: TData | ((data: TData) => TData)) => {\n            if (typeof window === 'undefined') return\n\n            const previousValue = window.localStorage.getItem(key)\n\n            const isFunc = data instanceof Function\n\n            window.localStorage.setItem(\n                key,\n                JSON.stringify(\n                    isFunc ? data(previousValue ? JSON.parse(previousValue) : initialValue) : data\n                )\n            )\n        },\n    }\n}\n"
  },
  {
    "path": "libs/client/shared/src/utils/form-utils.ts",
    "content": "import { DateTime } from 'luxon'\nimport { DateUtil } from '@maybe-finance/shared'\nimport defaults from 'lodash/defaults'\n\nconst { MIN_SUPPORTED_DATE, MAX_SUPPORTED_DATE, isToday } = DateUtil\n\ntype ValidateFormDateOpts = {\n    required?: boolean\n    minDate?: string\n    maxDate?: string\n}\n\nexport function validateFormDate(date: string | null, opts?: ValidateFormDateOpts) {\n    const _opts = defaults({}, opts)\n\n    if (!date) {\n        const isRequired = _opts.required ?? true\n        return isRequired ? 'Valid date required' : true\n    }\n\n    const _date = DateTime.fromISO(date)\n\n    if (!_date.isValid) return 'Invalid date'\n\n    const minDate = _opts.minDate ? DateTime.fromISO(_opts.minDate) : MIN_SUPPORTED_DATE\n    const maxDate = _opts.maxDate ? DateTime.fromISO(_opts.maxDate) : MAX_SUPPORTED_DATE\n\n    if (_date < minDate) {\n        return `Date must be ${minDate.toFormat('MMM dd yyyy')} or later`\n    }\n\n    if (_date.endOf('day') > maxDate.endOf('day')) {\n        return isToday(maxDate.toISODate(), DateTime.utc())\n            ? 'Date cannot be in future'\n            : `Date must be ${maxDate.toFormat('MMM yyyy')} or earlier`\n    }\n\n    return true\n}\n"
  },
  {
    "path": "libs/client/shared/src/utils/image-loaders.ts",
    "content": "import type { ImageLoaderProps } from 'next/legacy/image'\n\nfunction isJSON(str: string): boolean {\n    try {\n        JSON.parse(str)\n        return true\n    } catch (e) {\n        return false\n    }\n}\n\nexport function enhancerizerLoader({ src, width }: ImageLoaderProps): string {\n    let parsed: { [key: string]: string | number }\n\n    if (isJSON(src)) {\n        parsed = JSON.parse(src)\n    } else {\n        parsed = { src }\n    }\n\n    parsed.width ??= width\n    parsed.height ??= width\n\n    const queryString = Object.entries(parsed)\n        .map((pair) => pair.map(encodeURIComponent).join('='))\n        .join('&')\n\n    return `https://enhancerizer.maybe.co/images?${queryString}`\n}\n"
  },
  {
    "path": "libs/client/shared/src/utils/index.ts",
    "content": "export * from './image-loaders'\nexport * from './browser-utils'\nexport * from './account-utils'\nexport * from './form-utils'\n"
  },
  {
    "path": "libs/client/shared/tsconfig.json",
    "content": "{\n    \"extends\": \"../../../tsconfig.base.json\",\n    \"compilerOptions\": {\n        \"jsx\": \"react-jsx\",\n        \"allowJs\": true,\n        \"esModuleInterop\": true,\n        \"allowSyntheticDefaultImports\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"strict\": true,\n        \"noImplicitReturns\": true,\n        \"noFallthroughCasesInSwitch\": true\n    },\n    \"files\": [],\n    \"include\": [],\n    \"references\": [\n        {\n            \"path\": \"./tsconfig.lib.json\"\n        },\n        {\n            \"path\": \"./tsconfig.spec.json\"\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/client/shared/tsconfig.lib.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../../dist/out-tsc\",\n        \"types\": [\"node\"]\n    },\n    \"files\": [\n        \"../../../node_modules/@nrwl/react/typings/cssmodule.d.ts\",\n        \"../../../node_modules/@nrwl/react/typings/image.d.ts\"\n    ],\n    \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"**/*.spec.tsx\", \"**/*.test.tsx\", \"jest.config.ts\"],\n    \"include\": [\"**/*.js\", \"**/*.jsx\", \"**/*.ts\", \"**/*.tsx\"]\n}\n"
  },
  {
    "path": "libs/client/shared/tsconfig.spec.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../../dist/out-tsc\",\n        \"module\": \"commonjs\",\n        \"types\": [\"jest\", \"node\"]\n    },\n    \"include\": [\n        \"**/*.spec.ts\",\n        \"**/*.test.ts\",\n        \"**/*.spec.tsx\",\n        \"**/*.test.tsx\",\n        \"**/*.spec.js\",\n        \"**/*.test.js\",\n        \"**/*.spec.jsx\",\n        \"**/*.test.jsx\",\n        \"**/*.d.ts\",\n        \"jest.config.ts\"\n    ]\n}\n"
  },
  {
    "path": "libs/design-system/.babelrc",
    "content": "{\n    \"presets\": [\n        [\n            \"@nrwl/react/babel\",\n            {\n                \"runtime\": \"automatic\",\n                \"useBuiltIns\": \"usage\"\n            }\n        ]\n    ],\n    \"plugins\": []\n}\n"
  },
  {
    "path": "libs/design-system/.eslintrc.json",
    "content": "{\n    \"extends\": [\"plugin:@nrwl/nx/react\", \"../../.eslintrc.json\"],\n    \"ignorePatterns\": [\"!**/*\"],\n    \"overrides\": [\n        {\n            \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.ts\", \"*.tsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/design-system/.storybook/main.js",
    "content": "const rootMain = require('../../../.storybook/main')\n\nmodule.exports = {\n    ...rootMain,\n\n    core: { ...rootMain.core, builder: 'webpack5' },\n    stories: [\n        ...rootMain.stories,\n        '../docs/**/*.stories.mdx',\n        '../src/lib/**/*.stories.mdx',\n        '../src/lib/**/*.stories.@(js|jsx|ts|tsx)',\n    ],\n    addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'],\n    webpackFinal: async (config, { configType }) => {\n        // apply any global webpack configs that might have been specified in .storybook/main.js\n        if (rootMain.webpackFinal) {\n            config = await rootMain.webpackFinal(config, { configType })\n        }\n\n        // add your own webpack tweaks if needed\n\n        return config\n    },\n}\n"
  },
  {
    "path": "libs/design-system/.storybook/manager.js",
    "content": "import { addons } from '@storybook/addons'\nimport theme from './theme'\n\naddons.setConfig({\n    theme,\n})\n"
  },
  {
    "path": "libs/design-system/.storybook/preview.js",
    "content": "import '../assets/styles.css'\n\nimport theme from './theme'\n\nexport const parameters = {\n    actions: { argTypesRegex: '^on[A-Z].*' },\n    viewMode: 'docs', // Show Docs tab by default\n    controls: {\n        matchers: {\n            color: /(background|color)$/i,\n            date: /Date$/,\n        },\n    },\n    backgrounds: {\n        default: 'dark',\n        values: [\n            {\n                name: 'dark',\n                value: '#16161A',\n            },\n        ],\n    },\n    docs: {\n        theme,\n    },\n    options: {\n        storySort: {\n            order: ['Getting Started', ['About', 'Colors', 'Typography'], 'Components'],\n        },\n    },\n}\n"
  },
  {
    "path": "libs/design-system/.storybook/theme.js",
    "content": "import { create } from '@storybook/theming'\nimport logo from '../assets/logo.svg'\n\nexport default create({\n    base: 'dark',\n\n    brandTitle: 'Maybe',\n    brandUrl: 'https://maybe.co',\n    brandImage: logo,\n\n    fontBase: '\"General Sans\", sans-serif',\n\n    colorPrimary: '#4361EE',\n    colorSecondary: '#F12980',\n\n    appBg: '#1C1C20',\n    appContentBg: '#16161A',\n})\n"
  },
  {
    "path": "libs/design-system/.storybook/tsconfig.json",
    "content": "{\n    \"extends\": \"../tsconfig.json\",\n    \"compilerOptions\": {\n        \"emitDecoratorMetadata\": true,\n        \"outDir\": \"\"\n    },\n    \"files\": [\n        \"../../../node_modules/@nrwl/react/typings/styled-jsx.d.ts\",\n        \"../../../node_modules/@nrwl/react/typings/cssmodule.d.ts\",\n        \"../../../node_modules/@nrwl/react/typings/image.d.ts\"\n    ],\n    \"exclude\": [\n        \"../**/*.spec.ts\",\n        \"../**/*.test.ts\",\n        \"../**/*.spec.js\",\n        \"../**/*.test.js\",\n        \"../**/*.spec.tsx\",\n        \"../**/*.test.tsx\",\n        \"../**/*.spec.jsx\",\n        \"../**/*.test.jsx\",\n        \"jest.config.ts\"\n    ],\n    \"include\": [\"../src/**/*\", \"*.js\", \"../docs\"]\n}\n"
  },
  {
    "path": "libs/design-system/README.md",
    "content": "# Maybe Design System\n\nComponents and patterns used at [Maybe](https://maybe.co)\n\n[designsystem.maybe.co](https://designsystem.maybe.co)\n"
  },
  {
    "path": "libs/design-system/assets/styles.css",
    "content": "@font-face {\n    font-family: 'Monument Extended';\n    font-weight: 100;\n    src: url('fonts/monument/MonumentExtended-Thin.woff2') format('woff2');\n}\n\n@font-face {\n    font-family: 'Monument Extended';\n    font-weight: 200;\n    src: url('fonts/monument/MonumentExtended-Light.woff2') format('woff2');\n}\n\n@font-face {\n    font-family: 'Monument Extended';\n    font-weight: 300;\n    src: url('fonts/monument/MonumentExtended-Book.woff2') format('woff2');\n}\n\n@font-face {\n    font-family: 'Monument Extended';\n    font-weight: 400;\n    src: url('fonts/monument/MonumentExtended-Regular.woff2') format('woff2');\n}\n\n@font-face {\n    font-family: 'Monument Extended';\n    font-weight: 500;\n    src: url('fonts/monument/MonumentExtended-Medium.woff2') format('woff2');\n}\n\n@font-face {\n    font-family: 'Monument Extended';\n    font-weight: 700;\n    src: url('fonts/monument/MonumentExtended-Bold.woff2') format('woff2');\n}\n\n@font-face {\n    font-family: 'Monument Extended';\n    font-weight: 800;\n    src: url('fonts/monument/MonumentExtended-Black.woff2') format('woff2');\n}\n\n@font-face {\n    font-family: 'Monument Extended';\n    font-weight: 900;\n    src: url('fonts/monument/MonumentExtended-Heavy.woff2') format('woff2');\n}\n\n@font-face {\n    font-family: 'Inter';\n    src: url('fonts/inter/Inter-Variable.woff2') format('woff2-variations');\n    font-weight: 100 900;\n}\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n"
  },
  {
    "path": "libs/design-system/docs/Getting Started/About.stories.mdx",
    "content": "import { Meta } from '@storybook/addon-docs'\n\n<Meta title=\"Getting Started/About\" />\n\n# Maybe Design System\n\nComponents and patterns used at [Maybe](https://maybe.co)\n"
  },
  {
    "path": "libs/design-system/docs/Getting Started/Colors.stories.mdx",
    "content": "import { Meta } from '@storybook/addon-docs'\nimport Swatch from '../util/Swatch'\nimport SwatchGroup from '../util/SwatchGroup'\n\n<Meta title=\"Getting Started/Colors\" />\n\n# Colors\n\n<SwatchGroup heading=\"Neutral\" description=\"These are the base white and black colors\">\n    <Swatch color=\"White\" className=\"bg-white\" />\n    <Swatch color=\"Black\" className=\"bg-black\" />\n</SwatchGroup>\n\n<SwatchGroup\n    heading=\"Grays\"\n    description=\"For the dark theme there is a wider range of dark grays. Most things within the UI – text, input fields, backgrounds and dividers are usually gray.\"\n>\n    <Swatch color=\"Gray 25\" className=\"bg-gray-25\" />\n    <Swatch color=\"Gray 50\" className=\"bg-gray-50\" />\n    <Swatch color=\"Gray 100\" className=\"bg-gray-100\" />\n    <Swatch color=\"Gray 200\" className=\"bg-gray-200\" />\n    <Swatch color=\"Gray 300\" className=\"bg-gray-300\" />\n    <Swatch color=\"Gray 400\" className=\"bg-gray-400\" />\n    <Swatch color=\"Gray 500\" className=\"bg-gray-500\" />\n    <Swatch color=\"Gray 600\" className=\"bg-gray-600\" />\n    <Swatch color=\"Gray 700\" className=\"bg-gray-700\" />\n    <Swatch color=\"Gray 800\" className=\"bg-gray-800\" />\n</SwatchGroup>\n\n<SwatchGroup\n    heading=\"Primary\"\n    description=\"This is the primary brand color used for buttons, links and inputs. The darker variant should be used to reach an AA+ contrast ratio.\"\n>\n    <Swatch color=\"Cyan 50\" className=\"bg-cyan-50\" />\n    <Swatch color=\"Cyan 300\" className=\"bg-cyan-300\" />\n    <Swatch color=\"Cyan 400\" className=\"bg-cyan-500\" />\n    <Swatch color=\"Cyan 500\" className=\"bg-cyan-500\" />\n</SwatchGroup>\n\n<SwatchGroup\n    heading=\"Error\"\n    description=\"This is the color used to communicate destructive or negative actions as well as error states.\"\n>\n    <Swatch color=\"Red 50\" className=\"bg-red-50\" />\n    <Swatch color=\"Red 300\" className=\"bg-red-300\" />\n    <Swatch color=\"Red 400\" className=\"bg-red-400\" />\n    <Swatch color=\"Red 500\" className=\"bg-red-500\" />\n</SwatchGroup>\n\n<SwatchGroup\n    heading=\"Success\"\n    description=\"This is the color used to communicate successful or positive actions as well as success states.\"\n>\n    <Swatch color=\"Teal 50\" className=\"bg-teal-50\" />\n    <Swatch color=\"Teal 300\" className=\"bg-teal-300\" />\n    <Swatch color=\"Teal 400\" className=\"bg-teal-400\" />\n    <Swatch color=\"Teal 500\" className=\"bg-teal-500\" />\n</SwatchGroup>\n\n<SwatchGroup\n    heading=\"Warning\"\n    description=\"This is the color used to communicate a warning state, which is mainly used to draw a user’s attention.\"\n>\n    <Swatch color=\"Yellow 50\" className=\"bg-yellow-50\" />\n    <Swatch color=\"Yellow 300\" className=\"bg-yellow-300\" />\n    <Swatch color=\"Yellow 400\" className=\"bg-yellow-400\" />\n    <Swatch color=\"Yellow 500\" className=\"bg-yellow-500\" />\n</SwatchGroup>\n\n<SwatchGroup heading=\"Blue\">\n    <Swatch color=\"Blue 50\" className=\"bg-blue-50\" />\n    <Swatch color=\"Blue 300\" className=\"bg-blue-300\" />\n    <Swatch color=\"Blue 400\" className=\"bg-blue-400\" />\n    <Swatch color=\"Blue 500\" className=\"bg-blue-500\" />\n</SwatchGroup>\n\n<SwatchGroup heading=\"Orange\">\n    <Swatch color=\"Orange 50\" className=\"bg-orange-50\" />\n    <Swatch color=\"Orange 300\" className=\"bg-orange-300\" />\n    <Swatch color=\"Orange 400\" className=\"bg-orange-400\" />\n    <Swatch color=\"Orange 500\" className=\"bg-orange-500\" />\n</SwatchGroup>\n\n<SwatchGroup heading=\"Pink\">\n    <Swatch color=\"Pink 50\" className=\"bg-pink-50\" />\n    <Swatch color=\"Pink 300\" className=\"bg-pink-300\" />\n    <Swatch color=\"Pink 400\" className=\"bg-pink-400\" />\n    <Swatch color=\"Pink 500\" className=\"bg-pink-500\" />\n</SwatchGroup>\n\n<SwatchGroup heading=\"Grape\">\n    <Swatch color=\"Grape 50\" className=\"bg-grape-50\" />\n    <Swatch color=\"Grape 300\" className=\"bg-grape-300\" />\n    <Swatch color=\"Grape 400\" className=\"bg-grape-400\" />\n    <Swatch color=\"Grape 500\" className=\"bg-grape-500\" />\n</SwatchGroup>\n\n<SwatchGroup heading=\"Indigo\">\n    <Swatch color=\"Indigo 50\" className=\"bg-indigo-50\" />\n    <Swatch color=\"Indigo 300\" className=\"bg-indigo-300\" />\n    <Swatch color=\"Indigo 400\" className=\"bg-indigo-400\" />\n    <Swatch color=\"Indigo 500\" className=\"bg-indigo-500\" />\n</SwatchGroup>\n\n<SwatchGroup heading=\"Green\">\n    <Swatch color=\"Green 50\" className=\"bg-green-50\" />\n    <Swatch color=\"Green 300\" className=\"bg-green-300\" />\n    <Swatch color=\"Green 400\" className=\"bg-green-400\" />\n    <Swatch color=\"Green 500\" className=\"bg-green-500\" />\n</SwatchGroup>\n"
  },
  {
    "path": "libs/design-system/docs/Getting Started/Typography.stories.mdx",
    "content": "import { Canvas, Meta } from '@storybook/addon-docs'\nimport Swatch from '../util/Swatch'\nconst headingClasses = 'text-white font-display font-extrabold mb-5'\nconst paragraphClasses = 'text-white font-sans font-regular mb-3'\n\n<Meta title=\"Getting Started/Typography\" />\n\n# Typography\n\n<Canvas>\n    <div>\n        <h1 className={`text-5xl ${headingClasses}`}>Heading 1</h1>\n        <h2 className={`text-4xl ${headingClasses}`}>Heading 2</h2>\n        <h3 className={`text-3xl ${headingClasses}`}>Heading 3</h3>\n        <p className={`text-lg ${paragraphClasses}`}>Large text</p>\n        <p className={`text-base ${paragraphClasses}`}>Medium text</p>\n        <p className={`text-sm ${paragraphClasses}`}>Small text</p>\n    </div>\n</Canvas>\n"
  },
  {
    "path": "libs/design-system/docs/util/Swatch.tsx",
    "content": "import classNames from 'classnames'\nimport React from 'react'\n\nexport interface SwatchProps {\n    color: string\n    className: string\n}\n\nfunction Swatch({ color, className }: SwatchProps): JSX.Element {\n    return (\n        <div className=\"inline-block mr-4 mb-6\" title={className}>\n            <div className={classNames('rounded-lg w-32 h-12', className)}></div>\n            <span className=\"text-sm text-white\">{color}</span>\n        </div>\n    )\n}\n\nexport default Swatch\n"
  },
  {
    "path": "libs/design-system/docs/util/SwatchGroup.tsx",
    "content": "import type { ReactNode } from 'react'\n\nexport interface SwatchGroupProps {\n    heading: string\n    description?: string\n    children: ReactNode\n}\n\nfunction SwatchGroup({ heading, description, children }: SwatchGroupProps): JSX.Element {\n    return (\n        <div className=\"flex my-12\">\n            <div className=\"w-96 shrink-0 mr-8\">\n                <h2 className=\"font-display font-bold text-xl text-white uppercase\">{heading}</h2>\n                {description && <p className=\"text-base text-gray-200\">{description}</p>}\n            </div>\n            <div className=\"grow\">{children}</div>\n        </div>\n    )\n}\n\nexport default SwatchGroup\n"
  },
  {
    "path": "libs/design-system/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n    displayName: 'design-system',\n    preset: '../../jest.preset.js',\n    transform: {\n        '^.+\\\\.[tj]sx?$': ['babel-jest', { presets: ['@nrwl/react/babel'] }],\n    },\n    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n    coverageDirectory: '../../coverage/libs/design-system',\n    setupFilesAfterEnv: ['./jest.setup.js'],\n}\n"
  },
  {
    "path": "libs/design-system/jest.setup.js",
    "content": "import '@testing-library/jest-dom'\n"
  },
  {
    "path": "libs/design-system/package.json",
    "content": "{\n    \"name\": \"@maybe-finance/design-system\",\n    \"version\": \"0.0.1\",\n    \"nx\": {\n        \"targets\": {\n            \"build\": {\n                \"executor\": \"@nrwl/web:rollup\",\n                \"outputs\": [\n                    \"{options.outputPath}\"\n                ],\n                \"options\": {\n                    \"outputPath\": \"dist/libs/design-system\",\n                    \"tsConfig\": \"libs/design-system/tsconfig.lib.json\",\n                    \"project\": \"libs/design-system/package.json\",\n                    \"entryFile\": \"libs/design-system/src/index.ts\",\n                    \"external\": [\n                        \"react/jsx-runtime\"\n                    ],\n                    \"rollupConfig\": \"@nrwl/react/plugins/bundle-rollup\",\n                    \"assets\": [\n                        {\n                            \"glob\": \"libs/design-system/README.md\",\n                            \"input\": \".\",\n                            \"output\": \".\"\n                        }\n                    ]\n                },\n                \"configurations\": {\n                    \"production\": {\n                        \"optimization\": true,\n                        \"extractLicenses\": true,\n                        \"inspect\": false\n                    }\n                }\n            },\n            \"lint\": {\n                \"executor\": \"@nrwl/linter:eslint\",\n                \"outputs\": [\n                    \"{options.outputFile}\"\n                ],\n                \"options\": {\n                    \"lintFilePatterns\": [\n                        \"libs/design-system/**/*.{ts,tsx,js,jsx}\"\n                    ]\n                }\n            },\n            \"test\": {\n                \"executor\": \"@nrwl/jest:jest\",\n                \"outputs\": [\n                    \"{workspaceRoot}/coverage/libs/design-system\"\n                ],\n                \"options\": {\n                    \"jestConfig\": \"libs/design-system/jest.config.ts\",\n                    \"passWithNoTests\": true\n                }\n            },\n            \"storybook\": {\n                \"executor\": \"@nrwl/storybook:storybook\",\n                \"options\": {\n                    \"uiFramework\": \"@storybook/react\",\n                    \"port\": 4400,\n                    \"staticDir\": [\n                        \"libs/design-system/.storybook/public\"\n                    ],\n                    \"config\": {\n                        \"configFolder\": \"libs/design-system/.storybook\"\n                    }\n                },\n                \"configurations\": {\n                    \"ci\": {\n                        \"quiet\": true\n                    }\n                }\n            },\n            \"build-storybook\": {\n                \"executor\": \"@nrwl/storybook:build\",\n                \"outputs\": [\n                    \"{options.outputPath}\"\n                ],\n                \"options\": {\n                    \"uiFramework\": \"@storybook/react\",\n                    \"outputPath\": \"dist/storybook/design-system\",\n                    \"staticDir\": [\n                        \"libs/design-system/.storybook/public\"\n                    ],\n                    \"config\": {\n                        \"configFolder\": \"libs/design-system/.storybook\"\n                    }\n                },\n                \"configurations\": {\n                    \"ci\": {\n                        \"quiet\": true\n                    }\n                }\n            },\n            \"deploy\": {\n                \"executor\": \"@nrwl/workspace:run-commands\",\n                \"options\": {\n                    \"command\": \"node tools/scripts/triggerDesignSystemDeploy.js\"\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "libs/design-system/postcss.config.js",
    "content": "module.exports = {\n    plugins: {\n        tailwindcss: {\n            config: './libs/design-system/tailwind.config.js',\n        },\n        autoprefixer: {},\n    },\n}\n"
  },
  {
    "path": "libs/design-system/src/index.ts",
    "content": "export * from './lib/AccordionRow'\nexport type { AccordionRowProps } from './lib/AccordionRow'\n\nexport * from './lib/Alert'\nexport type { AlertProps, AlertVariant } from './lib/Alert'\n\nexport * from './lib/Badge'\nexport type { BadgeVariant, BadgeProps } from './lib/Badge'\n\nexport * from './lib/Breadcrumb'\nexport type { BreadcrumbProps, BreadcrumbGroupProps } from './lib/Breadcrumb'\n\nexport * from './lib/Button'\nexport type { ButtonVariant, ButtonProps } from './lib/Button'\n\nexport * from './lib/Checkbox'\nexport type { CheckboxProps } from './lib/Checkbox'\n\nexport * from './lib/DatePicker'\nexport type { DatePickerProps } from './lib/DatePicker'\n\nexport * from './lib/FormGroup'\nexport type { FormGroupProps } from './lib/FormGroup'\n\nexport * from './lib/inputs'\nexport type {\n    InputProps,\n    InputColorHintColor,\n    InputColorHintProps,\n    InputCurrencyProps,\n    InputPasswordProps,\n} from './lib/inputs'\n\nexport * from './lib/IndexTabs'\n\nexport * from './lib/Listbox'\n\nexport * from './lib/LoadingPlaceholder'\n\nexport * from './lib/LoadingSpinner'\n\nexport * from './lib/Menu'\n\nexport * from './lib/Popover'\n\nexport * from './lib/Tab'\n\nexport * from './lib/Takeover'\n\nexport * from './lib/Toast'\nexport type { ToastProps, ToastVariant } from './lib/Toast'\n\nexport * from './lib/Toggle'\nexport type { ToggleProps } from './lib/Toggle'\n\nexport * from './lib/Tooltip'\nexport type { TooltipProps } from './lib/Tooltip'\n\nexport * from './lib/TrendLine'\nexport type { TrendLineProps } from './lib/TrendLine'\n\nexport * from './lib/Dialog'\nexport type { DialogProps } from './lib/Dialog'\n\nexport * from './lib/Slider'\nexport type { SliderProps } from './lib/Slider'\n\nexport * from './lib/Step'\n\nexport * from './lib/FractionalCircle'\nexport type { FractionalCircleProps } from './lib/FractionalCircle'\n\nexport * from './lib/RadioGroup'\n\nexport * from './lib/RTEditor'\n"
  },
  {
    "path": "libs/design-system/src/lib/AccordionRow/AccordionRow.spec.tsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react'\nimport { AccordionRow } from './'\n\ndescribe('AccordionRow', () => {\n    describe('when rendered with text content', () => {\n        it('should display the text', () => {\n            const component = render(<AccordionRow label=\"Label\">Hello, World!</AccordionRow>)\n\n            expect(screen.getByText('Label')).toBeInTheDocument()\n            expect(screen.getByText('Hello, World!')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when passed an `href` prop', () => {\n        it('should render as a link', () => {\n            const component = render(\n                <AccordionRow label=\"Label\" href=\"/to\">\n                    Hello, World!\n                </AccordionRow>\n            )\n\n            expect(screen.getByRole('link')).toHaveAttribute('href', '/to')\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when passed an `active` prop', () => {\n        it('should be highlighted', () => {\n            const component = render(\n                <AccordionRow label=\"Label\" active={true}>\n                    Hello, World!\n                </AccordionRow>\n            )\n\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when passed a `collapsible` prop', () => {\n        it('should render a caret for expanding and collapsing when collapsible', () => {\n            const component = render(\n                <AccordionRow label=\"Label\" collapsible={true}>\n                    Hello, World!\n                </AccordionRow>\n            )\n\n            expect(component).toMatchSnapshot()\n        })\n\n        it('should not render a caret when not collapsible', () => {\n            const component = render(\n                <AccordionRow label=\"Label\" collapsible={false}>\n                    Hello, World!\n                </AccordionRow>\n            )\n\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when pressed', () => {\n        it('should call the `onClick` callback', () => {\n            const onClickMock = jest.fn()\n            render(<AccordionRow onClick={onClickMock} collapsible={true} label=\"Label\" />)\n\n            fireEvent.click(screen.getByRole('button'))\n            expect(onClickMock).toHaveBeenCalledTimes(1)\n        })\n\n        it('should call the `onToggle` callback if collapsible', () => {\n            const onToggleMock = jest.fn()\n            render(<AccordionRow onToggle={onToggleMock} collapsible={true} label=\"Label\" />)\n\n            fireEvent.click(screen.getByRole('button'))\n            expect(onToggleMock).toHaveBeenCalledWith(false)\n\n            fireEvent.click(screen.getByRole('button'))\n            expect(onToggleMock).toHaveBeenCalledWith(true)\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/AccordionRow/AccordionRow.stories.tsx",
    "content": "import type { AccordionRowProps } from './AccordionRow'\nimport type { Story, Meta } from '@storybook/react'\n\nimport AccordionRow from './AccordionRow'\n\nexport default {\n    title: 'Components/AccordionRow',\n    component: AccordionRow,\n    parameters: { controls: { exclude: ['className', 'children'] } },\n    argTypes: {\n        label: {\n            control: 'text',\n            description: 'List label/title',\n        },\n    },\n    args: {\n        label: 'Level 0',\n        collapsible: true,\n    },\n} as Meta\n\nconst Template: Story<AccordionRowProps> = (args) => (\n    <AccordionRow {...args}>\n        <AccordionRow label=\"Level 1\" collapsible={true} level={1}>\n            <AccordionRow label=\"Level 2\" level={2} />\n        </AccordionRow>\n    </AccordionRow>\n)\n\nexport const Base = Template.bind({})\n"
  },
  {
    "path": "libs/design-system/src/lib/AccordionRow/AccordionRow.tsx",
    "content": "import type { HTMLAttributes } from 'react'\nimport React, { useEffect, useState } from 'react'\nimport AnimateHeight from 'react-animate-height'\nimport { RiArrowRightSFill as Caret } from 'react-icons/ri'\nimport Link from 'next/link'\nimport cn from 'classnames'\n\nconst LevelClassMap = {\n    0: { base: 'pl-3 bg-gray-500', interactions: 'hover:bg-gray-400 cursor-pointer' },\n    1: { base: 'pl-6 bg-gray-700', interactions: 'hover:bg-gray-600 cursor-pointer' },\n    2: { base: 'pl-14 bg-gray-800', interactions: 'hover:bg-gray-700 cursor-pointer' },\n}\n\nexport interface AccordionRowProps extends HTMLAttributes<HTMLElement> {\n    /** Label string or node */\n    label: string | React.ReactNode\n\n    /** Whether the label should be transformed to uppercase */\n    uppercase?: boolean\n\n    /** Level for indentation and color */\n    level?: keyof typeof LevelClassMap\n\n    /** Whether the AccordionRow can be collapsed/expanded */\n    collapsible?: boolean\n\n    /** Whether the AccordionRow is currently expanded */\n    expanded?: boolean\n\n    /** Optional link href */\n    href?: string\n\n    /** Whether the row is active (highlighted) */\n    active?: boolean\n\n    onClick?: () => void\n\n    onToggle?: (expanded: boolean) => void\n\n    className?: string\n\n    children?: React.ReactNode\n}\n\nfunction AccordionRow({\n    label,\n    uppercase = false,\n    level = 0,\n    collapsible = true,\n    expanded: expandedProp = true,\n    href,\n    active,\n    onClick,\n    onToggle,\n    className,\n    children,\n    ...rest\n}: AccordionRowProps): JSX.Element {\n    const [isExpanded, setIsExpanded] = useState<boolean>(expandedProp)\n\n    useEffect(() => setIsExpanded(expandedProp), [expandedProp])\n\n    const handleClick = () => {\n        onClick && onClick()\n        if (!collapsible) return\n\n        const expanded = !isExpanded\n        setIsExpanded(expanded)\n        onToggle && onToggle(expanded)\n    }\n\n    const component = (\n        <>\n            <div\n                className={cn(\n                    className,\n                    'py-3 pr-3 mb-0.5 flex items-center justify-between space-x-3 rounded-lg leading-none text-base text-white',\n                    collapsible && 'select-none',\n                    active && '!bg-cyan !bg-opacity-10',\n                    LevelClassMap[level].base,\n                    (collapsible || href) && LevelClassMap[level].interactions\n                )}\n                onClick={handleClick}\n                role={collapsible ? 'button' : undefined}\n                {...rest}\n            >\n                {collapsible && (\n                    <div className=\"shrink-0 w-4 text-gray-50\">\n                        <Caret\n                            className={cn(\n                                isExpanded && 'transform rotate-90',\n                                'w-5 h-5 transition-transform'\n                            )}\n                        />\n                    </div>\n                )}\n                <div className={cn('w-full', uppercase && 'uppercase')}>{label}</div>\n            </div>\n            {children && <AnimateHeight height={isExpanded ? 'auto' : 0}>{children}</AnimateHeight>}\n        </>\n    )\n\n    return href ? (\n        <Link href={href} legacyBehavior>\n            <a>{component}</a>\n        </Link>\n    ) : (\n        component\n    )\n}\n\nexport default AccordionRow\n"
  },
  {
    "path": "libs/design-system/src/lib/AccordionRow/__snapshots__/AccordionRow.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`AccordionRow when passed a \\`collapsible\\` prop should not render a caret when not collapsible 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"py-3 pr-3 mb-0.5 flex items-center justify-between space-x-3 rounded-lg leading-none text-base text-white pl-3 bg-gray-500\"\n      >\n        <div\n          class=\"w-full\"\n        >\n          Label\n        </div>\n      </div>\n      <div\n        aria-hidden=\"false\"\n        class=\"rah-static rah-static--height-auto \"\n        style=\"height: auto; overflow: visible;\"\n      >\n        <div>\n          Hello, World!\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"py-3 pr-3 mb-0.5 flex items-center justify-between space-x-3 rounded-lg leading-none text-base text-white pl-3 bg-gray-500\"\n    >\n      <div\n        class=\"w-full\"\n      >\n        Label\n      </div>\n    </div>\n    <div\n      aria-hidden=\"false\"\n      class=\"rah-static rah-static--height-auto \"\n      style=\"height: auto; overflow: visible;\"\n    >\n      <div>\n        Hello, World!\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`AccordionRow when passed a \\`collapsible\\` prop should render a caret for expanding and collapsing when collapsible 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"py-3 pr-3 mb-0.5 flex items-center justify-between space-x-3 rounded-lg leading-none text-base text-white select-none pl-3 bg-gray-500 hover:bg-gray-400 cursor-pointer\"\n        role=\"button\"\n      >\n        <div\n          class=\"shrink-0 w-4 text-gray-50\"\n        >\n          <svg\n            class=\"transform rotate-90 w-5 h-5 transition-transform\"\n            fill=\"currentColor\"\n            height=\"1em\"\n            stroke=\"currentColor\"\n            stroke-width=\"0\"\n            viewBox=\"0 0 24 24\"\n            width=\"1em\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <g>\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M16 12l-6 6V6z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          class=\"w-full\"\n        >\n          Label\n        </div>\n      </div>\n      <div\n        aria-hidden=\"false\"\n        class=\"rah-static rah-static--height-auto \"\n        style=\"height: auto; overflow: visible;\"\n      >\n        <div>\n          Hello, World!\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"py-3 pr-3 mb-0.5 flex items-center justify-between space-x-3 rounded-lg leading-none text-base text-white select-none pl-3 bg-gray-500 hover:bg-gray-400 cursor-pointer\"\n      role=\"button\"\n    >\n      <div\n        class=\"shrink-0 w-4 text-gray-50\"\n      >\n        <svg\n          class=\"transform rotate-90 w-5 h-5 transition-transform\"\n          fill=\"currentColor\"\n          height=\"1em\"\n          stroke=\"currentColor\"\n          stroke-width=\"0\"\n          viewBox=\"0 0 24 24\"\n          width=\"1em\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n        >\n          <g>\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M16 12l-6 6V6z\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        class=\"w-full\"\n      >\n        Label\n      </div>\n    </div>\n    <div\n      aria-hidden=\"false\"\n      class=\"rah-static rah-static--height-auto \"\n      style=\"height: auto; overflow: visible;\"\n    >\n      <div>\n        Hello, World!\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`AccordionRow when passed an \\`active\\` prop should be highlighted 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"py-3 pr-3 mb-0.5 flex items-center justify-between space-x-3 rounded-lg leading-none text-base text-white select-none !bg-cyan !bg-opacity-10 pl-3 bg-gray-500 hover:bg-gray-400 cursor-pointer\"\n        role=\"button\"\n      >\n        <div\n          class=\"shrink-0 w-4 text-gray-50\"\n        >\n          <svg\n            class=\"transform rotate-90 w-5 h-5 transition-transform\"\n            fill=\"currentColor\"\n            height=\"1em\"\n            stroke=\"currentColor\"\n            stroke-width=\"0\"\n            viewBox=\"0 0 24 24\"\n            width=\"1em\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <g>\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M16 12l-6 6V6z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          class=\"w-full\"\n        >\n          Label\n        </div>\n      </div>\n      <div\n        aria-hidden=\"false\"\n        class=\"rah-static rah-static--height-auto \"\n        style=\"height: auto; overflow: visible;\"\n      >\n        <div>\n          Hello, World!\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"py-3 pr-3 mb-0.5 flex items-center justify-between space-x-3 rounded-lg leading-none text-base text-white select-none !bg-cyan !bg-opacity-10 pl-3 bg-gray-500 hover:bg-gray-400 cursor-pointer\"\n      role=\"button\"\n    >\n      <div\n        class=\"shrink-0 w-4 text-gray-50\"\n      >\n        <svg\n          class=\"transform rotate-90 w-5 h-5 transition-transform\"\n          fill=\"currentColor\"\n          height=\"1em\"\n          stroke=\"currentColor\"\n          stroke-width=\"0\"\n          viewBox=\"0 0 24 24\"\n          width=\"1em\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n        >\n          <g>\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M16 12l-6 6V6z\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        class=\"w-full\"\n      >\n        Label\n      </div>\n    </div>\n    <div\n      aria-hidden=\"false\"\n      class=\"rah-static rah-static--height-auto \"\n      style=\"height: auto; overflow: visible;\"\n    >\n      <div>\n        Hello, World!\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`AccordionRow when passed an \\`href\\` prop should render as a link 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <a\n        href=\"/to\"\n      >\n        <div\n          class=\"py-3 pr-3 mb-0.5 flex items-center justify-between space-x-3 rounded-lg leading-none text-base text-white select-none pl-3 bg-gray-500 hover:bg-gray-400 cursor-pointer\"\n          role=\"button\"\n        >\n          <div\n            class=\"shrink-0 w-4 text-gray-50\"\n          >\n            <svg\n              class=\"transform rotate-90 w-5 h-5 transition-transform\"\n              fill=\"currentColor\"\n              height=\"1em\"\n              stroke=\"currentColor\"\n              stroke-width=\"0\"\n              viewBox=\"0 0 24 24\"\n              width=\"1em\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n            >\n              <g>\n                <path\n                  d=\"M0 0h24v24H0z\"\n                  fill=\"none\"\n                />\n                <path\n                  d=\"M16 12l-6 6V6z\"\n                />\n              </g>\n            </svg>\n          </div>\n          <div\n            class=\"w-full\"\n          >\n            Label\n          </div>\n        </div>\n        <div\n          aria-hidden=\"false\"\n          class=\"rah-static rah-static--height-auto \"\n          style=\"height: auto; overflow: visible;\"\n        >\n          <div>\n            Hello, World!\n          </div>\n        </div>\n      </a>\n    </div>\n  </body>,\n  \"container\": <div>\n    <a\n      href=\"/to\"\n    >\n      <div\n        class=\"py-3 pr-3 mb-0.5 flex items-center justify-between space-x-3 rounded-lg leading-none text-base text-white select-none pl-3 bg-gray-500 hover:bg-gray-400 cursor-pointer\"\n        role=\"button\"\n      >\n        <div\n          class=\"shrink-0 w-4 text-gray-50\"\n        >\n          <svg\n            class=\"transform rotate-90 w-5 h-5 transition-transform\"\n            fill=\"currentColor\"\n            height=\"1em\"\n            stroke=\"currentColor\"\n            stroke-width=\"0\"\n            viewBox=\"0 0 24 24\"\n            width=\"1em\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <g>\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M16 12l-6 6V6z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          class=\"w-full\"\n        >\n          Label\n        </div>\n      </div>\n      <div\n        aria-hidden=\"false\"\n        class=\"rah-static rah-static--height-auto \"\n        style=\"height: auto; overflow: visible;\"\n      >\n        <div>\n          Hello, World!\n        </div>\n      </div>\n    </a>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`AccordionRow when rendered with text content should display the text 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"py-3 pr-3 mb-0.5 flex items-center justify-between space-x-3 rounded-lg leading-none text-base text-white select-none pl-3 bg-gray-500 hover:bg-gray-400 cursor-pointer\"\n        role=\"button\"\n      >\n        <div\n          class=\"shrink-0 w-4 text-gray-50\"\n        >\n          <svg\n            class=\"transform rotate-90 w-5 h-5 transition-transform\"\n            fill=\"currentColor\"\n            height=\"1em\"\n            stroke=\"currentColor\"\n            stroke-width=\"0\"\n            viewBox=\"0 0 24 24\"\n            width=\"1em\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <g>\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M16 12l-6 6V6z\"\n              />\n            </g>\n          </svg>\n        </div>\n        <div\n          class=\"w-full\"\n        >\n          Label\n        </div>\n      </div>\n      <div\n        aria-hidden=\"false\"\n        class=\"rah-static rah-static--height-auto \"\n        style=\"height: auto; overflow: visible;\"\n      >\n        <div>\n          Hello, World!\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"py-3 pr-3 mb-0.5 flex items-center justify-between space-x-3 rounded-lg leading-none text-base text-white select-none pl-3 bg-gray-500 hover:bg-gray-400 cursor-pointer\"\n      role=\"button\"\n    >\n      <div\n        class=\"shrink-0 w-4 text-gray-50\"\n      >\n        <svg\n          class=\"transform rotate-90 w-5 h-5 transition-transform\"\n          fill=\"currentColor\"\n          height=\"1em\"\n          stroke=\"currentColor\"\n          stroke-width=\"0\"\n          viewBox=\"0 0 24 24\"\n          width=\"1em\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n        >\n          <g>\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M16 12l-6 6V6z\"\n            />\n          </g>\n        </svg>\n      </div>\n      <div\n        class=\"w-full\"\n      >\n        Label\n      </div>\n    </div>\n    <div\n      aria-hidden=\"false\"\n      class=\"rah-static rah-static--height-auto \"\n      style=\"height: auto; overflow: visible;\"\n    >\n      <div>\n        Hello, World!\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/AccordionRow/index.ts",
    "content": "export { default as AccordionRow } from './AccordionRow'\nexport type { AccordionRowProps } from './AccordionRow'\n"
  },
  {
    "path": "libs/design-system/src/lib/Alert/Alert.stories.tsx",
    "content": "import type { Meta, Story } from '@storybook/react'\nimport type { AlertProps } from './Alert'\nimport { RiMailCheckLine as CustomIcon } from 'react-icons/ri'\n\nimport Alert from './Alert'\n\nexport default {\n    title: 'Components/Alert',\n    component: Alert,\n    parameters: { controls: { exclude: ['onClose'] } },\n    argTypes: {\n        variant: {\n            control: { type: 'select' },\n        },\n        icon: {\n            table: {\n                disable: true,\n            },\n        },\n    },\n    args: {\n        children: 'Alert message!',\n        isVisible: true,\n    },\n} as Meta\n\nconst Template: Story<AlertProps> = (args) => {\n    return <Alert {...args} />\n}\n\nexport const Base = Template.bind({})\n\nexport const Info = Template.bind({})\nInfo.args = { variant: 'info' }\n\nexport const Error = Template.bind({})\nError.args = { variant: 'error' }\n\nexport const Success = Template.bind({})\nSuccess.args = { variant: 'success' }\n\nexport const WithCloseButton = Template.bind({})\nWithCloseButton.args = {\n    onClose: () => alert('onClose()'),\n    children:\n        'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Soluta sequi ipsam eligendi a cumque corrupti eum obcaecati perspiciatis. Nobis in ab illo et sequi explicabo quisquam dolor corrupti totam architecto.',\n}\n\nexport const WithCustomIcon = Template.bind({})\nWithCustomIcon.args = {\n    children: 'I have a custom icon',\n    icon: CustomIcon,\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/Alert/Alert.tsx",
    "content": "import type { ReactNode } from 'react'\nimport type { IconType } from 'react-icons'\nimport classNames from 'classnames'\nimport {\n    RiCheckboxCircleLine as SuccessIcon,\n    RiCloseFill as CloseIcon,\n    RiErrorWarningLine as ErrorIcon,\n    RiInformationLine as InfoIcon,\n} from 'react-icons/ri'\n\nexport type AlertVariant = 'info' | 'error' | 'success'\n\nconst backgroundVariants: { [key in AlertVariant]: string } = Object.freeze({\n    info: 'bg-gray-400 text-white',\n    error: 'bg-red text-red bg-opacity-10',\n    success: 'bg-teal text-teal bg-opacity-10',\n})\n\nconst iconVariants: { [key in AlertVariant]: IconType } = Object.freeze({\n    info: InfoIcon,\n    error: ErrorIcon,\n    success: SuccessIcon,\n})\n\nexport interface AlertProps {\n    // Indicates if Alert is visible or not.\n    isVisible: boolean\n    // Renders a close button a callback is passed.\n    onClose?: () => void\n    // Text/Content displayed in the Alert.\n    children: ReactNode\n    // Alert variants.\n    variant?: AlertVariant\n    // Custom icon\n    icon?: IconType\n    className?: string\n}\n\nfunction Alert({\n    isVisible,\n    onClose,\n    children,\n    className,\n    icon,\n    variant = 'info',\n}: AlertProps): JSX.Element | null {\n    // For now we can close it abruptly to avoid spending time on any overoptimization, but this can\n    // be later be improved to use a transition instead.\n    if (!isVisible) {\n        return null\n    }\n\n    const Icon = icon || iconVariants[variant]\n\n    return (\n        <div className={classNames(className, 'rounded flex ' + backgroundVariants[variant])}>\n            <div className=\"py-2 flex\">\n                <div className=\"shrink-0 ml-4\">{<Icon fontSize={20} />}</div>\n\n                <span className=\"text-base ml-2 mr-1\">{children}</span>\n            </div>\n\n            {onClose && (\n                <div className=\"ml-auto items-start mt-1.5 mr-4\">\n                    <button onClick={onClose} title=\"Close\">\n                        <CloseIcon fontSize={24} />\n                    </button>\n                </div>\n            )}\n        </div>\n    )\n}\n\nexport default Alert\n"
  },
  {
    "path": "libs/design-system/src/lib/Alert/index.ts",
    "content": "export { default as Alert } from './Alert'\nexport type { AlertProps, AlertVariant } from './Alert'\n"
  },
  {
    "path": "libs/design-system/src/lib/Badge/Badge.spec.tsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport { Badge } from './'\n\ndescribe('Badge', () => {\n    describe('when rendered with text', () => {\n        it('should display the text', () => {\n            const component = render(<Badge variant=\"teal\">Hello, World!</Badge>)\n\n            expect(screen.getByText('Hello, World!')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when passed a true `highlighted` prop', () => {\n        it('should render as a highlighted Badge', () => {\n            const component = render(\n                <Badge variant=\"teal\" highlighted={true}>\n                    Hello, World!\n                </Badge>\n            )\n\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when passed an `as` prop', () => {\n        it('should render as the specified element', () => {\n            const component = render(\n                <Badge variant=\"teal\" as=\"a\">\n                    Hello, World!\n                </Badge>\n            )\n            // Ideally we'd actually assert the tag name here, but I can't see a good way to do that\n            expect(component).toMatchSnapshot()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Badge/Badge.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { BadgeProps } from './Badge'\n\nimport Badge from './Badge'\n\nexport default {\n    title: 'Components/Badge',\n    component: Badge,\n    parameters: { controls: { exclude: ['as', 'className'] } },\n    argTypes: {\n        children: {\n            control: 'text',\n            description: 'Badge content',\n        },\n    },\n    args: {\n        children: '+4.5M',\n        variant: 'teal',\n    },\n} as Meta\n\nconst Template: Story<BadgeProps> = (args) => <Badge {...args} />\n\nexport const Base = Template.bind({})\n\nexport const Highlighted = Template.bind({})\nHighlighted.args = { highlighted: true }\n"
  },
  {
    "path": "libs/design-system/src/lib/Badge/Badge.tsx",
    "content": "import type { ReactNode, HTMLAttributes } from 'react'\nimport classNames from 'classnames'\n\nconst BadgeVariants = Object.freeze({\n    teal: {\n        normal: 'text-teal bg-teal bg-opacity-10',\n        highlighted: 'text-gray-800 bg-teal',\n    },\n    red: {\n        normal: 'text-red bg-red bg-opacity-10',\n        highlighted: 'text-gray-800 bg-red',\n    },\n    gray: {\n        normal: 'text-white bg-gray-700',\n        highlighted: 'text-gray-800 bg-gray-100',\n    },\n    cyan: {\n        normal: 'text-cyan bg-cyan bg-opacity-10',\n        highlighted: 'text-gray-800 bg-cyan',\n    },\n    plain: {\n        normal: '',\n        highlighted: '',\n    },\n    warn: {\n        normal: 'text-yellow bg-yellow bg-opacity-10',\n        highlighted: 'text-gray-800 bg-yellow',\n    },\n})\n\nexport type BadgeVariant = keyof typeof BadgeVariants\n\nconst SizeVariant = Object.freeze({\n    sm: 'px-1.5 py-1 text-sm',\n    md: 'px-2 py-1 text-base',\n})\n\nexport interface BadgeProps extends HTMLAttributes<HTMLElement> {\n    variant?: BadgeVariant\n\n    size?: 'sm' | 'md'\n\n    /** Whether the badge is in a brighter highlighted state */\n    highlighted?: boolean\n\n    /** Whether the badge will be used to display numerical data */\n    numeric?: boolean\n\n    /** Element to use (defaults to <div>) */\n    as?: React.ElementType\n\n    className?: string\n\n    children: ReactNode\n}\n\nfunction Badge({\n    variant = 'teal',\n    size = 'md',\n    highlighted = false,\n    numeric = true,\n    as = 'div',\n    className,\n    children,\n    ...rest\n}: BadgeProps): JSX.Element {\n    const classes = classNames(\n        className,\n        BadgeVariants[variant][highlighted ? 'highlighted' : 'normal'],\n        SizeVariant[size],\n        numeric && 'tabular-nums',\n        'inline-block font-medium rounded'\n    )\n\n    const Tag = as\n\n    return (\n        <Tag className={classes} {...rest}>\n            {children}\n        </Tag>\n    )\n}\n\nexport default Badge\n"
  },
  {
    "path": "libs/design-system/src/lib/Badge/__snapshots__/Badge.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Badge when passed a true \\`highlighted\\` prop should render as a highlighted Badge 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"text-gray-800 bg-teal px-2 py-1 text-base tabular-nums inline-block font-medium rounded\"\n      >\n        Hello, World!\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"text-gray-800 bg-teal px-2 py-1 text-base tabular-nums inline-block font-medium rounded\"\n    >\n      Hello, World!\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Badge when passed an \\`as\\` prop should render as the specified element 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <a\n        class=\"text-teal bg-teal bg-opacity-10 px-2 py-1 text-base tabular-nums inline-block font-medium rounded\"\n      >\n        Hello, World!\n      </a>\n    </div>\n  </body>,\n  \"container\": <div>\n    <a\n      class=\"text-teal bg-teal bg-opacity-10 px-2 py-1 text-base tabular-nums inline-block font-medium rounded\"\n    >\n      Hello, World!\n    </a>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Badge when rendered with text should display the text 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"text-teal bg-teal bg-opacity-10 px-2 py-1 text-base tabular-nums inline-block font-medium rounded\"\n      >\n        Hello, World!\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"text-teal bg-teal bg-opacity-10 px-2 py-1 text-base tabular-nums inline-block font-medium rounded\"\n    >\n      Hello, World!\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Badge/index.ts",
    "content": "export { default as Badge } from './Badge'\nexport type { BadgeVariant, BadgeProps } from './Badge'\n"
  },
  {
    "path": "libs/design-system/src/lib/Breadcrumb/Breadcrumb.spec.tsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport Breadcrumb from './Breadcrumb'\n\ndescribe('Breadcrumbs', () => {\n    describe('when rendered with text and hrefs', () => {\n        it('should display the linked text', () => {\n            const component = render(\n                <Breadcrumb.Group>\n                    <Breadcrumb href=\"/example\">Example</Breadcrumb>\n                    <Breadcrumb href=\"/example2\">Example 2</Breadcrumb>\n                </Breadcrumb.Group>\n            )\n\n            expect(screen.getByText('Example')).toBeInTheDocument()\n            expect(screen.getByText('Example 2')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Breadcrumb/Breadcrumb.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { BreadcrumbProps } from './Breadcrumb'\n\nimport Breadcrumb from './Breadcrumb'\n\nexport default {\n    title: 'Components/Breadcrumbs',\n    component: Breadcrumb,\n    parameters: { controls: { exclude: ['as', 'className'] } },\n    argTypes: {},\n    args: {},\n} as Meta\n\nconst Template: Story<BreadcrumbProps> = (args) => (\n    <Breadcrumb.Group {...args}>\n        <Breadcrumb href=\"/\">asdf</Breadcrumb>\n        <Breadcrumb>asdf</Breadcrumb>\n    </Breadcrumb.Group>\n)\n\nexport const Base = Template.bind({})\n"
  },
  {
    "path": "libs/design-system/src/lib/Breadcrumb/Breadcrumb.tsx",
    "content": "import type { ReactNode, HTMLAttributes } from 'react'\nimport { Fragment } from 'react'\nimport classNames from 'classnames'\nimport { RiArrowRightSLine } from 'react-icons/ri'\nimport Link from 'next/link'\n\nexport interface BreadcrumbProps {\n    href?: string\n    className?: string\n    children: ReactNode\n}\n\nfunction Breadcrumb({ href, className, children }: BreadcrumbProps): JSX.Element {\n    const Tag = href ? 'a' : 'span'\n    const inner = <Tag className={classNames('', className)}>{children}</Tag>\n\n    return href ? (\n        <Link href={href} legacyBehavior>\n            {inner}\n        </Link>\n    ) : (\n        inner\n    )\n}\n\nexport interface BreadcrumbGroupProps extends HTMLAttributes<HTMLDivElement> {\n    className?: string\n    children: ReactNode[]\n}\n\nfunction Group({ className, children, ...rest }: BreadcrumbGroupProps): JSX.Element {\n    return (\n        <div\n            className={classNames('flex items-center space-x-1 text-gray-100 text-base', className)}\n            {...rest}\n        >\n            {children.map((child, idx) => (\n                <Fragment key={`${child}-${idx}`}>\n                    <span className={classNames(idx === children.length - 1 && 'text-white')}>\n                        {child}\n                    </span>\n                    {idx < children.length - 1 && <RiArrowRightSLine className=\"w-4 h-4\" />}\n                </Fragment>\n            ))}\n        </div>\n    )\n}\n\nexport default Object.assign(Breadcrumb, { Group })\n"
  },
  {
    "path": "libs/design-system/src/lib/Breadcrumb/__snapshots__/Breadcrumb.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Breadcrumbs when rendered with text and hrefs should display the linked text 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"flex items-center space-x-1 text-gray-100 text-base\"\n      >\n        <span\n          class=\"\"\n        >\n          <a\n            class=\"\"\n            href=\"/example\"\n          >\n            Example\n          </a>\n        </span>\n        <svg\n          class=\"w-4 h-4\"\n          fill=\"currentColor\"\n          height=\"1em\"\n          stroke=\"currentColor\"\n          stroke-width=\"0\"\n          viewBox=\"0 0 24 24\"\n          width=\"1em\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n        >\n          <g>\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M13.172 12l-4.95-4.95 1.414-1.414L16 12l-6.364 6.364-1.414-1.414z\"\n            />\n          </g>\n        </svg>\n        <span\n          class=\"text-white\"\n        >\n          <a\n            class=\"\"\n            href=\"/example2\"\n          >\n            Example 2\n          </a>\n        </span>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"flex items-center space-x-1 text-gray-100 text-base\"\n    >\n      <span\n        class=\"\"\n      >\n        <a\n          class=\"\"\n          href=\"/example\"\n        >\n          Example\n        </a>\n      </span>\n      <svg\n        class=\"w-4 h-4\"\n        fill=\"currentColor\"\n        height=\"1em\"\n        stroke=\"currentColor\"\n        stroke-width=\"0\"\n        viewBox=\"0 0 24 24\"\n        width=\"1em\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <g>\n          <path\n            d=\"M0 0h24v24H0z\"\n            fill=\"none\"\n          />\n          <path\n            d=\"M13.172 12l-4.95-4.95 1.414-1.414L16 12l-6.364 6.364-1.414-1.414z\"\n          />\n        </g>\n      </svg>\n      <span\n        class=\"text-white\"\n      >\n        <a\n          class=\"\"\n          href=\"/example2\"\n        >\n          Example 2\n        </a>\n      </span>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Breadcrumb/index.ts",
    "content": "export { default as Breadcrumb } from './Breadcrumb'\nexport type { BreadcrumbProps, BreadcrumbGroupProps } from './Breadcrumb'\n"
  },
  {
    "path": "libs/design-system/src/lib/Button/Button.spec.tsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react'\nimport Button from './Button'\n\ndescribe('Button', () => {\n    describe('when rendered with text', () => {\n        it('should display the text', () => {\n            const component = render(<Button>Hello, World!</Button>)\n\n            expect(screen.getByText('Hello, World!')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when pressed', () => {\n        it('should call the `onClick` callback', () => {\n            const onClickMock = jest.fn()\n            render(<Button onClick={onClickMock}>Hello, World!</Button>)\n\n            fireEvent.click(screen.getByRole('button'))\n            expect(onClickMock).toHaveBeenCalledTimes(1)\n        })\n    })\n\n    describe('when passed an `href` value', () => {\n        it('should render as an <a> element', () => {\n            const component = render(<Button href=\"https://example.com\">Hello, World!</Button>)\n            // Ideally we'd actually assert the tag name here, but I can't see a good way to do that\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when passed an `as` prop', () => {\n        it('should render as the specified element', () => {\n            const component = render(<Button as=\"div\">Hello, World!</Button>)\n            // Ideally we'd actually assert the tag name here, but I can't see a good way to do that\n            expect(component).toMatchSnapshot()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Button/Button.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { ButtonProps } from './Button'\n\nimport Button from './Button'\n\nexport default {\n    title: 'Components/Button',\n    component: Button,\n    parameters: { controls: { exclude: ['as', 'className', 'onClick'] } },\n    argTypes: {\n        children: {\n            control: 'text',\n            description: 'Button content',\n        },\n    },\n    args: {\n        children: 'Click Me!',\n        variant: 'primary',\n    },\n} as Meta\n\nconst Template: Story<ButtonProps> = (args) => <Button {...args} />\n\nexport const Base = Template.bind({})\n"
  },
  {
    "path": "libs/design-system/src/lib/Button/Button.tsx",
    "content": "import type { PropsWithChildren, ReactNode } from 'react'\nimport React from 'react'\nimport classNames from 'classnames'\n\nconst ButtonVariants = Object.freeze({\n    primary:\n        'px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan',\n    secondary:\n        'px-4 py-2 rounded text-base bg-gray-500 text-gray-25 shadow hover:bg-gray-400 focus:bg-gray-400 focus:ring-gray-400',\n    input: 'px-4 py-2 rounded text-base bg-transparent text-gray-25 border border-gray-200 shadow focus:bg-gray-500 focus:ring-gray-400',\n    link: 'px-4 py-2 rounded text-base text-cyan hover:text-cyan-400 focus:text-cyan-300 focus:ring-cyan',\n    icon: 'p-0 w-8 h-8 rounded text-2xl text-gray-25 hover:bg-gray-300 focus:bg-gray-200 focus:ring-gray-400',\n    profileIcon: 'p-0 w-12 h-12 rounded text-2xl text-gray-25',\n    danger: 'px-4 py-2 rounded text-base bg-red text-gray-700 shadow hover:bg-red-400 focus:bg-red-400 focus:ring-red',\n    warn: 'px-4 py-2 rounded text-base bg-gray-500 text-red-500 shadow hover:bg-gray-400 focus:bg-gray-400 focus:ring-red',\n})\n\nexport type ButtonVariant = keyof typeof ButtonVariants\n\nexport type ButtonProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'as'> &\n    PropsWithChildren<{\n        variant?: ButtonVariant\n\n        onClick?: () => void\n\n        disabled?: boolean\n\n        /** Display as a full-width block */\n        fullWidth?: boolean\n\n        /** Will use an 'a' tag if specified */\n        href?: string\n\n        /** 'a' download attribute */\n        download?: string\n\n        /** 'a' target attribute */\n        target?: string\n\n        /** Element to use (default is <button> or <a> depending on props) */\n        as?: React.ElementType\n\n        className?: string\n\n        leftIcon?: ReactNode\n\n        /** Display spinner based on the value passed */\n        isLoading?: boolean\n    }>\n\nfunction Button(\n    {\n        variant = 'primary',\n        fullWidth = false,\n        href,\n        as,\n        children,\n        className,\n        leftIcon,\n        isLoading = false,\n        ...rest\n    }: ButtonProps,\n    ref: React.Ref<HTMLElement>\n): JSX.Element {\n    const combinedClassName = classNames(\n        className,\n        ButtonVariants[variant],\n        fullWidth && 'w-full',\n        'inline-flex items-center justify-center text-center',\n        'font-medium leading-6 whitespace-nowrap select-none cursor-pointer',\n        'focus:outline-none focus:ring focus:ring-opacity-60',\n        'disabled:opacity-50 disabled:pointer-events-none',\n        'transition-colors duration-200'\n    )\n\n    const Tag = as || (href ? 'a' : 'button')\n\n    return (\n        <Tag\n            ref={ref}\n            className={combinedClassName}\n            role=\"button\"\n            {...(Tag === 'button' && { type: 'button' })}\n            {...(href && { href })} // Adds href only if exists.\n            {...rest}\n        >\n            {leftIcon && <span className=\"mr-1.5\">{leftIcon}</span>}\n            {children}\n            {isLoading && Tag === 'button' && (\n                <span className=\"ml-1.5\">\n                    <Spinner fill={variant === 'primary' ? '#16161A' : '#FFFFFF'} />\n                </span>\n            )}\n        </Tag>\n    )\n}\n\n// Add a spinner to Button component and toggle it via flag\n\ninterface SpinnerProps {\n    fill: string\n}\n\nfunction Spinner({ fill }: SpinnerProps): JSX.Element {\n    return (\n        <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path\n                d=\"M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z\"\n                opacity=\".25\"\n                fill={fill}\n            />\n            <path\n                d=\"M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z\"\n                fill={fill}\n            >\n                <animateTransform\n                    attributeName=\"transform\"\n                    type=\"rotate\"\n                    dur=\"0.75s\"\n                    values=\"0 12 12;360 12 12\"\n                    repeatCount=\"indefinite\"\n                />\n            </path>\n        </svg>\n    )\n}\n\nexport default React.forwardRef(Button)\n"
  },
  {
    "path": "libs/design-system/src/lib/Button/__snapshots__/Button.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Button when passed an \\`as\\` prop should render as the specified element 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n        role=\"button\"\n      >\n        Hello, World!\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n      role=\"button\"\n    >\n      Hello, World!\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Button when passed an \\`href\\` value should render as an <a> element 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <a\n        class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n        href=\"https://example.com\"\n        role=\"button\"\n      >\n        Hello, World!\n      </a>\n    </div>\n  </body>,\n  \"container\": <div>\n    <a\n      class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n      href=\"https://example.com\"\n      role=\"button\"\n    >\n      Hello, World!\n    </a>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Button when rendered with text should display the text 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <button\n        class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n        role=\"button\"\n        type=\"button\"\n      >\n        Hello, World!\n      </button>\n    </div>\n  </body>,\n  \"container\": <div>\n    <button\n      class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n      role=\"button\"\n      type=\"button\"\n    >\n      Hello, World!\n    </button>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Button/index.ts",
    "content": "export { default as Button } from './Button'\nexport type { ButtonVariant, ButtonProps } from './Button'\n"
  },
  {
    "path": "libs/design-system/src/lib/Checkbox/Checkbox.spec.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-empty-function */\nimport { fireEvent, render, screen } from '@testing-library/react'\nimport { Checkbox } from '.'\n\ndescribe('Checkbox', () => {\n    describe('when rendered with a label', () => {\n        it('should display the label', () => {\n            const component = render(<Checkbox onChange={() => {}} label=\"Checkbox label\" />)\n\n            expect(screen.getByText('Checkbox label')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when pressed', () => {\n        it('should call the `onChange` callback', () => {\n            const onChangeMock = jest.fn()\n            render(<Checkbox onChange={onChangeMock} />)\n\n            fireEvent.click(screen.getByRole('switch'))\n            expect(onChangeMock).toHaveBeenCalledTimes(1)\n        })\n    })\n\n    describe('when toggle is checked', () => {\n        it('should display a checkmark', () => {\n            const component = render(<Checkbox onChange={() => {}} checked />)\n\n            expect(component).toMatchSnapshot()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Checkbox/Checkbox.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { CheckboxProps } from './Checkbox'\nimport { useState } from 'react'\n\nimport Checkbox from './Checkbox'\n\nexport default {\n    title: 'Components/Checkbox',\n    component: Checkbox,\n    parameters: { controls: { exclude: ['className', 'onClick'] } },\n    args: {\n        label: 'Check something',\n    },\n} as Meta\n\nconst Template: Story<CheckboxProps> = (args) => {\n    const [enabled, setEnabled] = useState(args.checked)\n\n    return <Checkbox {...args} checked={enabled} onChange={(checked) => setEnabled(checked)} />\n}\n\nexport const Base = Template.bind({})\n"
  },
  {
    "path": "libs/design-system/src/lib/Checkbox/Checkbox.tsx",
    "content": "import { Switch } from '@headlessui/react'\nimport classNames from 'classnames'\nimport type { ReactNode } from 'react'\n\nexport interface CheckboxProps {\n    label?: ReactNode\n    checked?: boolean\n    onChange?: (checked: boolean) => void\n    className?: string\n    wrapperClassName?: string\n    disabled?: boolean\n    dark?: boolean\n}\n\nexport default function Checkbox({\n    label,\n    checked = false,\n    className,\n    wrapperClassName,\n    disabled = false,\n    onChange,\n    dark = false,\n    ...rest\n}: CheckboxProps): JSX.Element {\n    return (\n        <Switch.Group>\n            <div\n                className={classNames(\n                    wrapperClassName,\n                    'flex items-center space-x-3 text-white text-base'\n                )}\n            >\n                <Switch\n                    checked={checked}\n                    disabled={disabled}\n                    className={classNames(\n                        className,\n                        !checked && dark && 'bg-black',\n                        checked ? 'bg-cyan border-cyan focus:ring-cyan' : 'focus:ring-gray',\n                        disabled && 'bg-gray-700 cursor-not-allowed',\n                        'shrink-0 flex items-center justify-center w-4 h-4 border border-gray-200 rounded cursor-pointer',\n                        'focus:ring-2 focus:outline-none focus:ring-opacity-60',\n                        'transition-colors ease-in-out duration-200'\n                    )}\n                    onChange={(checked: boolean) => onChange && onChange(checked)}\n                    {...rest}\n                >\n                    {checked && (\n                        <svg\n                            viewBox=\"0 0 9 8\"\n                            className=\"text-black h-2 ml-px\"\n                            fill=\"currentColor\"\n                            xmlns=\"http://www.w3.org/2000/svg\"\n                        >\n                            <path d=\"M2.5279 7.00293C2.61863 7.09961 2.74531 7.15445 2.8779 7.15445C3.01049 7.15445 3.13717 7.09961 3.2279 7.00293L8.8479 1.38293C8.94256 1.28905 8.9958 1.16125 8.9958 1.02793C8.9958 0.89461 8.94256 0.766812 8.8479 0.672929L8.3179 0.142929C8.12348 -0.0476429 7.81232 -0.0476429 7.6179 0.142929L2.8779 4.88293L1.3779 3.39293C1.28717 3.29625 1.16049 3.24141 1.0279 3.24141C0.895313 3.24141 0.768633 3.29625 0.677899 3.39293L0.147899 3.92293C0.0532428 4.01681 0 4.14461 0 4.27793C0 4.41125 0.0532428 4.53904 0.147899 4.63293L2.5279 7.00293Z\" />\n                        </svg>\n                    )}\n                </Switch>\n                {label && <Switch.Label>{label}</Switch.Label>}\n            </div>\n        </Switch.Group>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/Checkbox/__snapshots__/Checkbox.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Checkbox when rendered with a label should display the label 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"flex items-center space-x-3 text-white text-base\"\n      >\n        <button\n          aria-checked=\"false\"\n          aria-labelledby=\"headlessui-label-:r1:\"\n          class=\"focus:ring-gray shrink-0 flex items-center justify-center w-4 h-4 border border-gray-200 rounded cursor-pointer focus:ring-2 focus:outline-none focus:ring-opacity-60 transition-colors ease-in-out duration-200\"\n          data-headlessui-state=\"\"\n          id=\"headlessui-switch-:r0:\"\n          role=\"switch\"\n          tabindex=\"0\"\n          type=\"button\"\n        />\n        <label\n          id=\"headlessui-label-:r1:\"\n        >\n          Checkbox label\n        </label>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"flex items-center space-x-3 text-white text-base\"\n    >\n      <button\n        aria-checked=\"false\"\n        aria-labelledby=\"headlessui-label-:r1:\"\n        class=\"focus:ring-gray shrink-0 flex items-center justify-center w-4 h-4 border border-gray-200 rounded cursor-pointer focus:ring-2 focus:outline-none focus:ring-opacity-60 transition-colors ease-in-out duration-200\"\n        data-headlessui-state=\"\"\n        id=\"headlessui-switch-:r0:\"\n        role=\"switch\"\n        tabindex=\"0\"\n        type=\"button\"\n      />\n      <label\n        id=\"headlessui-label-:r1:\"\n      >\n        Checkbox label\n      </label>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Checkbox when toggle is checked should display a checkmark 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"flex items-center space-x-3 text-white text-base\"\n      >\n        <button\n          aria-checked=\"true\"\n          class=\"bg-cyan border-cyan focus:ring-cyan shrink-0 flex items-center justify-center w-4 h-4 border border-gray-200 rounded cursor-pointer focus:ring-2 focus:outline-none focus:ring-opacity-60 transition-colors ease-in-out duration-200\"\n          data-headlessui-state=\"checked\"\n          id=\"headlessui-switch-:r3:\"\n          role=\"switch\"\n          tabindex=\"0\"\n          type=\"button\"\n        >\n          <svg\n            class=\"text-black h-2 ml-px\"\n            fill=\"currentColor\"\n            viewBox=\"0 0 9 8\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <path\n              d=\"M2.5279 7.00293C2.61863 7.09961 2.74531 7.15445 2.8779 7.15445C3.01049 7.15445 3.13717 7.09961 3.2279 7.00293L8.8479 1.38293C8.94256 1.28905 8.9958 1.16125 8.9958 1.02793C8.9958 0.89461 8.94256 0.766812 8.8479 0.672929L8.3179 0.142929C8.12348 -0.0476429 7.81232 -0.0476429 7.6179 0.142929L2.8779 4.88293L1.3779 3.39293C1.28717 3.29625 1.16049 3.24141 1.0279 3.24141C0.895313 3.24141 0.768633 3.29625 0.677899 3.39293L0.147899 3.92293C0.0532428 4.01681 0 4.14461 0 4.27793C0 4.41125 0.0532428 4.53904 0.147899 4.63293L2.5279 7.00293Z\"\n            />\n          </svg>\n        </button>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"flex items-center space-x-3 text-white text-base\"\n    >\n      <button\n        aria-checked=\"true\"\n        class=\"bg-cyan border-cyan focus:ring-cyan shrink-0 flex items-center justify-center w-4 h-4 border border-gray-200 rounded cursor-pointer focus:ring-2 focus:outline-none focus:ring-opacity-60 transition-colors ease-in-out duration-200\"\n        data-headlessui-state=\"checked\"\n        id=\"headlessui-switch-:r3:\"\n        role=\"switch\"\n        tabindex=\"0\"\n        type=\"button\"\n      >\n        <svg\n          class=\"text-black h-2 ml-px\"\n          fill=\"currentColor\"\n          viewBox=\"0 0 9 8\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n        >\n          <path\n            d=\"M2.5279 7.00293C2.61863 7.09961 2.74531 7.15445 2.8779 7.15445C3.01049 7.15445 3.13717 7.09961 3.2279 7.00293L8.8479 1.38293C8.94256 1.28905 8.9958 1.16125 8.9958 1.02793C8.9958 0.89461 8.94256 0.766812 8.8479 0.672929L8.3179 0.142929C8.12348 -0.0476429 7.81232 -0.0476429 7.6179 0.142929L2.8779 4.88293L1.3779 3.39293C1.28717 3.29625 1.16049 3.24141 1.0279 3.24141C0.895313 3.24141 0.768633 3.29625 0.677899 3.39293L0.147899 3.92293C0.0532428 4.01681 0 4.14461 0 4.27793C0 4.41125 0.0532428 4.53904 0.147899 4.63293L2.5279 7.00293Z\"\n          />\n        </svg>\n      </button>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Checkbox/index.ts",
    "content": "export { default as Checkbox } from './Checkbox'\nexport type { CheckboxProps } from './Checkbox'\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePicker.spec.tsx",
    "content": "import user from '@testing-library/user-event'\nimport { fireEvent, render, screen, waitFor } from '@testing-library/react'\nimport { DatePicker } from './'\nimport { DateTime } from 'luxon'\n\n// DatePicker configuration\nconst minDate = DateTime.now().minus({ years: 2 })\nconst maxDate = DateTime.now()\n\ndescribe('<DatePicker />', () => {\n    describe('When datepicker is closed', () => {\n        it('should have placeholder when empty', () => {\n            const onChangeMock = jest.fn()\n\n            const component = render(\n                <DatePicker\n                    name=\"picker\"\n                    minCalendarDate={minDate.toISODate()}\n                    maxCalendarDate={maxDate.toISODate()}\n                    value={null}\n                    onChange={onChangeMock}\n                />\n            )\n\n            expect(screen.getByPlaceholderText('MM / DD / YYYY')).toBeInTheDocument()\n            expect(screen.queryByText('Cancel')).not.toBeInTheDocument()\n            expect(screen.queryByText('Apply')).not.toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n\n        it('should have date when valid', () => {\n            const onChangeMock = jest.fn()\n\n            const component = render(\n                <DatePicker\n                    name=\"picker\"\n                    minCalendarDate={minDate.toISODate()}\n                    maxCalendarDate={maxDate.toISODate()}\n                    value={null}\n                    onChange={onChangeMock}\n                />\n            )\n\n            const input = component.getByPlaceholderText('MM / DD / YYYY') as HTMLInputElement\n\n            user.type(input, '02012021')\n            expect(onChangeMock).toHaveBeenCalledWith('2021-02-01')\n            expect(screen.queryByText('Date must be')).not.toBeInTheDocument()\n\n            expect(screen.queryByText('Cancel')).not.toBeInTheDocument()\n            expect(screen.queryByText('Apply')).not.toBeInTheDocument()\n        })\n    })\n\n    describe('When datepicker is expanded', () => {\n        it('should be able to open modal and select a date', async () => {\n            const onChangeMock = jest.fn()\n\n            render(\n                <div id=\"container\">\n                    <DatePicker\n                        name=\"picker\"\n                        minCalendarDate={minDate.toISODate()}\n                        maxCalendarDate={maxDate.toISODate()}\n                        value={null}\n                        onChange={onChangeMock}\n                    />\n                </div>\n            )\n\n            const currentMonth = DateTime.now()\n            const priorMonth = DateTime.now().minus({ months: 1 })\n\n            // Open the modal\n            fireEvent.click(screen.getByTestId('datepicker-toggle-icon'))\n\n            await waitFor(() => expect(screen.getByTestId('datepicker-panel')).toBeInTheDocument())\n\n            // Go back a month\n            fireEvent.click(screen.getByTestId('datepicker-range-back-arrow'))\n            expect(screen.queryByText(priorMonth.monthShort)).toBeInTheDocument()\n            expect(screen.queryByText(priorMonth.year)).toBeInTheDocument()\n\n            // Go forward a month\n            fireEvent.click(screen.getByTestId('datepicker-range-next-arrow'))\n            expect(screen.queryByText(currentMonth.monthShort)).toBeInTheDocument()\n            expect(screen.queryByText(currentMonth.year)).toBeInTheDocument()\n\n            // Select a date\n            fireEvent.click(screen.getByText('17'))\n            fireEvent.click(screen.getByText('Apply'))\n            expect(onChangeMock).toHaveBeenCalledWith(\n                DateTime.fromObject(\n                    {\n                        day: 17,\n                        month: currentMonth.month,\n                        year: currentMonth.year,\n                    },\n                    { zone: 'utc' }\n                ).toISODate()\n            )\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePicker.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { DatePickerProps } from './DatePicker'\nimport { useState } from 'react'\n\nimport DatePicker from './DatePicker'\n\nexport default {\n    title: 'Components/DatePicker',\n    component: DatePicker,\n    argTypes: {\n        placeholder: { description: 'The Input placeholder value' },\n        value: { description: 'Valid ISO 8601 string' },\n        minDate: { control: 'text', description: 'Valid ISO 8601 string' },\n        maxDate: { control: 'text', description: 'Valid ISO 8601 string' },\n        error: { control: 'text', description: 'Error message' },\n    },\n} as Meta\n\nexport const Base: Story<DatePickerProps> = (props: DatePickerProps) => {\n    const [date, setDate] = useState<Date | null>(null)\n\n    return (\n        <div className=\"h-96 flex justify-center\">\n            <DatePicker {...props} value={date} onChange={setDate} label=\"Sample label\" />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePicker.tsx",
    "content": "import type * as PopperJs from '@popperjs/core'\nimport { PatternFormat, type NumberFormatValues } from 'react-number-format'\nimport type { Ref } from 'react'\nimport { useState, useCallback, forwardRef } from 'react'\nimport { Popover, Portal } from '@headlessui/react'\nimport { RiCalendarEventFill as CalendarIcon } from 'react-icons/ri'\nimport { Button, Input } from '../..'\nimport { DateTime } from 'luxon'\nimport classNames from 'classnames'\nimport { usePopper } from 'react-popper'\nimport { DatePickerCalendar } from './DatePickerCalendar'\nimport { MAX_SUPPORTED_DATE, MIN_SUPPORTED_DATE } from './utils'\n\nconst INPUT_DATE_FORMAT = 'MM / dd / yyyy'\n\nexport interface DatePickerProps {\n    name: string\n    value: string | null\n    onChange: (date: string | null) => void\n    error?: string\n    label?: string\n    className?: string\n    placeholder?: string\n    minCalendarDate?: string\n    maxCalendarDate?: string\n    popperPlacement?: PopperJs.Placement\n    popperStrategy?: PopperJs.PositioningStrategy\n}\n\nfunction toFormattedStr(date: string | null) {\n    if (!date) return ''\n    return DateTime.fromISO(date).toFormat(INPUT_DATE_FORMAT)\n}\n\nfunction DatePicker(\n    {\n        name,\n        value,\n        onChange,\n        error,\n        label,\n        className,\n        placeholder = 'MM / DD / YYYY',\n        minCalendarDate = MIN_SUPPORTED_DATE.toISODate(),\n        maxCalendarDate = MAX_SUPPORTED_DATE.toISODate(),\n        popperPlacement = 'auto',\n        popperStrategy = 'fixed',\n    }: DatePickerProps,\n    ref: Ref<HTMLInputElement>\n): JSX.Element {\n    // Positions the datepicker panel appropriately based on screen size and parent elements\n    const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>()\n    const [popperElement, setPopperElement] = useState<HTMLDivElement | null>()\n    const { styles, attributes } = usePopper(referenceElement, popperElement, {\n        placement: popperPlacement,\n        strategy: popperStrategy,\n        modifiers: [\n            {\n                name: 'offset',\n                options: {\n                    offset: [0, 8],\n                },\n            },\n            {\n                name: 'preventOverflow',\n                options: {\n                    altAxis: true,\n                },\n            },\n        ],\n    })\n\n    const [calendarValue, setCalendarValue] = useState(value ?? '')\n\n    // Only change input value when it is cleared or is a date value\n    const handleInputValueChange = useCallback(\n        (date: NumberFormatValues) => {\n            if (!date.formattedValue) {\n                setCalendarValue('')\n                onChange(null)\n            } else {\n                const inputDate = DateTime.fromFormat(date.formattedValue, INPUT_DATE_FORMAT)\n\n                if (inputDate.isValid) {\n                    setCalendarValue(inputDate.toISODate())\n                    onChange(inputDate.toISODate())\n                }\n            }\n        },\n        [onChange]\n    )\n\n    return (\n        <Popover className={classNames(className, 'relative')}>\n            <div ref={setReferenceElement}>\n                <PatternFormat\n                    name={name}\n                    customInput={Input} // passes all props below to <Input /> - https://github.com/s-yadav/react-number-format#custom-inputs\n                    getInputRef={ref}\n                    format=\"## / ## / ####\"\n                    placeholder={placeholder}\n                    mask={['M', 'M', 'D', 'D', 'Y', 'Y', 'Y', 'Y']}\n                    value={toFormattedStr(value)}\n                    error={error}\n                    label={label}\n                    onValueChange={handleInputValueChange}\n                    fixedRightOverride={\n                        <Popover.Button data-testid=\"datepicker-toggle-icon\">\n                            <CalendarIcon className=\"text-lg\" />\n                        </Popover.Button>\n                    }\n                />\n            </div>\n\n            <Portal>\n                <Popover.Panel\n                    className=\"border border-gray-500 rounded bg-gray-700 shadow-lg z-50\"\n                    ref={setPopperElement}\n                    style={styles.popper}\n                    data-testid=\"datepicker-panel\"\n                    {...attributes.popper}\n                >\n                    {({ close }) => (\n                        <div\n                            className={classNames('flex gap-6 p-4 rounded text-gray-25 text-base')}\n                        >\n                            <div className=\"flex flex-col\">\n                                {/* Calendar */}\n                                <div className=\"grow mt-2\">\n                                    <DatePickerCalendar\n                                        date={calendarValue}\n                                        onChange={setCalendarValue}\n                                        minDate={minCalendarDate}\n                                        maxDate={maxCalendarDate}\n                                        controlButtons={\n                                            <div className=\"flex items-center justify-end\">\n                                                <Button\n                                                    className=\"mr-4\"\n                                                    variant=\"secondary\"\n                                                    onClick={() => {\n                                                        // reset to input value\n                                                        setCalendarValue(value ?? '')\n                                                        close()\n                                                    }}\n                                                >\n                                                    Cancel\n                                                </Button>\n                                                <Button\n                                                    disabled={!calendarValue}\n                                                    onClick={() => {\n                                                        onChange(\n                                                            calendarValue ? calendarValue : null\n                                                        ) // commit value change\n                                                        close()\n                                                    }}\n                                                >\n                                                    Apply\n                                                </Button>\n                                            </div>\n                                        }\n                                    />\n                                </div>\n                            </div>\n                        </div>\n                    )}\n                </Popover.Panel>\n            </Portal>\n        </Popover>\n    )\n}\n\nexport default forwardRef<HTMLInputElement, DatePickerProps>(DatePicker)\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerCalendar.tsx",
    "content": "import type { DateObj } from 'dayzed'\nimport classNames from 'classnames'\nimport { useDayzed } from 'dayzed'\nimport { DateTime, Info } from 'luxon'\nimport { useMemo, useState } from 'react'\nimport { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri'\nimport { DatePickerMonth } from './DatePickerMonth'\nimport { DatePickerYear } from './DatePickerYear'\n\nexport interface DatePickerCalendarProps {\n    date?: string\n    onChange: (date: string) => void\n    minDate: string\n    maxDate: string\n    controlButtons: React.ReactNode\n}\n\nexport function DatePickerCalendar({\n    date,\n    onChange,\n    minDate,\n    maxDate,\n    controlButtons,\n}: DatePickerCalendarProps) {\n    const { currentCalendarDate, currentCalendarSelection } = useMemo(() => {\n        if (!date)\n            return { currentCalendarDate: DateTime.now().toJSDate(), currentCalendarSelection: [] }\n\n        return {\n            currentCalendarDate: DateTime.fromISO(date).toJSDate(),\n            currentCalendarSelection: DateTime.fromISO(date).toJSDate(),\n        }\n    }, [date])\n\n    const [offset, setOffset] = useState(0)\n\n    const { calendars, getBackProps, getForwardProps, getDateProps } = useDayzed({\n        date: currentCalendarDate, // determines what month to show\n        selected: currentCalendarSelection,\n        onDateSelected: (dateObj: DateObj) => {\n            setOffset(0)\n            onChange(DateTime.fromJSDate(dateObj.date).toISODate())\n        },\n        minDate: DateTime.fromISO(minDate).toJSDate(),\n        maxDate: DateTime.fromISO(maxDate).toJSDate(),\n        offset,\n        onOffsetChanged: setOffset,\n    })\n\n    const [view, setView] = useState<'calendar' | 'month' | 'year'>('calendar')\n\n    return (\n        <div>\n            {view === 'calendar' && (\n                <div className=\"flex rounded gap-2\">\n                    {/* Back button (- 1 month) */}\n                    <button\n                        {...getBackProps({ calendars })}\n                        className=\"text-gray-50 hover:text-white hover:bg-gray-500 flex justify-center items-center rounded w-10 h-10 disabled:opacity-50 disabled:pointer-events-none\"\n                        data-testid=\"datepicker-range-back-arrow\"\n                    >\n                        <RiArrowLeftSLine size={24} />\n                    </button>\n\n                    {/* Displays the calendar's current month and year */}\n                    <div className=\"flex grow justify-around gap-2\">\n                        <button\n                            className=\"text-white hover:bg-gray-500 flex grow justify-center items-center rounded uppercase\"\n                            data-testid=\"datepicker-range-month-button\"\n                            onClick={() => setView('month')}\n                        >\n                            {Info.months('short')[calendars[0].month]}\n                        </button>\n                        <button\n                            className=\"text-white hover:bg-gray-500 flex grow justify-center items-center rounded\"\n                            data-testid=\"datepicker-range-year-button\"\n                            onClick={() => setView('year')}\n                        >\n                            {calendars[0].year}\n                        </button>\n                    </div>\n\n                    {/* Forward button (+ 1 month) */}\n                    <button\n                        {...getForwardProps({ calendars })}\n                        className=\"text-gray-50 hover:text-white hover:bg-gray-500 flex justify-center items-center rounded w-10 h-10 disabled:opacity-50 disabled:pointer-events-none\"\n                        data-testid=\"datepicker-range-next-arrow\"\n                    >\n                        <RiArrowRightSLine size={24} />\n                    </button>\n                </div>\n            )}\n            <div className=\"mt-2\">\n                {view === 'month' && (\n                    <DatePickerMonth\n                        calendars={calendars}\n                        getBackProps={getBackProps}\n                        getForwardProps={getForwardProps}\n                        onMonthSelected={() => setView('calendar')}\n                    />\n                )}\n                {view === 'year' && (\n                    <DatePickerYear\n                        minDate={minDate}\n                        maxDate={maxDate}\n                        calendars={calendars}\n                        getBackProps={getBackProps}\n                        getForwardProps={getForwardProps}\n                        onYearSelected={() => setView('calendar')}\n                    />\n                )}\n                {view === 'calendar' &&\n                    calendars.map((calendar) => (\n                        <div\n                            key={`${calendar.year}-${calendar.month}`}\n                            className={classNames(\n                                'grid grid-cols-7 gap-y-1',\n                                calendar.weeks.length < 6 && 'gap-y-2 pb-4'\n                            )}\n                        >\n                            {/* Day names row */}\n                            <div className=\"contents text-base text-gray-100 text-center\">\n                                {/* Rotate Info.weekdays +6 to start with Sunday */}\n                                {[...Array(7)].map((_, day) => (\n                                    <div key={`${calendar.year}-${calendar.month}-${day}`}>\n                                        {Info.weekdays('short')[(day + 6) % 7]}\n                                    </div>\n                                ))}\n                            </div>\n\n                            {/* Date cells */}\n                            <div className=\"contents text-base text-white\" data-testid=\"day-cells\">\n                                {calendar.weeks.map((week, weekIndex) => (\n                                    <div\n                                        className=\"contents\"\n                                        key={`${calendar.year}-${calendar.month}-${weekIndex}`}\n                                    >\n                                        {week.map((dateObj, dateIndex) => {\n                                            if (dateObj) {\n                                                return (\n                                                    <div\n                                                        key={`${calendar.year}-${calendar.month}-${weekIndex}-${dateIndex}`}\n                                                        className={classNames('relative px-1.5')}\n                                                    >\n                                                        {/* Date button */}\n                                                        <button\n                                                            key={`${calendar.year}-${calendar.month}-${weekIndex}-${dateIndex}`}\n                                                            disabled={!dateObj.selectable}\n                                                            type=\"button\"\n                                                            className={classNames(\n                                                                'w-10 h-10 rounded-full text-center',\n                                                                'disabled:opacity-50 disabled:pointer-events-none',\n                                                                'focus:outline-none focus:ring focus:ring-gray-200 focus:ring-opacity-50',\n                                                                dateObj.today && 'font-semibold',\n                                                                dateObj.selected\n                                                                    ? 'bg-cyan text-black'\n                                                                    : 'hover:bg-gray-600'\n                                                            )}\n                                                            {...getDateProps({\n                                                                dateObj,\n                                                            })}\n                                                        >\n                                                            {dateObj.date.getDate()}\n                                                        </button>\n                                                    </div>\n                                                )\n                                            }\n\n                                            // Empty cell\n                                            return (\n                                                <div\n                                                    key={`${calendar.year}-${calendar.month}-${weekIndex}-${dateIndex}`}\n                                                ></div>\n                                            )\n                                        })}\n                                    </div>\n                                ))}\n                            </div>\n                        </div>\n                    ))}\n            </div>\n\n            {view === 'calendar' && controlButtons}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerInput.tsx",
    "content": "import { type NumberFormatValues, PatternFormat } from 'react-number-format'\nimport { DateTime } from 'luxon'\nimport { useCallback } from 'react'\nimport { Input } from '../inputs'\n\nexport interface DatePickerInput {\n    onChange: (value: string) => void\n    value?: string\n    error?: string\n    hasError?: boolean\n    onError?: (error: string) => void\n    minDate?: string\n    maxDate?: string\n    className?: string\n}\n\nexport function DatePickerInput({\n    value,\n    onChange,\n    error,\n    hasError,\n    onError,\n    minDate,\n    maxDate,\n    className,\n}: DatePickerInput) {\n    const handleInputValueChange = useCallback(\n        (date: NumberFormatValues) => {\n            if (date.value.length === 8) {\n                let errorMessage = ''\n\n                /**\n                 * react-number-format guarantees that we will always have valid user inputs, so\n                 * we can rely on the length of the user input string to determine when the date\n                 * is valid and when we should update it in the UI\n                 */\n                const month = +date.value.substring(0, 2)\n                const day = +date.value.substring(2, 4)\n                const year = +date.value.substring(4, 8)\n\n                const inputDate = DateTime.fromObject({ month, day, year })\n\n                // Make sure date is valid\n                if (!inputDate.isValid) errorMessage = 'Invalid date provided'\n\n                const min = DateTime.fromISO(\n                    minDate || DateTime.now().minus({ years: 50 }).toISODate()\n                )\n\n                const max = DateTime.fromISO(maxDate || DateTime.now().toISODate())\n\n                if (inputDate < min) errorMessage = `Date must be greater than ${min.toISODate()}`\n                if (inputDate > max) errorMessage = `Date must be less than ${max.toISODate()}`\n\n                if (errorMessage) {\n                    onError && onError(errorMessage)\n                } else {\n                    onError && onError('')\n                }\n\n                // Pass value back in YYYY-MM-DD format\n                onChange(inputDate.toFormat('yyyy-MM-dd'))\n            }\n        },\n        [minDate, maxDate, onChange, onError]\n    )\n\n    return (\n        <PatternFormat\n            customInput={Input} // passes all props below to <Input /> - https://github.com/s-yadav/react-number-format#custom-inputs\n            format=\"## / ## / ####\"\n            mask={['M', 'M', 'D', 'D', 'Y', 'Y', 'Y', 'Y']}\n            value={value ? DateTime.fromISO(value).toFormat('MM / dd / yyyy') : ''} // must pass an empty string for the \"undefined\" condition to trigger a re-render\n            error={error}\n            hasError={!!error || hasError}\n            onValueChange={handleInputValueChange}\n            className={className}\n            placeholder=\"MM / DD / YYYY\"\n            inputClassName=\"text-center\"\n        />\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerMonth.tsx",
    "content": "import type { Calendar, GetBackForwardPropsOptions } from 'dayzed'\nimport classNames from 'classnames'\nimport { Info } from 'luxon'\nimport { useMemo } from 'react'\nimport { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri'\n\nexport interface DatePickerMonthProps {\n    calendars: Calendar[]\n    getBackProps: (data: GetBackForwardPropsOptions) => Record<string, any>\n    getForwardProps: (data: GetBackForwardPropsOptions) => Record<string, any>\n    onMonthSelected: () => void\n}\n\nexport function DatePickerMonth({\n    calendars,\n    getBackProps,\n    getForwardProps,\n    onMonthSelected,\n}: DatePickerMonthProps) {\n    const calendar = calendars[0]\n\n    const selectedMonth = useMemo(() => `${calendar.year}/${calendar.month}`, [])\n\n    const getMonthProps = (month: number) => {\n        if (month > calendar.month) {\n            return getForwardProps({\n                calendars,\n                offset: month - calendar.month,\n                onClick: onMonthSelected,\n            })\n        }\n\n        return getBackProps({ calendars, offset: calendar.month - month, onClick: onMonthSelected })\n    }\n\n    const months = Info.months('short')\n\n    return (\n        <div>\n            <div className=\"flex justify-around gap-x-2\">\n                <button\n                    className={classNames(\n                        'text-white hover:bg-gray-500 flex justify-center items-center rounded w-8 h-8',\n                        'disabled:opacity-50 disabled:pointer-events-none'\n                    )}\n                    type=\"button\"\n                    {...getBackProps({ calendars, offset: 12 })}\n                >\n                    <RiArrowLeftSLine size={24} />\n                </button>\n                <span className=\"text-white  flex justify-center items-center rounded grow\">\n                    {calendar.year}\n                </span>\n                <button\n                    className={classNames(\n                        'text-white hover:bg-gray-500 flex justify-center items-center rounded w-8 h-8',\n                        'disabled:opacity-50 disabled:pointer-events-none'\n                    )}\n                    type=\"button\"\n                    {...getForwardProps({ calendars, offset: 12 })}\n                >\n                    <RiArrowRightSLine size={24} />\n                </button>\n            </div>\n            <div className=\"grid grid-cols-3 grid-rows-4 gap-2 mt-2\">\n                {months.map((month, index) => (\n                    <button\n                        {...getMonthProps(index)}\n                        key={month}\n                        className={classNames(\n                            'flex justify-center items-center rounded h-10 w-20',\n                            'disabled:opacity-50 disabled:pointer-events-none',\n                            `${calendar.year}/${index}` === selectedMonth\n                                ? 'bg-cyan-500 text-black hover:bg-cyan-400'\n                                : 'text-white hover:bg-gray-500'\n                        )}\n                    >\n                        {month}\n                    </button>\n                ))}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerQuickSelect.tsx",
    "content": "import type { DateRange, SelectableDateRange } from './selectableRanges'\nimport { RadioGroup } from '@headlessui/react'\nimport classNames from 'classnames'\nimport { DateTime } from 'luxon'\nimport { useMemo } from 'react'\n\ninterface DatePickerQuickSelectProps {\n    ranges: SelectableDateRange[]\n    value?: Partial<DateRange>\n    onChange: (range: DateRange) => void\n}\n\nexport function DatePickerQuickSelect({ ranges, value, onChange }: DatePickerQuickSelectProps) {\n    const optionStyle = 'pl-2 pr-8 py-1.5 rounded'\n\n    const daysSelected = useMemo(() => {\n        if (!value || !value.start || !value.end) return ''\n\n        const start = DateTime.fromISO(value.start)\n        const end = DateTime.fromISO(value.end)\n\n        const diffDays = end.diff(start, 'days').days\n        const diffMonths = end.diff(start, 'months').months\n        const diffYears = end.diff(start, 'years').years\n\n        if (diffDays <= 90) {\n            return `Selected: ${Math.ceil(diffDays + 1)} days`\n        } else if (diffDays < 365) {\n            return `Selected: ${Math.round(diffMonths)} months`\n        } else {\n            return `Selected: ${Math.round(diffYears)} years`\n        }\n    }, [value])\n\n    // Determines whether the current value is custom, or one of the pre-defined options\n    const { isCustom, radioValue } = useMemo(() => {\n        if (!value) return { isCustom: true }\n\n        const index = ranges.findIndex(\n            (val: SelectableDateRange) => val.start === value.start && val.end === value.end\n        )\n\n        if (index === -1) {\n            return {\n                radioValue: { label: 'Custom', start: value.start, end: value.end },\n                isCustom: true,\n            }\n        } else {\n            return { radioValue: ranges[index], isCustom: false }\n        }\n    }, [value, ranges])\n\n    return (\n        <div className=\"flex flex-col min-w-[140px] h-full\">\n            <RadioGroup\n                value={radioValue}\n                onChange={(radioValue: SelectableDateRange) =>\n                    onChange({ start: radioValue.start, end: radioValue.end })\n                }\n            >\n                {ranges.map((range) => (\n                    <RadioGroup.Option value={range} key={range.label}>\n                        {({ active, checked }) => (\n                            <div\n                                className={classNames(\n                                    'my-1 hover:bg-gray-400 cursor-pointer',\n                                    optionStyle,\n                                    (active || checked) && 'bg-gray-400'\n                                )}\n                            >\n                                {range.label}\n                            </div>\n                        )}\n                    </RadioGroup.Option>\n                ))}\n            </RadioGroup>\n            <div className=\"grow\">\n                <div\n                    className={classNames(\n                        optionStyle,\n                        'mt-1 mb-8',\n                        isCustom ? 'bg-gray-400' : 'text-gray-200'\n                    )}\n                >\n                    Custom range\n                </div>\n            </div>\n            <div className=\"text-gray-200 mb-2\">{daysSelected}</div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerRange/DatePickerRange.spec.tsx",
    "content": "import user from '@testing-library/user-event'\nimport { fireEvent, getQueriesForElement, render, screen, waitFor } from '@testing-library/react'\nimport { DatePickerRange } from './DatePickerRange'\nimport { DateTime } from 'luxon'\n\n// Set date to Oct 29, 2021 to keep snapshots consistent\nbeforeAll(() => jest.useFakeTimers().setSystemTime(new Date('2021-10-29 12:00:00')))\n\n// DatePicker configuration\nconst minDate = DateTime.now().minus({ years: 2 })\nconst maxDate = DateTime.now()\n\ndescribe('<DatePickerRange />', () => {\n    describe('When datepicker is closed', () => {\n        it('should have placeholder when empty', () => {\n            const onChangeMock = jest.fn()\n\n            const component = render(\n                <DatePickerRange\n                    selectableRanges={['this-month', 'prior-month']}\n                    minDate={minDate.toISODate()}\n                    maxDate={maxDate.toISODate()}\n                    value={undefined}\n                    onChange={onChangeMock}\n                />\n            )\n\n            expect(screen.queryByText('Select a date range')).toBeInTheDocument()\n            expect(screen.queryByText('Cancel')).not.toBeInTheDocument()\n            expect(screen.queryByText('Apply')).not.toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n\n        it('should have date when valid', () => {\n            const onChangeMock = jest.fn()\n\n            const component = render(\n                <DatePickerRange\n                    selectableRanges={['this-month', 'prior-month']}\n                    minDate={'2021-01-01'}\n                    maxDate={'2022-01-01'}\n                    value={{ start: '2021-01-01', end: '2022-01-01' }}\n                    onChange={onChangeMock}\n                />\n            )\n\n            expect(screen.queryByText('Jan 01, 2021')).toBeInTheDocument()\n            expect(screen.queryByText('to')).toBeInTheDocument()\n            expect(screen.queryByText('Jan 01, 2022')).toBeInTheDocument()\n            expect(screen.queryByText('Cancel')).not.toBeInTheDocument()\n            expect(screen.queryByText('Apply')).not.toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('When datepicker is expanded', () => {\n        it('should be able to select a date from the dropdown while clicking', async () => {\n            const onChangeMock = jest.fn()\n\n            const start = DateTime.fromISO('2021-12-01')\n            const end = DateTime.fromISO('2022-01-31')\n\n            render(\n                <DatePickerRange\n                    selectableRanges={['day', 'last-30-days']}\n                    minDate={start.toISODate()}\n                    maxDate={end.toISODate()}\n                    value={{ start: start.toISODate(), end: end.toISODate() }}\n                    onChange={onChangeMock}\n                />\n            )\n\n            // Open the modal\n            fireEvent.click(screen.getByTestId('datepicker-range-toggle-icon'))\n\n            await waitFor(() =>\n                expect(screen.getByTestId('datepicker-range-panel')).toBeInTheDocument()\n            )\n\n            expect(screen.queryByText('Cancel')).toBeInTheDocument()\n            expect(screen.queryByText('Apply')).toBeInTheDocument()\n\n            expect(screen.queryByText(end.monthShort)).toBeInTheDocument()\n            expect(screen.queryByText(end.year)).toBeInTheDocument()\n\n            // Go back a month\n            fireEvent.click(screen.getByTestId('datepicker-range-back-arrow'))\n            expect(screen.queryByText(start.monthShort)).toBeInTheDocument()\n            expect(screen.queryByText(start.year)).toBeInTheDocument()\n\n            // Select start date\n            fireEvent.click(\n                await getQueriesForElement(screen.getByTestId('day-cells')).findByText('1')\n            )\n\n            // Go forward a month\n            fireEvent.click(screen.getByTestId('datepicker-range-next-arrow'))\n            expect(screen.queryByText(end.monthShort)).toBeInTheDocument()\n            expect(screen.queryByText(end.year)).toBeInTheDocument()\n\n            // Select end date\n            fireEvent.click(\n                await getQueriesForElement(screen.getByTestId('day-cells')).findByText('31')\n            )\n\n            // Submit\n            fireEvent.click(screen.getByText('Apply'))\n\n            expect(onChangeMock).toHaveBeenCalledWith({\n                start: start.toISODate(),\n                end: end.toISODate(),\n            })\n\n            await waitFor(() =>\n                expect(screen.queryByTestId('datepicker-range-panel')).not.toBeInTheDocument()\n            )\n\n            expect(screen.queryByText('Cancel')).not.toBeInTheDocument()\n            expect(screen.queryByText('Apply')).not.toBeInTheDocument()\n        })\n        it('should be able to select a date from the dropdown using inputs', async () => {\n            const onChangeMock = jest.fn()\n\n            const start = DateTime.fromISO('2022-01-01')\n            const end = DateTime.fromISO('2022-01-20')\n\n            render(\n                <DatePickerRange\n                    selectableRanges={['day', 'last-30-days']}\n                    minDate={start.toISODate()}\n                    maxDate={end.toISODate()}\n                    value={{ start: start.toISODate(), end: end.toISODate() }}\n                    onChange={onChangeMock}\n                />\n            )\n\n            // Open the modal\n            fireEvent.click(screen.getByTestId('datepicker-range-toggle-icon'))\n\n            await waitFor(() =>\n                expect(screen.getByTestId('datepicker-range-panel')).toBeInTheDocument()\n            )\n\n            expect(screen.queryByText('Cancel')).toBeInTheDocument()\n            expect(screen.queryByText('Apply')).toBeInTheDocument()\n\n            const dateInputs = screen.getAllByRole('textbox')\n\n            user.type(dateInputs[0], start.toFormat('MMddyyyy'))\n            user.type(dateInputs[1], end.toFormat('MMddyyyy'))\n\n            expect(screen.queryByText(end.monthShort)).toBeInTheDocument()\n            expect(screen.queryByText(end.year)).toBeInTheDocument()\n\n            // Submit\n            fireEvent.click(screen.getByText('Apply'))\n\n            expect(onChangeMock).toHaveBeenCalledWith({\n                start: start.toISODate(),\n                end: end.toISODate(),\n            })\n\n            await waitFor(() =>\n                expect(screen.queryByTestId('datepicker-range-panel')).not.toBeInTheDocument()\n            )\n\n            expect(screen.queryByText('Cancel')).not.toBeInTheDocument()\n            expect(screen.queryByText('Apply')).not.toBeInTheDocument()\n        })\n\n        it('should be able to select a date using the quick select sidebar', async () => {\n            const onChangeMock = jest.fn()\n\n            render(\n                <DatePickerRange\n                    selectableRanges={['prior-month', 'this-month']}\n                    onChange={onChangeMock}\n                />\n            )\n\n            // Open the modal\n            fireEvent.click(screen.getByTestId('datepicker-range-toggle-icon'))\n\n            await waitFor(() =>\n                expect(screen.getByTestId('datepicker-range-panel')).toBeInTheDocument()\n            )\n\n            expect(screen.queryByText('Cancel')).toBeInTheDocument()\n            expect(screen.queryByText('Apply')).toBeInTheDocument()\n\n            fireEvent.click(screen.getByText('Last month'))\n\n            const lastMonth = DateTime.now().minus({ months: 1 })\n\n            expect(screen.queryByText(lastMonth.monthShort)).toBeInTheDocument()\n            expect(screen.queryByText(lastMonth.year)).toBeInTheDocument()\n\n            // Submit\n            fireEvent.click(screen.getByText('Apply'))\n\n            expect(onChangeMock).toHaveBeenCalledWith({\n                start: lastMonth.startOf('month').toISODate(),\n                end: lastMonth.endOf('month').toISODate(),\n            })\n\n            await waitFor(() =>\n                expect(screen.queryByTestId('datepicker-range-panel')).not.toBeInTheDocument()\n            )\n\n            expect(screen.queryByText('Cancel')).not.toBeInTheDocument()\n            expect(screen.queryByText('Apply')).not.toBeInTheDocument()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerRange/DatePickerRange.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { DateRange } from '../selectableRanges'\nimport { useState } from 'react'\nimport { DateTime } from 'luxon'\nimport { DatePickerRange } from './DatePickerRange'\n\nexport default {\n    title: 'Components/DatePicker/DatePickerRange',\n    component: DatePickerRange,\n    argTypes: {\n        placeholder: { description: 'The Input placeholder value' },\n        value: { description: 'Valid ISO 8601 string' },\n        minDate: { control: 'text', description: 'Valid ISO 8601 string' },\n        maxDate: { control: 'text', description: 'Valid ISO 8601 string' },\n    },\n} as Meta\n\nexport const Base: Story = (args) => {\n    const [range, setRange] = useState<Partial<DateRange> | undefined>(undefined)\n\n    return (\n        <div className=\"flex justify-center h-[600px]\">\n            <DatePickerRange\n                {...args}\n                value={range}\n                selectableRanges={[\n                    'this-month',\n                    'prior-month',\n                    'this-year',\n                    'prior-year',\n                    {\n                        label: 'All time',\n                        labelShort: 'All',\n                        start: DateTime.now().minus({ years: 3 }).toISODate(), // this would be dynamic in real app\n                        end: DateTime.now().toISODate(),\n                    },\n                ]}\n                onChange={setRange}\n            />\n        </div>\n    )\n}\n\nexport const Tabs: Story = (args) => {\n    // const [range, setRange] = useState<Partial<DateRange> | undefined>(undefined)\n\n    const [range, setRange] = useState<Partial<DateRange> | undefined>({\n        start: '2022-02-01',\n        end: '2022-02-28',\n    })\n\n    return (\n        <div className=\"flex justify-center h-[600px]\">\n            <DatePickerRange\n                {...args}\n                variant=\"tabs\"\n                value={range}\n                selectableRanges={[\n                    'day',\n                    'last-7-days',\n                    'last-30-days',\n                    'last-90-days',\n                    {\n                        label: 'All time',\n                        labelShort: 'All',\n                        start: DateTime.now().minus({ years: 3 }).toISODate(), // this would be dynamic in real app\n                        end: DateTime.now().toISODate(),\n                    },\n                ]}\n                onChange={setRange}\n                popperPlacement=\"bottom-end\"\n            />\n        </div>\n    )\n}\n\nexport const TabsWithCustomRange: Story = (args) => {\n    // const [range, setRange] = useState<Partial<DateRange> | undefined>(undefined)\n\n    const [range, setRange] = useState<Partial<DateRange> | undefined>({\n        start: '2022-02-01',\n        end: '2022-02-28',\n    })\n\n    return (\n        <div className=\"flex justify-center h-[600px]\">\n            <DatePickerRange\n                {...args}\n                variant=\"tabs-custom\"\n                value={range}\n                selectableRanges={[\n                    'day',\n                    'last-7-days',\n                    'last-30-days',\n                    'last-90-days',\n                    {\n                        label: 'All time',\n                        labelShort: 'All',\n                        start: DateTime.now().minus({ years: 3 }).toISODate(), // this would be dynamic in real app\n                        end: DateTime.now().toISODate(),\n                    },\n                ]}\n                onChange={setRange}\n                popperPlacement=\"bottom-end\"\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerRange/DatePickerRange.tsx",
    "content": "import type * as PopperJs from '@popperjs/core'\nimport type { DateRange, SelectableDateRange, SelectableRangeKeys } from '../selectableRanges'\n\nimport { Popover, Portal } from '@headlessui/react'\nimport classNames from 'classnames'\nimport { DateTime } from 'luxon'\nimport { RiArrowRightLine } from 'react-icons/ri'\nimport { useEffect, useMemo, useState } from 'react'\nimport { usePopper } from 'react-popper'\nimport { Button } from '../../Button'\nimport { DatePickerRangeCalendar } from './DatePickerRangeCalendar'\nimport { DatePickerInput } from '../DatePickerInput'\nimport { getNormalizedRanges } from '../selectableRanges'\nimport { DatePickerQuickSelect } from '../DatePickerQuickSelect'\nimport { DatePickerRangeButton } from './DatePickerRangeButton'\nimport { DatePickerRangeTabs } from './DatePickerRangeTabs'\n\nexport interface DatePickerRangeProps {\n    variant?: 'default' | 'tabs' | 'tabs-custom'\n    value?: Partial<DateRange>\n    selectableRanges: Array<SelectableRangeKeys | SelectableDateRange>\n    onChange: (range: DateRange) => void\n    className?: string\n    minDate?: string\n    maxDate?: string\n    popperPlacement?: PopperJs.Placement\n    popperStrategy?: PopperJs.PositioningStrategy\n}\n\nexport function DatePickerRange({\n    variant = 'default',\n    value,\n    selectableRanges,\n    onChange,\n    className,\n    minDate = DateTime.now().minus({ years: 50 }).toISODate(),\n    maxDate = DateTime.now().toISODate(),\n    popperPlacement = 'bottom-end',\n    popperStrategy = 'fixed',\n}: DatePickerRangeProps) {\n    // PopperJS configuration: positions the datepicker panel appropriately based on screen size and parent elements\n    const [referenceElement, setReferenceElement] = useState<HTMLElement | null>()\n    const [popperElement, setPopperElement] = useState<HTMLDivElement | null>()\n    const { styles, attributes } = usePopper(referenceElement, popperElement, {\n        placement: popperPlacement,\n        strategy: popperStrategy,\n        modifiers: [\n            {\n                name: 'offset',\n                options: {\n                    offset: [0, variant === 'default' ? 8 : 16],\n                },\n            },\n            {\n                name: 'preventOverflow',\n                options: {\n                    altAxis: true,\n                },\n            },\n        ],\n    })\n\n    const [range, setRange] = useState(value)\n    const [rangeError, setRangeError] = useState(false)\n    const [startError, setStartError] = useState(false)\n    const [endError, setEndError] = useState(false)\n\n    useEffect(() => {\n        setRangeError(\n            range && !!range.start && !!range.end\n                ? DateTime.fromISO(range.start) > DateTime.fromISO(range.end)\n                : false\n        )\n    }, [range])\n\n    const tabs = useMemo<SelectableDateRange[]>(() => {\n        return getNormalizedRanges(selectableRanges)\n    }, [selectableRanges])\n\n    return (\n        <Popover className={classNames(className, 'relative z-10')}>\n            {variant === 'tabs' ? (\n                <DatePickerRangeTabs\n                    variant=\"default\"\n                    tabs={tabs}\n                    value={value}\n                    onChange={(range) => {\n                        onChange(range)\n                        setRange(range)\n                    }}\n                />\n            ) : variant === 'tabs-custom' ? (\n                <DatePickerRangeTabs\n                    variant=\"custom\"\n                    tabs={[...tabs, 'custom']}\n                    value={value}\n                    onChange={(range) => {\n                        onChange(range)\n                        setRange(range)\n                    }}\n                    setReferenceElement={setReferenceElement}\n                />\n            ) : (\n                <DatePickerRangeButton\n                    value={value}\n                    setPopperReferenceElement={setReferenceElement}\n                />\n            )}\n\n            <Portal>\n                <Popover.Panel\n                    className={classNames(\n                        'border border-gray-500 rounded bg-gray-700 shadow-lg z-50'\n                    )}\n                    ref={setPopperElement}\n                    style={styles.popper}\n                    data-testid=\"datepicker-range-panel\"\n                    {...attributes.popper}\n                >\n                    {({ close }) => (\n                        <div\n                            className={classNames('flex gap-6 p-4 rounded text-gray-25 text-base')}\n                        >\n                            {variant === 'default' && (\n                                <div className=\"hidden sm:block whitespace-nowrap\">\n                                    <DatePickerQuickSelect\n                                        ranges={tabs}\n                                        value={range}\n                                        onChange={setRange}\n                                    />\n                                </div>\n                            )}\n                            <div className=\"flex flex-col\">\n                                <div className=\"grow\">\n                                    <DatePickerRangeCalendar\n                                        range={range}\n                                        onChange={setRange}\n                                        minDate={minDate}\n                                        maxDate={maxDate}\n                                        rangeInputs={\n                                            <div className=\"flex flex-col xs:flex-row justify-between gap-2 h-18 my-2\">\n                                                <DatePickerInput\n                                                    value={range ? range.start : undefined}\n                                                    onChange={(start: string) =>\n                                                        setRange((previous) => ({\n                                                            ...previous,\n                                                            start,\n                                                        }))\n                                                    }\n                                                    hasError={startError || rangeError}\n                                                    onError={(err) => setStartError(!!err)}\n                                                    minDate={minDate}\n                                                    maxDate={maxDate}\n                                                    className=\"w-full xs:w-36\"\n                                                />\n                                                <span className=\"flex justify-center items-center text-gray-50\">\n                                                    <RiArrowRightLine size={20} />\n                                                </span>\n                                                <DatePickerInput\n                                                    value={range ? range.end : undefined}\n                                                    onChange={(end: string) =>\n                                                        setRange((previous) => ({\n                                                            ...previous,\n                                                            end,\n                                                        }))\n                                                    }\n                                                    hasError={endError || rangeError}\n                                                    onError={(err) => setEndError(!!err)}\n                                                    minDate={minDate}\n                                                    maxDate={maxDate}\n                                                    className=\"w-full mb-4 xs:mb-0 xs:w-36\" // On mobile, calendar sits under the inputs\n                                                />\n                                            </div>\n                                        }\n                                        controlButtons={\n                                            <div className=\"flex items-center justify-end\">\n                                                <Button\n                                                    className=\"mr-4\"\n                                                    variant=\"secondary\"\n                                                    onClick={() => close()}\n                                                >\n                                                    Cancel\n                                                </Button>\n                                                <Button\n                                                    onClick={() => {\n                                                        onChange({\n                                                            start: range!.start!,\n                                                            end: range!.end!,\n                                                        })\n                                                        close()\n                                                    }}\n                                                    disabled={\n                                                        rangeError ||\n                                                        startError ||\n                                                        endError ||\n                                                        !range ||\n                                                        !range.start ||\n                                                        !range.end\n                                                    }\n                                                >\n                                                    Apply\n                                                </Button>\n                                            </div>\n                                        }\n                                    />\n                                </div>\n                            </div>\n                        </div>\n                    )}\n                </Popover.Panel>\n            </Portal>\n        </Popover>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerRange/DatePickerRangeButton.tsx",
    "content": "import type { DateRange } from '../selectableRanges'\nimport { Popover } from '@headlessui/react'\nimport { DateTime } from 'luxon'\nimport { RiCalendarEventFill } from 'react-icons/ri'\n\nexport interface DatePickerRangeButtonProps {\n    value?: Partial<DateRange>\n    setPopperReferenceElement: (el: HTMLButtonElement) => void\n}\n\n// The datepicker button that toggles the panel to open/close, and displays the currently selected date range\nexport function DatePickerRangeButton({\n    value,\n    setPopperReferenceElement,\n}: DatePickerRangeButtonProps) {\n    return (\n        <Popover.Button ref={setPopperReferenceElement} data-testid=\"datepicker-range-toggle-icon\">\n            {({ open }) => {\n                return (\n                    <div\n                        className=\"flex items-center justify-between font-normal text-gray-25 text-base border border-gray-200 rounded py-2 px-4\"\n                        data-testid=\"date-range\"\n                    >\n                        {open || !value ? (\n                            <span>Select a date range</span>\n                        ) : (\n                            <>\n                                {value.start && (\n                                    <span>\n                                        {DateTime.fromISO(value.start).toFormat('MMM dd, yyyy')}\n                                    </span>\n                                )}\n                                <span className=\"mx-2 text-gray-200\">to</span>\n                                {value.end && (\n                                    <span>\n                                        {DateTime.fromISO(value.end).toFormat('MMM dd, yyyy')}\n                                    </span>\n                                )}\n                            </>\n                        )}\n                        <RiCalendarEventFill className=\"ml-4 text-lg\" />\n                    </div>\n                )\n            }}\n        </Popover.Button>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerRange/DatePickerRangeCalendar.tsx",
    "content": "import type { DateRange } from '../selectableRanges'\nimport type { DateObj } from 'dayzed'\nimport { useDayzed } from 'dayzed'\nimport classNames from 'classnames'\nimport { DateTime, Info } from 'luxon'\nimport { useMemo, useState } from 'react'\nimport { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri'\n\nimport { DatePickerMonth } from '../DatePickerMonth'\nimport { DatePickerYear } from '../DatePickerYear'\n\nexport interface DatePickerRangeCalendarProps {\n    range?: Partial<DateRange>\n    onChange: (range: Partial<DateRange>) => void\n    minDate: string\n    maxDate: string\n    rangeInputs: React.ReactNode\n    controlButtons: React.ReactNode\n}\n\nexport function DatePickerRangeCalendar({\n    range,\n    onChange,\n    minDate,\n    maxDate,\n    rangeInputs,\n    controlButtons,\n}: DatePickerRangeCalendarProps) {\n    const { currentCalendarDate, currentCalendarSelection } = useMemo(() => {\n        if (!range)\n            return { currentCalendarDate: DateTime.now().toJSDate(), currentCalendarSelection: [] }\n\n        const dates: Date[] = []\n\n        if (range.start) {\n            dates.push(DateTime.fromISO(range.start).toJSDate())\n        }\n\n        if (range.end) {\n            dates.push(DateTime.fromISO(range.end).toJSDate())\n        }\n\n        return {\n            currentCalendarDate: DateTime.fromISO(\n                range.start && range.end ? range.end! : range.start!\n            ).toJSDate(),\n            currentCalendarSelection: dates,\n        }\n    }, [range])\n\n    const [offset, setOffset] = useState(0)\n\n    const { calendars, getBackProps, getForwardProps, getDateProps } = useDayzed({\n        date: currentCalendarDate, // determines what month to show\n        selected: currentCalendarSelection,\n        onDateSelected: (dateObj: DateObj) => {\n            const date = DateTime.fromJSDate(dateObj.date)\n            const start = range && range.start ? DateTime.fromISO(range.start) : undefined\n            const end = range && range.end ? DateTime.fromISO(range.end) : undefined\n\n            // We always want the calendar reflecting the most recently selected date (regardless of whether that date is\n            // the beginning or end of the range selection).  An offset should only exist *between* user clicks.\n            setOffset(0)\n\n            // Start the range process over\n            if ((start && end) || !start || (start && date < start)) {\n                onChange({ start: date.toISODate(), end: undefined })\n\n                return\n            }\n\n            onChange({ start: range!.start, end: date.toISODate() })\n        },\n        minDate: DateTime.fromISO(minDate).toJSDate(),\n        maxDate: DateTime.fromISO(maxDate).toJSDate(),\n        offset,\n        onOffsetChanged: setOffset,\n    })\n\n    const [view, setView] = useState<'calendar' | 'month' | 'year'>('calendar')\n\n    return (\n        <div>\n            {view === 'calendar' && (\n                <div className=\"flex rounded gap-2\">\n                    {/* Back button (- 1 month) */}\n                    <button\n                        {...getBackProps({ calendars })}\n                        className=\"text-gray-50 hover:text-white hover:bg-gray-500 flex justify-center items-center rounded w-10 h-10 disabled:opacity-50 disabled:pointer-events-none\"\n                        data-testid=\"datepicker-range-back-arrow\"\n                    >\n                        <RiArrowLeftSLine size={24} />\n                    </button>\n\n                    {/* Displays the calendar's current month and year */}\n                    <div className=\"flex grow justify-around gap-2\">\n                        <button\n                            className=\"text-white hover:bg-gray-500 flex grow justify-center items-center rounded uppercase\"\n                            data-testid=\"datepicker-range-month-button\"\n                            onClick={() => setView('month')}\n                        >\n                            {Info.months('short')[calendars[0].month]}\n                        </button>\n                        <button\n                            className=\"text-white hover:bg-gray-500 flex grow justify-center items-center rounded\"\n                            data-testid=\"datepicker-range-year-button\"\n                            onClick={() => setView('year')}\n                        >\n                            {calendars[0].year}\n                        </button>\n                    </div>\n\n                    {/* Forward button (+ 1 month) */}\n                    <button\n                        {...getForwardProps({ calendars })}\n                        className=\"text-gray-50 hover:text-white hover:bg-gray-500 flex justify-center items-center rounded w-10 h-10 disabled:opacity-50 disabled:pointer-events-none\"\n                        data-testid=\"datepicker-range-next-arrow\"\n                    >\n                        <RiArrowRightSLine size={24} />\n                    </button>\n                </div>\n            )}\n            {view === 'calendar' && rangeInputs}\n            <div className=\"mt-2\">\n                {view === 'month' && (\n                    <DatePickerMonth\n                        calendars={calendars}\n                        getBackProps={getBackProps}\n                        getForwardProps={getForwardProps}\n                        onMonthSelected={() => setView('calendar')}\n                    />\n                )}\n                {view === 'year' && (\n                    <DatePickerYear\n                        minDate={minDate}\n                        maxDate={maxDate}\n                        calendars={calendars}\n                        getBackProps={getBackProps}\n                        getForwardProps={getForwardProps}\n                        onYearSelected={() => setView('calendar')}\n                    />\n                )}\n                {view === 'calendar' &&\n                    calendars.map((calendar) => (\n                        <div\n                            key={`${calendar.year}-${calendar.month}`}\n                            className={classNames(\n                                'grid grid-cols-7 gap-y-1',\n                                calendar.weeks.length < 6 && 'gap-y-1 pb-4'\n                            )}\n                        >\n                            {/* Day names row */}\n                            <div className=\"contents text-base text-gray-100 text-center\">\n                                {/* Rotate Info.weekdays +6 to start with Sunday */}\n                                {[...Array(7)].map((_, day) => (\n                                    <div\n                                        key={`${calendar.year}-${calendar.month}-${day}`}\n                                        className=\"my-1\"\n                                    >\n                                        {Info.weekdays('short')[(day + 6) % 7]}\n                                    </div>\n                                ))}\n                            </div>\n\n                            {/* Date cells */}\n                            <div className=\"contents text-base text-white\" data-testid=\"day-cells\">\n                                {calendar.weeks.map((week, weekIndex) => (\n                                    <div\n                                        className=\"contents\"\n                                        key={`${calendar.year}-${calendar.month}-${weekIndex}`}\n                                    >\n                                        {week.map((dateObj, dateIndex) => {\n                                            if (dateObj) {\n                                                const d = DateTime.fromJSDate(dateObj.date)\n\n                                                const start =\n                                                    range && range.start\n                                                        ? DateTime.fromISO(range.start)\n                                                        : undefined\n\n                                                const end =\n                                                    range && range.end\n                                                        ? DateTime.fromISO(range.end)\n                                                        : undefined\n\n                                                const hasBothDates = !!start && !!end\n                                                const inRange = start && end && d > start && d < end\n                                                const isEnd =\n                                                    end && d.toISODate() === end.toISODate()\n                                                const isStart =\n                                                    start && d.toISODate() === start.toISODate()\n\n                                                const isFirstWeekday = dateObj.date.getDay() === 0\n                                                const isLastWeekday = dateObj.date.getDay() === 6\n                                                const isFirstDayOfMonth =\n                                                    dateObj.date.getDate() === 1\n                                                const isLastDayOfMonth =\n                                                    dateObj.date.getDate() ===\n                                                    calendar.lastDayOfMonth.getDate()\n\n                                                return (\n                                                    <div\n                                                        key={`${calendar.year}-${calendar.month}-${weekIndex}-${dateIndex}`}\n                                                        className={classNames(\n                                                            'relative px-1.5',\n                                                            inRange &&\n                                                                !dateObj.selected &&\n                                                                classNames(\n                                                                    'bg-gray-600',\n                                                                    (isFirstWeekday ||\n                                                                        isFirstDayOfMonth) &&\n                                                                        'rounded-l-full',\n                                                                    (isLastWeekday ||\n                                                                        isLastDayOfMonth) &&\n                                                                        'rounded-r-full'\n                                                                )\n                                                        )}\n                                                    >\n                                                        {hasBothDates &&\n                                                            ((isStart &&\n                                                                !isLastWeekday &&\n                                                                !isLastDayOfMonth) ||\n                                                                (isEnd &&\n                                                                    !isFirstWeekday &&\n                                                                    !isFirstDayOfMonth)) &&\n                                                            start.toISODate() !==\n                                                                end.toISODate() && (\n                                                                <>\n                                                                    {/* Fills background color gap for cells at ends of range */}\n                                                                    <div\n                                                                        className={classNames(\n                                                                            'absolute -z-10 w-1/2 h-full bg-gray-600',\n                                                                            isStart\n                                                                                ? 'left-1/2'\n                                                                                : 'left-0'\n                                                                        )}\n                                                                    ></div>\n                                                                </>\n                                                            )}\n                                                        {/* Actual date button */}\n                                                        <button\n                                                            key={`${calendar.year}-${calendar.month}-${weekIndex}-${dateIndex}`}\n                                                            disabled={!dateObj.selectable}\n                                                            className={classNames(\n                                                                'w-10 h-10 rounded-full text-center',\n                                                                'disabled:opacity-50 disabled:pointer-events-none',\n                                                                'focus:outline-none focus:ring focus:ring-gray-200 focus:ring-opacity-50',\n                                                                dateObj.today && 'font-semibold',\n                                                                isStart || isEnd\n                                                                    ? 'bg-cyan text-black'\n                                                                    : inRange\n                                                                    ? 'bg-gray-600'\n                                                                    : 'hover:bg-gray-600'\n                                                            )}\n                                                            {...getDateProps({\n                                                                dateObj,\n                                                            })}\n                                                        >\n                                                            {dateObj.date.getDate()}\n                                                        </button>\n                                                    </div>\n                                                )\n                                            }\n\n                                            // Empty cell\n                                            return (\n                                                <div\n                                                    key={`${calendar.year}-${calendar.month}-${weekIndex}-${dateIndex}`}\n                                                ></div>\n                                            )\n                                        })}\n                                    </div>\n                                ))}\n                            </div>\n                        </div>\n                    ))}\n            </div>\n\n            {view === 'calendar' && controlButtons}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerRange/DatePickerRangeTabs.tsx",
    "content": "import type { Dispatch, SetStateAction } from 'react'\nimport type { DateRange, SelectableDateRange } from '..'\nimport { useMemo } from 'react'\nimport { Popover, Tab } from '@headlessui/react'\nimport classNames from 'classnames'\nimport { RiCalendarEventFill } from 'react-icons/ri'\n\nexport type DatePickerRangeTabsProps =\n    | {\n          variant: 'custom'\n          value?: Partial<DateRange>\n          onChange: (range: DateRange) => void\n          tabs: Array<SelectableDateRange | 'custom'>\n          setReferenceElement?: Dispatch<SetStateAction<HTMLElement | null | undefined>>\n      }\n    | {\n          variant: 'default'\n          value?: Partial<DateRange>\n          onChange: (range: DateRange) => void\n          tabs: SelectableDateRange[]\n          setReferenceElement?: never\n      }\n\nfunction DefaultTab({ value, selected }: { value: string; selected: boolean }) {\n    return (\n        <span\n            className={classNames(\n                'flex items-center px-4 py-1 text-base rounded hover:bg-gray-300',\n                selected ? 'bg-gray-400 text-white shadow' : 'text-gray-100'\n            )}\n        >\n            {value}\n        </span>\n    )\n}\n\nconst NOT_SELECTED_TAB_INDEX = 999\n\n// The tabs that persistently show and can toggle the panel to open/close\nexport function DatePickerRangeTabs(props: DatePickerRangeTabsProps) {\n    const selectedIndex = useMemo(() => {\n        // No range, no tab selected\n        if (!props.value?.start || !props.value?.end) {\n            return NOT_SELECTED_TAB_INDEX\n        }\n\n        const tabIndex = props.tabs.findIndex(\n            (tab) =>\n                typeof tab !== 'string' && // not custom tab\n                tab.start === props.value?.start &&\n                tab.end === props.value?.end\n        )\n\n        // known range, select tab\n        if (tabIndex !== -1) {\n            return tabIndex\n        }\n\n        const customTabIndex = props.tabs.findIndex(\n            (tab) => typeof tab === 'string' && tab === 'custom'\n        )\n\n        // custom range, select custom tab\n        if (customTabIndex !== -1) {\n            return customTabIndex\n        }\n\n        return NOT_SELECTED_TAB_INDEX\n    }, [props.tabs, props.value?.start, props.value?.end])\n\n    // Render the custom variant (a datepicker shows up as the very last tab)\n    if (props.variant === 'custom') {\n        return (\n            <div className=\"flex items-center\">\n                <Tab.Group\n                    selectedIndex={selectedIndex}\n                    onChange={(index: number) => {\n                        const tab = props.tabs[index]\n\n                        // If the \"custom\" tab is clicked, we defer state updates until the user interacts with the calendar panel and submits\n                        if (tab !== 'custom') {\n                            props.onChange({ start: tab.start, end: tab.end })\n                        }\n                    }}\n                >\n                    <Tab.List className=\"inline-flex items-center p-1 gap-x-2 text-white\">\n                        {props.tabs.map((tab) => (\n                            <Tab key={typeof tab === 'string' ? tab : tab.label}>\n                                {({ selected }) =>\n                                    tab === 'custom' ? (\n                                        <Popover.Button\n                                            ref={props.setReferenceElement}\n                                            as=\"div\" // Cannot render a button inside of a button (<Tab> renders as a button by default)\n                                            data-testid=\"datepicker-range-toggle-icon-tabs\"\n                                            className={classNames(\n                                                'px-4 flex items-center text-gray-100 rounded h-8 hover:bg-gray-300',\n                                                selected && 'bg-gray-400 text-white shadow'\n                                            )}\n                                        >\n                                            <RiCalendarEventFill\n                                                className={classNames(\n                                                    'text-lg cursor-pointer',\n                                                    selected && 'text-white'\n                                                )}\n                                            />\n                                        </Popover.Button>\n                                    ) : (\n                                        <DefaultTab value={tab.labelShort} selected={selected} />\n                                    )\n                                }\n                            </Tab>\n                        ))}\n                    </Tab.List>\n                </Tab.Group>\n            </div>\n        )\n    }\n\n    // Default implementation - no datepicker is available to click here\n    return (\n        <div className=\"flex items-center\">\n            <Tab.Group\n                selectedIndex={selectedIndex}\n                onChange={(index: number) => {\n                    const { start, end } = props.tabs[index]\n                    props.onChange({ start, end })\n                }}\n            >\n                <Tab.List className=\"inline-flex items-center p-1 text-white\">\n                    {props.tabs.map((tab) => (\n                        <Tab key={tab.labelShort}>\n                            {({ selected }) => (\n                                <DefaultTab value={tab.labelShort} selected={selected} />\n                            )}\n                        </Tab>\n                    ))}\n                </Tab.List>\n            </Tab.Group>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerRange/__snapshots__/DatePickerRange.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`<DatePickerRange /> When datepicker is closed should have date when valid 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div\n      id=\"headlessui-portal-root\"\n    >\n      <div\n        data-headlessui-portal=\"\"\n      />\n      <div\n        data-headlessui-portal=\"\"\n      />\n    </div>\n    <div>\n      <div\n        class=\"relative z-10\"\n        data-headlessui-state=\"\"\n      >\n        <button\n          aria-expanded=\"false\"\n          data-headlessui-state=\"\"\n          data-testid=\"datepicker-range-toggle-icon\"\n          id=\"headlessui-popover-button-:r5:\"\n          type=\"button\"\n        >\n          <div\n            class=\"flex items-center justify-between font-normal text-gray-25 text-base border border-gray-200 rounded py-2 px-4\"\n            data-testid=\"date-range\"\n          >\n            <span>\n              Jan 01, 2021\n            </span>\n            <span\n              class=\"mx-2 text-gray-200\"\n            >\n              to\n            </span>\n            <span>\n              Jan 01, 2022\n            </span>\n            <svg\n              class=\"ml-4 text-lg\"\n              fill=\"currentColor\"\n              height=\"1em\"\n              stroke=\"currentColor\"\n              stroke-width=\"0\"\n              viewBox=\"0 0 24 24\"\n              width=\"1em\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n            >\n              <g>\n                <path\n                  d=\"M0 0h24v24H0z\"\n                  fill=\"none\"\n                />\n                <path\n                  d=\"M17 3h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4V1h2v2h6V1h2v2zM4 9v10h16V9H4zm2 4h5v4H6v-4z\"\n                />\n              </g>\n            </svg>\n          </div>\n        </button>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative z-10\"\n      data-headlessui-state=\"\"\n    >\n      <button\n        aria-expanded=\"false\"\n        data-headlessui-state=\"\"\n        data-testid=\"datepicker-range-toggle-icon\"\n        id=\"headlessui-popover-button-:r5:\"\n        type=\"button\"\n      >\n        <div\n          class=\"flex items-center justify-between font-normal text-gray-25 text-base border border-gray-200 rounded py-2 px-4\"\n          data-testid=\"date-range\"\n        >\n          <span>\n            Jan 01, 2021\n          </span>\n          <span\n            class=\"mx-2 text-gray-200\"\n          >\n            to\n          </span>\n          <span>\n            Jan 01, 2022\n          </span>\n          <svg\n            class=\"ml-4 text-lg\"\n            fill=\"currentColor\"\n            height=\"1em\"\n            stroke=\"currentColor\"\n            stroke-width=\"0\"\n            viewBox=\"0 0 24 24\"\n            width=\"1em\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <g>\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M17 3h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4V1h2v2h6V1h2v2zM4 9v10h16V9H4zm2 4h5v4H6v-4z\"\n              />\n            </g>\n          </svg>\n        </div>\n      </button>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`<DatePickerRange /> When datepicker is closed should have placeholder when empty 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"relative z-10\"\n        data-headlessui-state=\"\"\n      >\n        <button\n          aria-expanded=\"false\"\n          data-headlessui-state=\"\"\n          data-testid=\"datepicker-range-toggle-icon\"\n          id=\"headlessui-popover-button-:r0:\"\n          type=\"button\"\n        >\n          <div\n            class=\"flex items-center justify-between font-normal text-gray-25 text-base border border-gray-200 rounded py-2 px-4\"\n            data-testid=\"date-range\"\n          >\n            <span>\n              Select a date range\n            </span>\n            <svg\n              class=\"ml-4 text-lg\"\n              fill=\"currentColor\"\n              height=\"1em\"\n              stroke=\"currentColor\"\n              stroke-width=\"0\"\n              viewBox=\"0 0 24 24\"\n              width=\"1em\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n            >\n              <g>\n                <path\n                  d=\"M0 0h24v24H0z\"\n                  fill=\"none\"\n                />\n                <path\n                  d=\"M17 3h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4V1h2v2h6V1h2v2zM4 9v10h16V9H4zm2 4h5v4H6v-4z\"\n                />\n              </g>\n            </svg>\n          </div>\n        </button>\n      </div>\n    </div>\n    <div\n      id=\"headlessui-portal-root\"\n    >\n      <div\n        data-headlessui-portal=\"\"\n      />\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative z-10\"\n      data-headlessui-state=\"\"\n    >\n      <button\n        aria-expanded=\"false\"\n        data-headlessui-state=\"\"\n        data-testid=\"datepicker-range-toggle-icon\"\n        id=\"headlessui-popover-button-:r0:\"\n        type=\"button\"\n      >\n        <div\n          class=\"flex items-center justify-between font-normal text-gray-25 text-base border border-gray-200 rounded py-2 px-4\"\n          data-testid=\"date-range\"\n        >\n          <span>\n            Select a date range\n          </span>\n          <svg\n            class=\"ml-4 text-lg\"\n            fill=\"currentColor\"\n            height=\"1em\"\n            stroke=\"currentColor\"\n            stroke-width=\"0\"\n            viewBox=\"0 0 24 24\"\n            width=\"1em\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <g>\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M17 3h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4V1h2v2h6V1h2v2zM4 9v10h16V9H4zm2 4h5v4H6v-4z\"\n              />\n            </g>\n          </svg>\n        </div>\n      </button>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerRange/index.ts",
    "content": "export * from './DatePickerRange'\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/DatePickerYear.tsx",
    "content": "import type { Calendar, GetBackForwardPropsOptions } from 'dayzed'\nimport classNames from 'classnames'\nimport { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri'\n\nimport { generateYearsRange, disabled } from './utils'\n\nexport interface DatePickerYearProps {\n    calendars: Calendar[]\n    getBackProps: (data: GetBackForwardPropsOptions) => Record<string, any>\n    getForwardProps: (data: GetBackForwardPropsOptions) => Record<string, any>\n    onYearSelected: () => void\n    minDate: string\n    maxDate: string\n}\n\nexport function DatePickerYear({\n    calendars,\n    getBackProps,\n    getForwardProps,\n    onYearSelected,\n    minDate,\n    maxDate,\n}: DatePickerYearProps) {\n    const calendar = calendars[0]\n    const selectedYear = calendar.year\n    const years = generateYearsRange(selectedYear)\n\n    const getYearProps = (year: number) => {\n        if (year > calendar.year) {\n            const forwardProps = getForwardProps({\n                calendars,\n                offset: (year - calendar.year) * 12,\n                onClick: onYearSelected,\n            })\n\n            return {\n                ...forwardProps,\n                disabled: disabled({ year, maxDate }),\n            }\n        }\n\n        const backProps = getBackProps({\n            calendars,\n            offset: (calendar.year - year) * 12,\n            onClick: onYearSelected,\n        })\n\n        return {\n            ...backProps,\n            disabled: disabled({ year, minDate }),\n        }\n    }\n\n    return (\n        <div>\n            <div className=\"flex justify-around gap-x-2\">\n                <button\n                    className={classNames(\n                        'text-white hover:bg-gray-500 flex justify-center items-center rounded w-8 h-8',\n                        'disabled:opacity-50 disabled:pointer-events-none'\n                    )}\n                    type=\"button\"\n                    {...getBackProps({ calendars, offset: 12 * 12 })}\n                >\n                    <RiArrowLeftSLine size={24} />\n                </button>\n                <span className=\"text-white  flex justify-center items-center rounded grow\">\n                    {`${calendar.year - 5} - ${calendar.year + 6}`}\n                </span>\n                <button\n                    className={classNames(\n                        'text-white hover:bg-gray-500 flex justify-center items-center rounded w-8 h-8',\n                        'disabled:opacity-50 disabled:pointer-events-none'\n                    )}\n                    type=\"button\"\n                    {...getForwardProps({ calendars, offset: 12 * 12 })}\n                >\n                    <RiArrowRightSLine size={24} />\n                </button>\n            </div>\n            <div className=\"grid grid-cols-3 grid-rows-4 gap-2 mt-2\">\n                {years.map((year) => (\n                    <button\n                        {...getYearProps(year)}\n                        key={year}\n                        className={classNames(\n                            'flex justify-center items-center rounded h-10 w-20',\n                            'disabled:opacity-50 disabled:pointer-events-none',\n                            year === selectedYear\n                                ? 'bg-cyan-500 text-black hover:bg-cyan-400'\n                                : 'text-white hover:bg-gray-500'\n                        )}\n                    >\n                        {year}\n                    </button>\n                ))}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/__snapshots__/DatePicker.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`<DatePicker /> When datepicker is closed should have placeholder when empty 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"relative\"\n        data-headlessui-state=\"\"\n      >\n        <div>\n          <label\n            class=\"flex w-full flex-col\"\n          >\n            <div\n              class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n            >\n              <input\n                class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n                inputmode=\"numeric\"\n                name=\"picker\"\n                placeholder=\"MM / DD / YYYY\"\n                type=\"text\"\n                value=\"\"\n              />\n              <span\n                class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n              >\n                <button\n                  aria-expanded=\"false\"\n                  data-headlessui-state=\"\"\n                  data-testid=\"datepicker-toggle-icon\"\n                  id=\"headlessui-popover-button-:r0:\"\n                  type=\"button\"\n                >\n                  <svg\n                    class=\"text-lg\"\n                    fill=\"currentColor\"\n                    height=\"1em\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"0\"\n                    viewBox=\"0 0 24 24\"\n                    width=\"1em\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                  >\n                    <g>\n                      <path\n                        d=\"M0 0h24v24H0z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M17 3h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4V1h2v2h6V1h2v2zM4 9v10h16V9H4zm2 4h5v4H6v-4z\"\n                      />\n                    </g>\n                  </svg>\n                </button>\n              </span>\n            </div>\n          </label>\n        </div>\n      </div>\n    </div>\n    <div\n      id=\"headlessui-portal-root\"\n    >\n      <div\n        data-headlessui-portal=\"\"\n      />\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative\"\n      data-headlessui-state=\"\"\n    >\n      <div>\n        <label\n          class=\"flex w-full flex-col\"\n        >\n          <div\n            class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n          >\n            <input\n              class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n              inputmode=\"numeric\"\n              name=\"picker\"\n              placeholder=\"MM / DD / YYYY\"\n              type=\"text\"\n              value=\"\"\n            />\n            <span\n              class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n            >\n              <button\n                aria-expanded=\"false\"\n                data-headlessui-state=\"\"\n                data-testid=\"datepicker-toggle-icon\"\n                id=\"headlessui-popover-button-:r0:\"\n                type=\"button\"\n              >\n                <svg\n                  class=\"text-lg\"\n                  fill=\"currentColor\"\n                  height=\"1em\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"0\"\n                  viewBox=\"0 0 24 24\"\n                  width=\"1em\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                >\n                  <g>\n                    <path\n                      d=\"M0 0h24v24H0z\"\n                      fill=\"none\"\n                    />\n                    <path\n                      d=\"M17 3h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4V1h2v2h6V1h2v2zM4 9v10h16V9H4zm2 4h5v4H6v-4z\"\n                    />\n                  </g>\n                </svg>\n              </button>\n            </span>\n          </div>\n        </label>\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/index.ts",
    "content": "export type { DatePickerProps } from './DatePicker'\nexport type { DatePickerRangeProps } from './DatePickerRange'\nexport type { DateRange, SelectableRangeKeys, SelectableDateRange } from './selectableRanges'\n\nexport { default as DatePicker } from './DatePicker'\nexport { DatePickerRange } from './DatePickerRange'\nexport { getNormalizedRanges, getRangeDescription } from './selectableRanges'\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/selectableRanges.ts",
    "content": "import { DateTime } from 'luxon'\n\nexport type DateRange = {\n    start: string\n    end: string\n}\n\nexport type SelectableRangeKeys =\n    | 'day'\n    | 'last-7-days'\n    | 'this-month'\n    | 'prior-month'\n    | 'last-30-days'\n    | 'last-90-days'\n    | 'last-6-months'\n    | 'this-year'\n    | 'prior-year'\n    | 'last-365-days'\n    | 'last-3-years'\n    | 'last-5-years'\n\nexport type SelectableDateRange = DateRange & {\n    label: string\n    labelShort: string\n    alternateLabel?: string\n}\n\nexport const getNormalizedRanges: (\n    selections: Array<SelectableRangeKeys | SelectableDateRange> | 'all'\n) => SelectableDateRange[] = (selections) => {\n    const now = DateTime.now()\n    const nowISO = now.toISODate()\n\n    const ranges: { [key in SelectableRangeKeys]: SelectableDateRange } = {\n        day: {\n            label: 'Today',\n            labelShort: '1D',\n            alternateLabel: 'today',\n            start: now.minus({ days: 1 }).toISODate(),\n            end: nowISO,\n        },\n        'last-7-days': {\n            label: 'Last 7 days',\n            labelShort: '7D',\n            alternateLabel: 'past week',\n            start: now.minus({ days: 7 }).toISODate(),\n            end: nowISO,\n        },\n        'this-month': {\n            label: 'This month',\n            labelShort: 'This month',\n            alternateLabel: 'this month',\n            start: now.startOf('month').toISODate(),\n            end: nowISO,\n        },\n        'prior-month': {\n            label: 'Last month',\n            labelShort: 'Last month',\n            alternateLabel: 'last month',\n            start: now.minus({ months: 1 }).startOf('month').toISODate(),\n            end: now.minus({ months: 1 }).endOf('month').toISODate(),\n        },\n        'last-30-days': {\n            label: 'Last month',\n            labelShort: '1M',\n            alternateLabel: 'past month',\n            start: now.minus({ days: 30 }).toISODate(),\n            end: nowISO,\n        },\n        'last-90-days': {\n            label: 'Last 3 months',\n            labelShort: '3M',\n            alternateLabel: 'past 3 months',\n            start: now.minus({ days: 90 }).toISODate(),\n            end: nowISO,\n        },\n        'last-6-months': {\n            label: 'Last 6 months',\n            labelShort: '6M',\n            alternateLabel: 'past 6 months',\n            start: now.minus({ months: 6 }).toISODate(),\n            end: nowISO,\n        },\n        'prior-year': {\n            label: 'Last year',\n            labelShort: 'Last year',\n            alternateLabel: 'last year',\n            start: now.minus({ years: 1 }).startOf('year').toISODate(),\n            end: now.minus({ years: 1 }).endOf('year').toISODate(),\n        },\n        'last-365-days': {\n            label: 'Last 365 days',\n            labelShort: '1Y',\n            alternateLabel: 'past year',\n            start: now.minus({ days: 365 }).toISODate(),\n            end: nowISO,\n        },\n        'this-year': {\n            label: 'This year',\n            labelShort: 'YTD',\n            alternateLabel: 'this year',\n            start: now.startOf('year').toISODate(),\n            end: nowISO,\n        },\n        'last-3-years': {\n            label: 'Last 3 years',\n            labelShort: '3Y',\n            alternateLabel: 'past 3 years',\n            start: now.minus({ years: 3 }).toISODate(),\n            end: nowISO,\n        },\n        'last-5-years': {\n            label: 'Last 5 years',\n            labelShort: '5Y',\n            alternateLabel: 'past 5 years',\n            start: now.minus({ years: 5 }).toISODate(),\n            end: nowISO,\n        },\n    }\n\n    if (typeof selections === 'string' && selections === 'all') {\n        return Object.values(ranges)\n    }\n\n    return selections.map((selection) => {\n        if (typeof selection === 'string') {\n            return ranges[selection]\n        } else {\n            return selection\n        }\n    })\n}\n\nexport const getRangeDescription = (range?: DateRange, minDate?: string) => {\n    if (!range) return 'in this period'\n\n    const fromStartRange = {\n        start: minDate,\n        end: DateTime.now().toISODate(),\n        alternateLabel: 'from the start',\n    }\n\n    const knownRanges = [...getNormalizedRanges('all'), fromStartRange]\n\n    const knownRange = knownRanges.find(\n        ({ start, end }) => range.start === start && range.end === end\n    )\n\n    if (knownRange) {\n        return knownRange.alternateLabel || 'in this period'\n    }\n\n    return 'in this period'\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/utils.spec.tsx",
    "content": "import { generateYearsRange, disabled } from './utils'\n\ndescribe('disabled', () => {\n    it('should return true if year is less than minDate', () => {\n        const year = 2021\n        const minDate = '2022-01-01'\n        const result = disabled({ year, minDate })\n        expect(result).toBe(true)\n    })\n\n    it('should return false if year is more or equal than minDate', () => {\n        const year = 2022\n        const minDate = '2022-01-01'\n        const result = disabled({ year, minDate })\n        expect(result).toBe(false)\n    })\n\n    it('should return false if year is less or equal than maxDate', () => {\n        const year = 2022\n        const maxDate = '2022-01-01'\n        const result = disabled({ year, maxDate })\n        expect(result).toBe(false)\n    })\n\n    it('should return true if year is more than maxDate', () => {\n        const year = 2023\n        const maxDate = '2022-01-01'\n        const result = disabled({ year, maxDate })\n        expect(result).toBe(true)\n    })\n})\n\ndescribe('generateYearsRange', () => {\n    const currentYear = 2022\n\n    it('should return a range of years from a previous year', () => {\n        const years = generateYearsRange(2006, currentYear)\n        expect(years).toEqual([\n            2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017,\n        ])\n    })\n\n    it('should return a range of years from current year', () => {\n        const years = generateYearsRange(2018, currentYear)\n        expect(years).toEqual([\n            2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027, 2028, 2029,\n        ])\n    })\n\n    it('should return a range of years from next year', () => {\n        const years = generateYearsRange(2030, currentYear)\n        expect(years).toEqual([\n            2030, 2031, 2032, 2033, 2034, 2035, 2036, 2037, 2038, 2039, 2040, 2041,\n        ])\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/DatePicker/utils.tsx",
    "content": "import range from 'lodash/range'\nimport { DateTime } from 'luxon'\n\ntype DisabledProps = {\n    year: number\n    minDate?: string\n    maxDate?: string\n}\n\n// This custom disabled function because dayzed\n// ignores the offset when calculating the disabled dates\n// https://github.com/maybe-finance/maybe-app/pull/417#issuecomment-1208475083\nexport const disabled = ({ year, minDate, maxDate }: DisabledProps) => {\n    if (minDate) {\n        const minDateYear = DateTime.fromISO(minDate).year\n\n        if (year < minDateYear) {\n            return true\n        }\n    }\n\n    if (maxDate) {\n        const maxDateYear = DateTime.fromISO(maxDate).year\n\n        if (year > maxDateYear) {\n            return true\n        }\n    }\n\n    return false\n}\n\n// Return a year grid of 12 years\n// with the current year in the center position (index 4, position 2,2)\nexport const generateYearsRange = (year: number, currentYear = DateTime.now().year) => {\n    const naturalIndex = currentYear % 12 // Which position the current year would be in the grid if the grid started with 0\n    const desiredIndex = 4 // Which position we want the current year be in the grid\n    const offset = (year + naturalIndex + desiredIndex) % 12\n\n    return range(year - offset, year - offset + 12)\n}\n\n// We allow a maximum of 30 years of history for performance reasons (hypertable chunking)\nexport const MIN_SUPPORTED_DATE = DateTime.utc().minus({ years: 30 }).startOf('day')\nexport const MAX_SUPPORTED_DATE = DateTime.utc().startOf('day')\n"
  },
  {
    "path": "libs/design-system/src/lib/Dialog/Dialog.spec.tsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport { Dialog } from './'\n\ndescribe('Dialog', () => {\n    describe('when rendered with `open` prop true', () => {\n        it('should display the modals contents', () => {\n            const onToggleMock = jest.fn()\n            const component = render(\n                <Dialog isOpen={true} onClose={onToggleMock}>\n                    <Dialog.Title>Dialog Title</Dialog.Title>\n                    <Dialog.Content>Dialog Content</Dialog.Content>\n                    <Dialog.Description>Dialog Description</Dialog.Description>\n                    <Dialog.Actions>Dialog Actions</Dialog.Actions>\n                </Dialog>\n            )\n\n            expect(screen.getByText('Dialog Title')).toBeInTheDocument()\n            expect(screen.getByText('Dialog Content')).toBeInTheDocument()\n            expect(screen.getByText('Dialog Description')).toBeInTheDocument()\n            expect(screen.getByText('Dialog Actions')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Dialog/Dialog.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { DialogProps } from './Dialog'\nimport * as React from 'react'\n\nimport Dialog from './Dialog'\n\n// Misc UI elements for stories\nimport { Button, Input, FormGroup } from '../../'\n\nexport default {\n    title: 'Components/Dialog',\n    component: Dialog,\n    parameters: {\n        controls: { exclude: ['as', 'className'] },\n    },\n} as Meta\n\nexport const Base: Story<DialogProps> = (args) => {\n    const [isOpen, setIsOpen] = React.useState(false)\n    return (\n        <div className=\"h-48 flex items-center justify-center\">\n            <Button onClick={() => setIsOpen(true)}>Open Dialog</Button>\n            <Dialog {...args} isOpen={isOpen} onClose={() => setIsOpen(false)}>\n                <Dialog.Title>Add Home</Dialog.Title>\n                <Dialog.Content>\n                    <div className=\"space-y-3\">\n                        <Input type=\"text\" label=\"Label\" />\n                        <Input type=\"text\" label=\"Label\" />\n                        <Input type=\"text\" label=\"Label\" />\n                    </div>\n                </Dialog.Content>\n                <Dialog.Actions>\n                    <Button type=\"button\" onClick={() => setIsOpen(false)} fullWidth>\n                        Add Home\n                    </Button>\n                </Dialog.Actions>\n            </Dialog>\n        </div>\n    )\n}\n\nexport const WithDescription: Story<DialogProps> = (args) => {\n    const [isOpen, setIsOpen] = React.useState(false)\n    return (\n        <div className=\"h-48 flex items-center justify-center\">\n            <Button onClick={() => setIsOpen(true)}>Open Dialog</Button>\n            <Dialog {...args} isOpen={isOpen} onClose={() => setIsOpen(false)}>\n                <Dialog.Title>Add Home</Dialog.Title>\n                <Dialog.Content>\n                    <FormGroup className=\"space-y-3\">\n                        <Input type=\"text\" label=\"Label\" />\n                        <Input type=\"text\" label=\"Label\" />\n                        <Input type=\"text\" label=\"Label\" />\n                    </FormGroup>\n                </Dialog.Content>\n                <Dialog.Description>\n                    Removing the Business Account will remove all related transactions and\n                    historical data. This will likely impact other views such as your net worth\n                    dashboard.\n                </Dialog.Description>\n                <Dialog.Actions>\n                    <Button type=\"button\" onClick={() => setIsOpen(false)} fullWidth>\n                        Add Home\n                    </Button>\n                </Dialog.Actions>\n            </Dialog>\n        </div>\n    )\n}\n\nexport const WithMultipleActions: Story<DialogProps> = (args) => {\n    const [isOpen, setIsOpen] = React.useState(false)\n    return (\n        <div className=\"h-48 flex items-center justify-center\">\n            <Button onClick={() => setIsOpen(true)}>Open Dialog</Button>\n            <Dialog {...args} isOpen={isOpen} onClose={() => setIsOpen(false)}>\n                <Dialog.Title>Add Home</Dialog.Title>\n                <Dialog.Content>\n                    <div className=\"space-y-3\">\n                        <Input type=\"text\" label=\"Label\" />\n                        <Input type=\"text\" label=\"Label\" />\n                        <Input type=\"text\" label=\"Label\" />\n                    </div>\n                </Dialog.Content>\n\n                <Dialog.Actions>\n                    <Button\n                        type=\"button\"\n                        variant=\"secondary\"\n                        onClick={() => setIsOpen(false)}\n                        fullWidth\n                    >\n                        Cancel\n                    </Button>\n                    <Button\n                        type=\"button\"\n                        variant=\"primary\"\n                        onClick={() => setIsOpen(false)}\n                        fullWidth\n                    >\n                        Add Home\n                    </Button>\n                </Dialog.Actions>\n            </Dialog>\n        </div>\n    )\n}\n\nexport const KitchenSink: Story<DialogProps> = (args) => {\n    const [isOpen, setIsOpen] = React.useState(false)\n    return (\n        <div className=\"h-48 flex items-center justify-center\">\n            <Button onClick={() => setIsOpen(true)}>Open Dialog</Button>\n            <Dialog {...args} isOpen={isOpen} onClose={() => setIsOpen(false)}>\n                <Dialog.Title>Add Home</Dialog.Title>\n                <Dialog.Content>\n                    <FormGroup className=\"space-y-3\">\n                        <Input type=\"text\" label=\"Label\" />\n                        <Input type=\"text\" label=\"Label\" />\n                        <Input type=\"text\" label=\"Label\" />\n                    </FormGroup>\n                </Dialog.Content>\n                <Dialog.Description>\n                    Removing the Business Account will remove all related transactions and\n                    historical data. This will likely impact other views such as your net worth\n                    dashboard.\n                </Dialog.Description>\n                <Dialog.Actions>\n                    <Button\n                        type=\"button\"\n                        variant=\"secondary\"\n                        onClick={() => setIsOpen(false)}\n                        fullWidth\n                    >\n                        Cancel\n                    </Button>\n                    <Button\n                        type=\"button\"\n                        variant=\"primary\"\n                        onClick={() => setIsOpen(false)}\n                        fullWidth\n                    >\n                        Add\n                    </Button>\n                </Dialog.Actions>\n            </Dialog>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/Dialog/Dialog.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport React, { Children, Fragment } from 'react'\nimport { Dialog as HeadlessDialog, Transition } from '@headlessui/react'\nimport { RiCloseFill as IconClose } from 'react-icons/ri'\nimport classNames from 'classnames'\n\nconst DialogSizeClassName = Object.freeze({\n    xs: 'sm:max-w-xs',\n    sm: 'sm:max-w-sm',\n    md: 'sm:max-w-md',\n    lg: 'sm:max-w-lg',\n    xl: 'sm:max-w-xl',\n    '2xl': 'sm:max-w-2xl',\n})\n\nexport interface DialogProps {\n    /** Boolean value that controls open/close states */\n    isOpen?: boolean\n\n    /** Function to run on dialog close—also used to connect to close icon, and should be function used to toggle `isOpen` prop */\n    onClose: () => void\n\n    /** Whether or not to show the close icon */\n    showCloseButton?: boolean\n\n    initialFocus?: React.MutableRefObject<HTMLElement | null>\n\n    // defaults to 'md'\n    size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'\n}\n\nexport function DialogRoot({\n    isOpen = false,\n    onClose,\n    showCloseButton = true,\n    initialFocus,\n    size = 'md',\n    children,\n    ...rest\n}: PropsWithChildren<DialogProps>): JSX.Element {\n    let actions: React.ReactNode = null\n    let title: React.ReactNode = null\n    let content: React.ReactNode = null\n    let description: React.ReactNode = null\n    let unhandledChildren: React.ReactNode = null\n\n    Children.forEach(children, (child) => {\n        const name = (child as any).type?.name\n        switch (name) {\n            case 'Actions':\n                actions = child\n                break\n            case 'Content':\n                content = child\n                break\n            case 'Title':\n                title = child\n                break\n            case 'Description':\n                description = child\n                break\n            default:\n                unhandledChildren = child\n                console.warn(\n                    'Unhanded child type. Wrap your child element in <Dialog.Actions/>, <Dialog.Content/>, <Dialog.Title/>, or <Dialog.Description/> to ensure proper placement.'\n                )\n                break\n        }\n    })\n\n    return (\n        <Transition.Root\n            show={isOpen}\n            as={Fragment}\n            leave=\"ease-in duration-200\"\n            leaveFrom=\"opacity-100\"\n            leaveTo=\"opacity-0\"\n        >\n            <HeadlessDialog\n                as=\"div\"\n                className=\"relative z-10\"\n                onClose={onClose}\n                initialFocus={initialFocus}\n                {...rest}\n            >\n                <Transition.Child\n                    as={Fragment}\n                    enter=\"ease-out duration-300\"\n                    enterFrom=\"opacity-0\"\n                    enterTo=\"opacity-100\"\n                    leave=\"ease-in duration-200\"\n                    leaveFrom=\"opacity-100\"\n                    leaveTo=\"opacity-0\"\n                >\n                    {/* The backdrop */}\n                    <div\n                        className=\"fixed inset-0 bg-gray-800 bg-opacity-80 transition-opacity\"\n                        aria-hidden=\"true\"\n                    />\n                </Transition.Child>\n\n                <div className=\"fixed z-20 inset-0 overflow-y-auto\">\n                    <div className=\"flex items-center justify-center p-4 sm:p-0 min-h-full text-center \">\n                        <Transition.Child\n                            as={Fragment}\n                            enter=\"ease-out duration-300\"\n                            enterFrom=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                            enterTo=\"opacity-100 translate-y-0 sm:scale-100\"\n                            leave=\"ease-in duration-200\"\n                            leaveFrom=\"opacity-100 translate-y-0 sm:scale-100\"\n                            leaveTo=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                        >\n                            {/* Modal contents */}\n                            <HeadlessDialog.Panel\n                                className={classNames(\n                                    'relative p-4 sm:p-6 sm:my-8 w-full bg-gray-700 rounded text-left shadow-md shadow-black transform transition-all',\n                                    DialogSizeClassName[size]\n                                )}\n                            >\n                                <div className=\"w-full flex items-start justify-between\">\n                                    {title && title}\n                                    {showCloseButton && (\n                                        <div className=\"shrink-0 pl-6 ml-auto\">\n                                            <button\n                                                type=\"button\"\n                                                className=\"h-8 w-8 flex items-center justify-center bg-transparent text-gray-50 hover:bg-gray-500 rounded focus:bg-gray-400 focus:outline-none\"\n                                                onClick={onClose}\n                                            >\n                                                <IconClose className=\"h-6 w-6\" />\n                                            </button>\n                                        </div>\n                                    )}\n                                </div>\n                                <div className=\"mt-6\">\n                                    {content}\n                                    {description && description}\n                                </div>\n\n                                {unhandledChildren && (\n                                    <div className=\"py-2\">{unhandledChildren}</div>\n                                )}\n\n                                {actions && actions}\n                            </HeadlessDialog.Panel>\n                        </Transition.Child>\n                    </div>\n                </div>\n            </HeadlessDialog>\n        </Transition.Root>\n    )\n}\n\nexport type DialogChildProps = {\n    children: React.ReactNode\n    className?: string\n}\n\nfunction Title({ className, children, ...rest }: DialogChildProps) {\n    return (\n        <HeadlessDialog.Title className={className} as=\"h4\" {...rest}>\n            {children}\n        </HeadlessDialog.Title>\n    )\n}\n\nfunction Content({ className, children, ...rest }: DialogChildProps) {\n    return (\n        <div className={className} {...rest}>\n            {children}\n        </div>\n    )\n}\n\nfunction Description({ className, children, ...rest }: DialogChildProps) {\n    return (\n        <HeadlessDialog.Description className={`text-base text-gray-50 ${className}`} {...rest}>\n            {children}\n        </HeadlessDialog.Description>\n    )\n}\n\nfunction Actions({ className, children, ...rest }: DialogChildProps) {\n    return (\n        <div className={`flex space-x-2 mt-6 ${className}`} {...rest}>\n            {children}\n        </div>\n    )\n}\n\n// This assigns components as \"<Dialog.Title/>\", \"<Dialog.Description/>\", etc.\nconst Dialog = Object.assign(DialogRoot, {\n    Title,\n    Description,\n    Actions,\n    Content,\n})\n\nexport default Dialog\n"
  },
  {
    "path": "libs/design-system/src/lib/Dialog/DialogV2.tsx",
    "content": "import type { ReactNode } from 'react'\nimport { Dialog as HeadlessDialog, Transition } from '@headlessui/react'\nimport { Fragment } from 'react'\nimport { RiCloseFill } from 'react-icons/ri'\nimport classNames from 'classnames'\n\ntype ExtractProps<T> = T extends React.ComponentType<infer P> ? P : T\n\nconst DialogSize = Object.freeze({\n    xs: 'sm:max-w-xs',\n    sm: 'sm:max-w-sm',\n    md: 'sm:max-w-md',\n    lg: 'sm:max-w-lg',\n    xl: 'sm:max-w-xl',\n    '2xl': 'sm:max-w-2xl',\n})\n\ntype Size = keyof typeof DialogSize\n\ntype DialogProps = {\n    title?: ReactNode\n    description?: ReactNode\n    size?: Size\n\n    /* Panel styles */\n    className?: string\n\n    disablePadding?: boolean\n}\n\nexport default function DialogV2({\n    title,\n    description,\n    size = 'md',\n    className,\n    disablePadding = false,\n    children,\n    open,\n    onClose,\n    ...rest\n}: DialogProps & ExtractProps<typeof HeadlessDialog>) {\n    return (\n        <Transition.Root\n            show={open}\n            as={Fragment}\n            leave=\"ease-in duration-200\"\n            leaveFrom=\"opacity-100\"\n            leaveTo=\"opacity-0\"\n        >\n            <HeadlessDialog open={open} onClose={onClose} {...rest}>\n                {/* Backdrop */}\n                <Transition.Child\n                    as={Fragment}\n                    enter=\"ease-out duration-300\"\n                    enterFrom=\"opacity-0\"\n                    enterTo=\"opacity-100\"\n                    leave=\"ease-in duration-200\"\n                    leaveFrom=\"opacity-100\"\n                    leaveTo=\"opacity-0\"\n                >\n                    <div\n                        className=\"fixed inset-0 bg-gray-800 bg-opacity-80 transition-opacity\"\n                        aria-hidden=\"true\"\n                    />\n                </Transition.Child>\n\n                <div className=\"fixed z-20 inset-0 flex justify-center items-center\">\n                    <Transition.Child\n                        as={Fragment}\n                        enter=\"ease-out duration-300\"\n                        enterFrom=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                        enterTo=\"opacity-100 translate-y-0 sm:scale-100\"\n                        leave=\"ease-in duration-200\"\n                        leaveFrom=\"opacity-100 translate-y-0 sm:scale-100\"\n                        leaveTo=\"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95\"\n                    >\n                        <HeadlessDialog.Panel\n                            className={classNames(\n                                'custom-gray-scroll bg-gray-700 rounded shadow-md shadow-black transform transition-all max-h-[600px] w-full',\n                                DialogSize[size],\n                                !disablePadding && 'p-6',\n                                className\n                            )}\n                        >\n                            <>\n                                {title && (\n                                    <div className=\"flex items-center gap-4 justify-between mb-4\">\n                                        <HeadlessDialog.Title as=\"h4\">{title}</HeadlessDialog.Title>\n                                        <div className=\"shrink-0 pl-6 ml-auto\">\n                                            <button\n                                                type=\"button\"\n                                                className=\"h-8 w-8 flex items-center justify-center bg-transparent text-gray-50 hover:bg-gray-500 rounded focus:bg-gray-400 focus:outline-none\"\n                                                onClick={() => onClose(false)}\n                                            >\n                                                <RiCloseFill className=\"h-6 w-6\" />\n                                            </button>\n                                        </div>\n                                    </div>\n                                )}\n                                {description && (\n                                    <div className=\"text-base text-gray-50\">{description}</div>\n                                )}\n                                {children}\n                            </>\n                        </HeadlessDialog.Panel>\n                    </Transition.Child>\n                </div>\n            </HeadlessDialog>\n        </Transition.Root>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/Dialog/__snapshots__/Dialog.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Dialog when rendered with \\`open\\` prop true should display the modals contents 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px; display: none;\"\n      />\n    </div>\n    <div\n      id=\"headlessui-portal-root\"\n    >\n      <div\n        data-headlessui-portal=\"\"\n      >\n        <button\n          aria-hidden=\"true\"\n          style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px;\"\n          type=\"button\"\n        />\n        <div>\n          <div\n            aria-describedby=\"headlessui-description-:r3:\"\n            aria-labelledby=\"headlessui-dialog-title-:r2:\"\n            aria-modal=\"true\"\n            class=\"relative z-10\"\n            data-headlessui-state=\"open\"\n            id=\"headlessui-dialog-:r0:\"\n            role=\"dialog\"\n          >\n            <div\n              aria-hidden=\"true\"\n              class=\"fixed inset-0 bg-gray-800 bg-opacity-80 transition-opacity\"\n            />\n            <div\n              class=\"fixed z-20 inset-0 overflow-y-auto\"\n            >\n              <div\n                class=\"flex items-center justify-center p-4 sm:p-0 min-h-full text-center \"\n              >\n                <div\n                  class=\"relative p-4 sm:p-6 sm:my-8 w-full bg-gray-700 rounded text-left shadow-md shadow-black transform transition-all sm:max-w-md\"\n                  data-headlessui-state=\"open\"\n                  id=\"headlessui-dialog-panel-:r1:\"\n                >\n                  <div\n                    class=\"w-full flex items-start justify-between\"\n                  >\n                    <h4\n                      data-headlessui-state=\"open\"\n                      id=\"headlessui-dialog-title-:r2:\"\n                    >\n                      Dialog Title\n                    </h4>\n                    <div\n                      class=\"shrink-0 pl-6 ml-auto\"\n                    >\n                      <button\n                        class=\"h-8 w-8 flex items-center justify-center bg-transparent text-gray-50 hover:bg-gray-500 rounded focus:bg-gray-400 focus:outline-none\"\n                        type=\"button\"\n                      >\n                        <svg\n                          class=\"h-6 w-6\"\n                          fill=\"currentColor\"\n                          height=\"1em\"\n                          stroke=\"currentColor\"\n                          stroke-width=\"0\"\n                          viewBox=\"0 0 24 24\"\n                          width=\"1em\"\n                          xmlns=\"http://www.w3.org/2000/svg\"\n                        >\n                          <g>\n                            <path\n                              d=\"M0 0h24v24H0z\"\n                              fill=\"none\"\n                            />\n                            <path\n                              d=\"M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636z\"\n                            />\n                          </g>\n                        </svg>\n                      </button>\n                    </div>\n                  </div>\n                  <div\n                    class=\"mt-6\"\n                  >\n                    <div>\n                      Dialog Content\n                    </div>\n                    <p\n                      class=\"text-base text-gray-50 undefined\"\n                      data-headlessui-state=\"open\"\n                      id=\"headlessui-description-:r3:\"\n                    >\n                      Dialog Description\n                    </p>\n                  </div>\n                  <div\n                    class=\"flex space-x-2 mt-6 undefined\"\n                  >\n                    Dialog Actions\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n        <button\n          aria-hidden=\"true\"\n          style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px;\"\n          type=\"button\"\n        />\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px; display: none;\"\n    />\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Dialog/index.ts",
    "content": "export { default as DialogV2 } from './DialogV2'\nexport { default as Dialog } from './Dialog'\nexport type { DialogProps } from './Dialog'\n"
  },
  {
    "path": "libs/design-system/src/lib/FormGroup/FormGroup.spec.tsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport FormGroup from './FormGroup'\n\ndescribe('FormGroup', () => {\n    describe('when rendered with children', () => {\n        it('should wrap and display the children', () => {\n            const component = render(\n                <FormGroup>\n                    <label htmlFor=\"input\">Hello, World!</label>\n                    <input type=\"text\" id=\"input\" />\n                </FormGroup>\n            )\n\n            expect(screen.getByText('Hello, World!')).toBeInTheDocument()\n            expect(screen.getByLabelText('Hello, World!')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/FormGroup/FormGroup.tsx",
    "content": "import type { ReactNode } from 'react'\nimport classNames from 'classnames'\n\nexport interface FormGroupProps {\n    className?: string\n    children?: ReactNode\n}\n\nfunction FormGroup({ className, children, ...rest }: FormGroupProps): JSX.Element {\n    const combinedClassName = classNames(className, 'mb-4')\n\n    return (\n        <div className={combinedClassName} {...rest}>\n            {children}\n        </div>\n    )\n}\n\nexport default FormGroup\n"
  },
  {
    "path": "libs/design-system/src/lib/FormGroup/__snapshots__/FormGroup.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`FormGroup when rendered with children should wrap and display the children 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"mb-4\"\n      >\n        <label\n          for=\"input\"\n        >\n          Hello, World!\n        </label>\n        <input\n          id=\"input\"\n          type=\"text\"\n        />\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"mb-4\"\n    >\n      <label\n        for=\"input\"\n      >\n        Hello, World!\n      </label>\n      <input\n        id=\"input\"\n        type=\"text\"\n      />\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/FormGroup/index.ts",
    "content": "export { default as FormGroup } from './FormGroup'\nexport type { FormGroupProps } from './FormGroup'\n"
  },
  {
    "path": "libs/design-system/src/lib/FractionalCircle/FractionalCircle.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\n\nimport FractionalCircle from './FractionalCircle'\n\nexport default {\n    title: 'Components/FractionalCircle',\n    component: FractionalCircle,\n    argTypes: {\n        variant: {\n            options: ['default'],\n            control: { type: 'radio' },\n            defaultValue: 'default',\n        },\n    },\n    args: {\n        radius: 50,\n        stroke: 10,\n        percent: 15,\n    },\n} as Meta\n\nexport const Base: Story = (args) => (\n    <div className=\"flex items-center justify-center\">\n        <FractionalCircle {...(args as any)} />\n    </div>\n)\n"
  },
  {
    "path": "libs/design-system/src/lib/FractionalCircle/FractionalCircle.tsx",
    "content": "import classNames from 'classnames'\n\ntype CircleProps = {\n    radius: number\n    stroke: number\n    className?: string\n    fill?: boolean\n    percent?: number\n}\n\nfunction Circle({ radius, stroke, className, percent = 100 }: CircleProps) {\n    // validate\n    const _percent = percent < 0 ? 0 : percent > 100 ? 100 : percent\n    const circumference = Math.PI * 2 * radius\n    const strokePercent = ((100 - _percent) * circumference) / 100\n\n    return (\n        <circle\n            r={radius}\n            cx={radius + stroke / 2}\n            cy={radius + stroke / 2}\n            strokeWidth={stroke}\n            className={classNames('fill-transparent stroke-current', className)}\n            strokeDasharray={circumference}\n            strokeDashoffset={strokePercent}\n            transform={`rotate(-90, ${radius + stroke / 2}, ${radius + stroke / 2})`}\n            strokeLinecap=\"round\"\n        />\n    )\n}\n\nexport type FractionalCircleProps = {\n    percent: number\n    variant?: 'default' | 'yellow' | 'green' | 'red'\n    radius?: number\n    stroke?: number\n}\n\nconst CircleVariant = Object.freeze({\n    default: {\n        ring: 'text-white',\n        background: 'text-gray-300',\n    },\n    yellow: {\n        ring: 'text-yellow',\n        background: 'text-gray-300',\n    },\n    green: {\n        ring: 'text-teal',\n        background: 'text-teal-300',\n    },\n    red: {\n        ring: 'text-red',\n        background: 'text-red-300',\n    },\n})\n\nexport default function FractionalCircle({\n    percent,\n    variant = 'default',\n    radius = 7,\n    stroke = 2,\n}: FractionalCircleProps) {\n    return (\n        <svg width={radius * 2 + stroke} height={radius * 2 + stroke}>\n            <Circle radius={radius} stroke={stroke} className={CircleVariant[variant].background} />\n            <Circle\n                radius={radius}\n                stroke={stroke}\n                percent={percent}\n                className={CircleVariant[variant].ring}\n            />\n        </svg>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/FractionalCircle/index.ts",
    "content": "export { default as FractionalCircle } from './FractionalCircle'\nexport type { FractionalCircleProps } from './FractionalCircle'\n"
  },
  {
    "path": "libs/design-system/src/lib/IndexTabs/IndexTabs.spec.tsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport { useRef } from 'react'\nimport { IndexTabs } from './'\n\nconst Example = () => {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n    const section1 = useRef<HTMLDivElement>(null)\n    const section2 = useRef<HTMLDivElement>(null)\n\n    return (\n        <>\n            <IndexTabs\n                scrollContainer={scrollContainer}\n                sections={[\n                    {\n                        name: 'Section 1',\n                        elementRef: section1,\n                    },\n                    {\n                        name: 'Section 2',\n                        elementRef: section2,\n                    },\n                ]}\n            />\n            <div style={{ overflowY: 'auto', height: '400px' }} ref={scrollContainer}>\n                <div ref={section1} style={{ height: '500px' }}>\n                    Content 1\n                </div>\n                <div ref={section2} style={{ height: '500px' }}>\n                    Content 2\n                </div>\n            </div>\n        </>\n    )\n}\n\n// Very light testing - it's challenging to mock/test all of the scroll interactions\ndescribe('IndexTabs', () => {\n    it('should render correctly', () => {\n        const component = render(<Example />)\n\n        expect(screen.getByText('Section 1')).toBeInTheDocument()\n        expect(screen.queryByText('Content 1')).toBeInTheDocument()\n\n        expect(screen.getByText('Section 2')).toBeInTheDocument()\n        expect(screen.queryByText('Content 2')).toBeInTheDocument()\n\n        expect(component).toMatchSnapshot()\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/IndexTabs/IndexTabs.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport { useRef } from 'react'\n\nimport { IndexTabs } from '.'\n\nexport default {\n    title: 'Components/IndexTabs',\n    argTypes: {},\n    args: {},\n} as Meta\n\nexport const Base: Story = (_args) => {\n    const scrollContainer = useRef<HTMLDivElement>(null)\n    const section1 = useRef<HTMLDivElement>(null)\n    const section2 = useRef<HTMLDivElement>(null)\n    const section3 = useRef<HTMLDivElement>(null)\n\n    return (\n        <>\n            <IndexTabs\n                scrollContainer={scrollContainer}\n                sections={[\n                    {\n                        name: 'Section 1',\n                        elementRef: section1,\n                    },\n                    {\n                        name: 'Section 2',\n                        elementRef: section2,\n                    },\n                    {\n                        name: 'Section 3',\n                        elementRef: section3,\n                    },\n                ]}\n            />\n            <div className=\"h-24 w-96 mt-2 overflow-y-auto text-white\" ref={scrollContainer}>\n                <div className=\"mb-4\" ref={section1}>\n                    <h1 className=\"uppercase font-bold\">Section 1</h1>\n                    <p className=\"text-gray-50\">\n                        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod\n                        tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\n                        quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\n                        consequat.\n                    </p>\n                </div>\n                <div className=\"mb-4\" ref={section2}>\n                    <h1 className=\"uppercase font-bold\">Section 2</h1>\n                    <p className=\"text-gray-50\">\n                        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod\n                        tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\n                        quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\n                        consequat.\n                    </p>\n                </div>\n                <div className=\"mb-4\" ref={section3}>\n                    <h1 className=\"uppercase font-bold\">Section 3</h1>\n                    <p className=\"text-gray-50\">\n                        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod\n                        tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\n                        quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\n                        consequat.\n                    </p>\n                </div>\n            </div>\n        </>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/IndexTabs/IndexTabs.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react'\nimport type { RefObject, HTMLAttributes } from 'react'\nimport scrollIntoView from 'smooth-scroll-into-view-if-needed'\nimport classNames from 'classnames'\nimport { animate } from 'framer-motion'\n\nexport interface IndexTabsProps extends HTMLAttributes<HTMLElement> {\n    sections: {\n        name: string\n        elementRef: RefObject<HTMLElement>\n    }[]\n    scrollContainer: RefObject<HTMLElement>\n    initialIndex?: number\n}\n\nexport default function IndexTabs({\n    className,\n    sections,\n    scrollContainer,\n    initialIndex,\n    ...rest\n}: IndexTabsProps): JSX.Element {\n    // The active tab's index\n    const [activeIndex, setActiveIndex] = useState<number | null>(0)\n\n    // The index being automatically scrolled to\n    const [scrollingTo, setScrollingTo] = useState<number | null>(null)\n\n    const tabElements = useRef<(HTMLElement | null)[]>([])\n\n    useEffect(() => {\n        if (!scrollContainer.current) return\n        const scrollElement = scrollContainer.current\n        let scrollTimeout: NodeJS.Timeout | undefined = undefined\n\n        const onScroll = () => {\n            // Find the first visible section\n            let index: number | null = sections.findIndex(({ elementRef }) => {\n                const elementRect = elementRef.current?.getBoundingClientRect()\n                const parentRect = elementRef.current?.parentElement?.getBoundingClientRect()\n                if (!elementRect || !parentRect) return false\n\n                return elementRect.top <= parentRect.top\n                    ? parentRect.top - elementRect.top < elementRect.height\n                    : elementRect.bottom - parentRect.bottom < elementRect.height\n            })\n            index = index !== -1 ? index : null\n\n            // Only update active tab if we're not automatically scrolling or we're finished scrolling\n            if (scrollingTo === null || scrollingTo === index) {\n                setActiveIndex(index)\n                setScrollingTo(null)\n            }\n\n            // Reset scrollingTo after 100 ms of not scrolling\n            clearTimeout(scrollTimeout)\n            scrollTimeout = setTimeout(function () {\n                setScrollingTo(null)\n            }, 100)\n        }\n\n        scrollElement.addEventListener('scroll', onScroll)\n\n        return () => scrollElement.removeEventListener('scroll', onScroll)\n    }, [scrollContainer, scrollingTo, sections])\n\n    const scrollTo = useCallback(\n        (index: number) => {\n            const elementRef = sections[index].elementRef\n            if (!elementRef.current) return\n\n            scrollIntoView(elementRef.current, {\n                behavior: 'smooth',\n                block: 'start',\n                inline: 'nearest',\n            })\n\n            // Blink heading if the container is not scrollable\n            if (\n                scrollContainer.current &&\n                scrollContainer.current.clientHeight === scrollContainer.current.scrollHeight\n            ) {\n                const el = elementRef.current\n\n                animate(1, [1, 0.6, 1, 0.6, 1], {\n                    duration: 0.5,\n                    onUpdate: (value) => {\n                        const heading = el.querySelector<HTMLElement>('h1, h2, h3, h4, h5, h6')\n                        if (heading) heading.style.opacity = value.toString()\n                    },\n                })\n            }\n\n            setScrollingTo(index)\n            setActiveIndex(index)\n        },\n        [sections, scrollContainer]\n    )\n\n    useEffect(() => {\n        if (initialIndex) {\n            scrollTo(initialIndex)\n        }\n    }, [scrollTo, initialIndex])\n\n    useEffect(() => {\n        if (activeIndex !== null) {\n            const tabElement = tabElements.current[activeIndex]\n            tabElement &&\n                scrollIntoView(tabElement, {\n                    behavior: 'smooth',\n                    block: 'nearest',\n                    inline: 'nearest',\n                })\n        }\n    }, [activeIndex, tabElements])\n\n    return (\n        <nav\n            className={classNames(\n                className,\n                'relative flex gap-2 w-full overflow-x-scroll show-on-hover-custom-gray-scroll text-base'\n            )}\n            {...rest}\n        >\n            {sections.map(({ name }, index) => (\n                <button\n                    ref={(el) => (tabElements.current[index] = el)}\n                    key={name + index}\n                    className={classNames(\n                        'py-1 px-2 rounded-md whitespace-nowrap',\n                        'hover:bg-gray-600 transition',\n                        index === activeIndex ? 'text-white bg-gray-500' : 'text-gray-100'\n                    )}\n                    onClick={() => scrollTo(index)}\n                >\n                    {name}\n                </button>\n            ))}\n        </nav>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/IndexTabs/__snapshots__/IndexTabs.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`IndexTabs should render correctly 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <nav\n        class=\"relative flex gap-2 w-full overflow-x-scroll scrollbar-none text-base\"\n      >\n        <button\n          class=\"py-1 px-2 rounded-md whitespace-nowrap hover:bg-gray-600 transition text-white bg-gray-500\"\n        >\n          Section 1\n        </button>\n        <button\n          class=\"py-1 px-2 rounded-md whitespace-nowrap hover:bg-gray-600 transition text-gray-100\"\n        >\n          Section 2\n        </button>\n      </nav>\n      <div\n        style=\"overflow-y: auto; height: 400px;\"\n      >\n        <div\n          style=\"height: 500px;\"\n        >\n          Content 1\n        </div>\n        <div\n          style=\"height: 500px;\"\n        >\n          Content 2\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <nav\n      class=\"relative flex gap-2 w-full overflow-x-scroll scrollbar-none text-base\"\n    >\n      <button\n        class=\"py-1 px-2 rounded-md whitespace-nowrap hover:bg-gray-600 transition text-white bg-gray-500\"\n      >\n        Section 1\n      </button>\n      <button\n        class=\"py-1 px-2 rounded-md whitespace-nowrap hover:bg-gray-600 transition text-gray-100\"\n      >\n        Section 2\n      </button>\n    </nav>\n    <div\n      style=\"overflow-y: auto; height: 400px;\"\n    >\n      <div\n        style=\"height: 500px;\"\n      >\n        Content 1\n      </div>\n      <div\n        style=\"height: 500px;\"\n      >\n        Content 2\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/IndexTabs/index.ts",
    "content": "export { default as IndexTabs } from './IndexTabs'\n"
  },
  {
    "path": "libs/design-system/src/lib/Listbox/Listbox.spec.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-empty-function */\nimport { fireEvent, render, screen } from '@testing-library/react'\nimport { Listbox } from './'\n\n// Not tested too thoroughly, most logic is covered by Headless UI base\ndescribe('Listbox', () => {\n    describe('when closed', () => {\n        it('should render the button', () => {\n            const component = render(\n                <Listbox value=\"Option 1\" onChange={() => {}}>\n                    <Listbox.Button>Select...</Listbox.Button>\n                    <Listbox.Options>\n                        <Listbox.Option value=\"Option 1\">Option 1</Listbox.Option>\n                    </Listbox.Options>\n                </Listbox>\n            )\n\n            expect(screen.getByText('Select...')).toBeVisible()\n            expect(screen.queryByText('Option 1')).not.toBeVisible()\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when opened', () => {\n        it('should render the button and the items', () => {\n            const component = render(\n                <Listbox value=\"Option 1\" onChange={() => {}}>\n                    <Listbox.Button>Select...</Listbox.Button>\n                    <Listbox.Options>\n                        <Listbox.Option value=\"Option 1\">Option 1</Listbox.Option>\n                        <Listbox.Option value=\"Option 2\" disabled={true}>\n                            Option 2\n                        </Listbox.Option>\n                    </Listbox.Options>\n                </Listbox>\n            )\n\n            fireEvent.click(screen.getByText('Select...'))\n            ;['Select...', 'Option 1', 'Option 2'].forEach((text) =>\n                expect(screen.getByText(text)).toBeVisible()\n            )\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when an option is pressed', () => {\n        it('should call the `onChange` callback', () => {\n            const onChangeMock = jest.fn()\n            render(\n                <Listbox value=\"Option 1\" onChange={onChangeMock}>\n                    <Listbox.Button>Select...</Listbox.Button>\n                    <Listbox.Options>\n                        <Listbox.Option value=\"Option 1\">Option 1</Listbox.Option>\n                    </Listbox.Options>\n                </Listbox>\n            )\n\n            fireEvent.click(screen.getByText('Select...'))\n            fireEvent.click(screen.getByText('Option 1'))\n            expect(onChangeMock).toHaveBeenCalledWith('Option 1')\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Listbox/Listbox.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport { useState } from 'react'\nimport Listbox from './Listbox'\nimport { RiLineChartLine } from 'react-icons/ri'\n\nexport default {\n    title: 'Components/Listbox',\n    component: Listbox,\n    parameters: { controls: { exclude: ['className', 'as', 'refName'] } },\n    argTypes: {\n        icon: {\n            control: 'boolean',\n        },\n        iconPosition: {\n            control: 'select',\n            options: ['left', 'right'],\n            description: 'Side of the icon',\n            label: 'Icon Position',\n        },\n        checkIconPosition: {\n            control: 'select',\n            options: ['left', 'right'],\n            description: 'Side of the selected check',\n        },\n    },\n    args: {\n        icon: true,\n        iconPosition: 'left',\n        checkIconPosition: 'right',\n        label: 'Listbox',\n    },\n} as Meta\n\nconst Template: Story = ({ label, icon, iconPosition, checkIconPosition }) => {\n    const [value, setValue] = useState('Option 1')\n\n    return (\n        <div className=\"w-32 mb-32\">\n            <Listbox value={value} onChange={setValue}>\n                <Listbox.Button label={label}>{value}</Listbox.Button>\n                <Listbox.Options>\n                    <Listbox.Option\n                        value=\"Option 1\"\n                        icon={icon && RiLineChartLine}\n                        iconPosition={iconPosition}\n                        checkIconPosition={checkIconPosition}\n                    >\n                        Option 1\n                    </Listbox.Option>\n                    <Listbox.Option\n                        value=\"Option 2\"\n                        icon={icon && RiLineChartLine}\n                        iconPosition={iconPosition}\n                        checkIconPosition={checkIconPosition}\n                    >\n                        Option 2\n                    </Listbox.Option>\n                    <Listbox.Option\n                        value=\"Option 3\"\n                        icon={icon && RiLineChartLine}\n                        iconPosition={iconPosition}\n                        checkIconPosition={checkIconPosition}\n                    >\n                        Option 3\n                    </Listbox.Option>\n                    <Listbox.Option value=\"Option 6\" disabled={true}>\n                        Disabled\n                    </Listbox.Option>\n                </Listbox.Options>\n            </Listbox>\n        </div>\n    )\n}\n\nexport const Base = Template.bind({})\n"
  },
  {
    "path": "libs/design-system/src/lib/Listbox/Listbox.tsx",
    "content": "import type { Dispatch, MouseEventHandler, PropsWithChildren, SetStateAction } from 'react'\nimport type { IconType } from 'react-icons'\nimport type { PopperProps } from 'react-popper'\nimport React, { createContext, useContext, useState, useEffect, useRef } from 'react'\nimport { Listbox as HeadlessListbox } from '@headlessui/react'\nimport classNames from 'classnames'\nimport { RiArrowDownSFill, RiCheckFill } from 'react-icons/ri'\nimport { usePopper } from 'react-popper'\nimport { Checkbox } from '../Checkbox'\n\ntype ExtractProps<T> = T extends React.ComponentType<infer P> ? P : T\n\nconst ListboxButtonVariants = Object.freeze({\n    default:\n        'text-white bg-gray-500 border-gray-500 focus:border-cyan focus:ring-opacity-10 focus:ring-cyan',\n    teal: 'text-teal bg-teal bg-opacity-10 border-transparent focus:border-teal focus:ring-opacity-10 focus:ring-teal',\n    red: 'text-red bg-red bg-opacity-10 border-transparent focus:border-red focus:ring-opacity-10 focus:ring-red',\n    yellow: 'text-yellow bg-yellow bg-opacity-10 border-transparent focus:border-yellow focus:ring-opacity-10 focus:ring-yellow',\n})\n\nexport type ListboxButtonVariant = keyof typeof ListboxButtonVariants\n\nexport interface ListboxButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n    variant?: ListboxButtonVariant\n\n    /** Label that appears above the button */\n    label?: string\n\n    /** Icon that appears on the left side of the button */\n    icon?: IconType\n\n    /** Shows or hides down arrow dropdown icon */\n    hideRightIcon?: boolean\n\n    /** Placeholder that appears when there is no child value */\n    placeholder?: string\n\n    className?: string\n\n    labelClassName?: string\n\n    buttonClassName?: string\n\n    size?: 'small' | 'default'\n\n    onClick?: MouseEventHandler\n}\n\nconst ListboxContext = createContext<{\n    referenceElement: HTMLButtonElement | null\n    setReferenceElement?: Dispatch<SetStateAction<HTMLButtonElement | null>>\n    multiple: boolean\n}>({ referenceElement: null, multiple: false })\n\nfunction Listbox({\n    className,\n    onClick,\n    multiple = false,\n    ...rest\n}: { onClick?: MouseEventHandler } & ExtractProps<typeof HeadlessListbox>) {\n    const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null)\n\n    return (\n        <ListboxContext.Provider value={{ referenceElement, setReferenceElement, multiple }}>\n            <HeadlessListbox\n                as=\"div\"\n                className={classNames(className, 'relative')}\n                onClick={onClick}\n                multiple={multiple}\n                {...rest}\n            />\n        </ListboxContext.Provider>\n    )\n}\n\nfunction Button({\n    className,\n    variant = 'default',\n    label,\n    icon: Icon,\n    hideRightIcon = false,\n    placeholder,\n    labelClassName,\n    buttonClassName,\n    size = 'default',\n    children,\n    onClick,\n    ...rest\n}: ListboxButtonProps & ExtractProps<typeof HeadlessListbox.Button>) {\n    const { setReferenceElement } = useContext(ListboxContext)\n\n    return (\n        <HeadlessListbox.Button\n            as=\"label\"\n            className={classNames(className, 'relative flex w-full flex-col')}\n            onClick={onClick}\n        >\n            {({ disabled }) => (\n                <>\n                    {label && (\n                        <span\n                            className={classNames(\n                                labelClassName,\n                                'block mb-1 text-base text-gray-50 font-light leading-6'\n                            )}\n                        >\n                            {label}\n                        </span>\n                    )}\n\n                    <button\n                        type=\"button\"\n                        className={classNames(\n                            buttonClassName,\n                            ListboxButtonVariants[variant],\n                            size === 'default' ? 'py-2 px-4 h-10' : 'py-1 px-2 h-8',\n                            'flex items-center grow rounded border overflow-hidden',\n                            'text-base text-left font-light leading-none',\n                            'focus:ring',\n                            'placeholder-gray-200 disabled:placeholder-gray-200',\n                            disabled && 'text-gray-100'\n                        )}\n                        disabled={disabled}\n                        ref={setReferenceElement}\n                        {...rest}\n                    >\n                        {Icon && (\n                            <Icon\n                                className={classNames(\n                                    'mr-2 text-2xl',\n                                    variant === 'default' && 'text-gray-50'\n                                )}\n                            />\n                        )}\n\n                        <span className=\"grow\">\n                            {children ?? <span className=\"text-gray-200\">{placeholder}</span>}\n                        </span>\n\n                        {!hideRightIcon && (\n                            <RiArrowDownSFill\n                                className={classNames(\n                                    'ml-2 text-lg',\n                                    variant === 'default' && 'text-gray-50'\n                                )}\n                            />\n                        )}\n                    </button>\n                </>\n            )}\n        </HeadlessListbox.Button>\n    )\n}\n\ntype ListboxOptionsProps = ExtractProps<typeof HeadlessListbox.Options>\n\nfunction Options({\n    className,\n    placement = 'bottom-start',\n    children,\n    ...rest\n}: (Omit<ListboxOptionsProps, 'static'> | Omit<ListboxOptionsProps, 'unmount'>) & {\n    placement?: PopperProps<any>['placement']\n}) {\n    const { referenceElement, multiple } = useContext(ListboxContext)\n    const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)\n    const [isOpen, setIsOpen] = useState(false)\n\n    const isOpenRef = useRef(false)\n\n    const { styles, attributes, update } = usePopper(referenceElement, popperElement, {\n        placement,\n        modifiers: [\n            {\n                name: 'offset',\n                options: {\n                    offset: [0, 8],\n                },\n            },\n        ],\n    })\n\n    useEffect(() => {\n        if (isOpen && update) update()\n        if (isOpenRef.current !== isOpen) setIsOpen(isOpenRef.current)\n    }, [isOpen, update])\n\n    return (\n        <div\n            ref={setPopperElement}\n            className={classNames(\n                'z-20 absolute min-w-full shadow-md rounded bg-gray-700',\n                className\n            )}\n            style={styles.popper}\n            {...attributes.popper}\n        >\n            <HeadlessListbox.Options\n                className={classNames(multiple ? 'py-3 px-2' : 'py-2', className)}\n                unmount={false}\n                {...rest}\n            >\n                {({ open }) => {\n                    isOpenRef.current = open\n                    return children\n                }}\n            </HeadlessListbox.Options>\n        </div>\n    )\n}\n\ntype Position = 'left' | 'right'\n\ntype ListboxOptionProps = {\n    icon?: IconType\n    iconPosition?: Position\n    checkIconPosition?: Position\n}\n\nfunction Option({\n    className,\n    children,\n    icon: Icon,\n    iconPosition = 'left',\n    checkIconPosition = 'right',\n    ...rest\n}: PropsWithChildren<ListboxOptionProps & ExtractProps<typeof HeadlessListbox.Option>>) {\n    const { multiple } = useContext(ListboxContext)\n\n    const renderIcon = (selected: boolean, position: Position) => {\n        const iconClassName = selected ? 'text-white' : 'text-gray-100 group-hover:text-gray-50'\n\n        if (selected && checkIconPosition === position) {\n            return (\n                <span className={iconClassName}>\n                    <RiCheckFill className=\"w-5 h-5\" />\n                </span>\n            )\n        }\n\n        if (Icon && iconPosition === position) {\n            return (\n                <span className={iconClassName}>\n                    <Icon className=\"w-5 h-5\" />\n                </span>\n            )\n        }\n\n        return null\n    }\n\n    return (\n        <HeadlessListbox.Option {...rest}>\n            {({ selected, disabled }) =>\n                multiple ? (\n                    <div className={classNames('flex items-center', className)}>\n                        <Checkbox\n                            className=\"pointer-events-none\"\n                            checked={selected}\n                            label={children}\n                        />\n                    </div>\n                ) : (\n                    <button\n                        type=\"button\"\n                        className={classNames(\n                            className,\n                            'group flex justify-between items-center px-2.5 gap-x-2 w-full text-base leading-8 whitespace-nowrap',\n                            selected ? 'bg-gray-500 text-white' : 'text-gray-25 hover:bg-gray-600',\n                            disabled && 'opacity-50'\n                        )}\n                    >\n                        <div className=\"flex gap-x-2 items-center\">\n                            {renderIcon(selected, 'left')}\n\n                            <span>{children}</span>\n                        </div>\n\n                        {renderIcon(selected, 'right')}\n                    </button>\n                )\n            }\n        </HeadlessListbox.Option>\n    )\n}\n\nexport default Object.assign(Listbox, { Button, Options, Option })\n"
  },
  {
    "path": "libs/design-system/src/lib/Listbox/__snapshots__/Listbox.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Listbox when closed should render the button 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"relative\"\n        data-headlessui-state=\"\"\n      >\n        <label\n          aria-controls=\"headlessui-listbox-options-:r1:\"\n          aria-expanded=\"false\"\n          aria-haspopup=\"true\"\n          class=\"relative flex w-full flex-col\"\n          data-headlessui-state=\"\"\n          id=\"headlessui-listbox-button-:r0:\"\n        >\n          <button\n            class=\"text-white bg-gray-500 border-gray-500 focus:border-cyan focus:ring-opacity-10 focus:ring-cyan py-2 px-4 h-10 flex items-center grow rounded border overflow-hidden text-base text-left font-light leading-none focus:ring placeholder-gray-200 disabled:placeholder-gray-200\"\n            type=\"button\"\n          >\n            <span\n              class=\"grow\"\n            >\n              Select...\n            </span>\n            <svg\n              class=\"ml-2 text-lg text-gray-50\"\n              fill=\"currentColor\"\n              height=\"1em\"\n              stroke=\"currentColor\"\n              stroke-width=\"0\"\n              viewBox=\"0 0 24 24\"\n              width=\"1em\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n            >\n              <g>\n                <path\n                  d=\"M0 0h24v24H0z\"\n                  fill=\"none\"\n                />\n                <path\n                  d=\"M12 16l-6-6h12z\"\n                />\n              </g>\n            </svg>\n          </button>\n        </label>\n        <div\n          class=\"z-20 absolute min-w-full shadow-md rounded bg-gray-700\"\n          style=\"position: absolute; left: 0px; top: 0px;\"\n        >\n          <ul\n            aria-activedescendant=\"headlessui-listbox-option-:r2:\"\n            aria-labelledby=\"headlessui-listbox-button-:r0:\"\n            aria-orientation=\"vertical\"\n            class=\"py-2\"\n            data-headlessui-state=\"\"\n            hidden=\"\"\n            id=\"headlessui-listbox-options-:r1:\"\n            role=\"listbox\"\n            style=\"display: none;\"\n            tabindex=\"0\"\n          >\n            <li\n              aria-selected=\"true\"\n              data-headlessui-state=\"active selected\"\n              id=\"headlessui-listbox-option-:r2:\"\n              role=\"option\"\n              tabindex=\"-1\"\n            >\n              <button\n                class=\"group flex justify-between items-center px-2.5 gap-x-2 w-full text-base leading-8 whitespace-nowrap bg-gray-500 text-white\"\n                type=\"button\"\n              >\n                <div\n                  class=\"flex gap-x-2 items-center\"\n                >\n                  <span>\n                    Option 1\n                  </span>\n                </div>\n                <span\n                  class=\"text-white\"\n                >\n                  <svg\n                    class=\"w-5 h-5\"\n                    fill=\"currentColor\"\n                    height=\"1em\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"0\"\n                    viewBox=\"0 0 24 24\"\n                    width=\"1em\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                  >\n                    <g>\n                      <path\n                        d=\"M0 0h24v24H0z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414z\"\n                      />\n                    </g>\n                  </svg>\n                </span>\n              </button>\n            </li>\n          </ul>\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative\"\n      data-headlessui-state=\"\"\n    >\n      <label\n        aria-controls=\"headlessui-listbox-options-:r1:\"\n        aria-expanded=\"false\"\n        aria-haspopup=\"true\"\n        class=\"relative flex w-full flex-col\"\n        data-headlessui-state=\"\"\n        id=\"headlessui-listbox-button-:r0:\"\n      >\n        <button\n          class=\"text-white bg-gray-500 border-gray-500 focus:border-cyan focus:ring-opacity-10 focus:ring-cyan py-2 px-4 h-10 flex items-center grow rounded border overflow-hidden text-base text-left font-light leading-none focus:ring placeholder-gray-200 disabled:placeholder-gray-200\"\n          type=\"button\"\n        >\n          <span\n            class=\"grow\"\n          >\n            Select...\n          </span>\n          <svg\n            class=\"ml-2 text-lg text-gray-50\"\n            fill=\"currentColor\"\n            height=\"1em\"\n            stroke=\"currentColor\"\n            stroke-width=\"0\"\n            viewBox=\"0 0 24 24\"\n            width=\"1em\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <g>\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M12 16l-6-6h12z\"\n              />\n            </g>\n          </svg>\n        </button>\n      </label>\n      <div\n        class=\"z-20 absolute min-w-full shadow-md rounded bg-gray-700\"\n        style=\"position: absolute; left: 0px; top: 0px;\"\n      >\n        <ul\n          aria-activedescendant=\"headlessui-listbox-option-:r2:\"\n          aria-labelledby=\"headlessui-listbox-button-:r0:\"\n          aria-orientation=\"vertical\"\n          class=\"py-2\"\n          data-headlessui-state=\"\"\n          hidden=\"\"\n          id=\"headlessui-listbox-options-:r1:\"\n          role=\"listbox\"\n          style=\"display: none;\"\n          tabindex=\"0\"\n        >\n          <li\n            aria-selected=\"true\"\n            data-headlessui-state=\"active selected\"\n            id=\"headlessui-listbox-option-:r2:\"\n            role=\"option\"\n            tabindex=\"-1\"\n          >\n            <button\n              class=\"group flex justify-between items-center px-2.5 gap-x-2 w-full text-base leading-8 whitespace-nowrap bg-gray-500 text-white\"\n              type=\"button\"\n            >\n              <div\n                class=\"flex gap-x-2 items-center\"\n              >\n                <span>\n                  Option 1\n                </span>\n              </div>\n              <span\n                class=\"text-white\"\n              >\n                <svg\n                  class=\"w-5 h-5\"\n                  fill=\"currentColor\"\n                  height=\"1em\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"0\"\n                  viewBox=\"0 0 24 24\"\n                  width=\"1em\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                >\n                  <g>\n                    <path\n                      d=\"M0 0h24v24H0z\"\n                      fill=\"none\"\n                    />\n                    <path\n                      d=\"M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414z\"\n                    />\n                  </g>\n                </svg>\n              </span>\n            </button>\n          </li>\n        </ul>\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Listbox when opened should render the button and the items 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"relative\"\n        data-headlessui-state=\"open\"\n      >\n        <label\n          aria-controls=\"headlessui-listbox-options-:r4:\"\n          aria-expanded=\"true\"\n          aria-haspopup=\"true\"\n          class=\"relative flex w-full flex-col\"\n          data-headlessui-state=\"open\"\n          id=\"headlessui-listbox-button-:r3:\"\n        >\n          <button\n            class=\"text-white bg-gray-500 border-gray-500 focus:border-cyan focus:ring-opacity-10 focus:ring-cyan py-2 px-4 h-10 flex items-center grow rounded border overflow-hidden text-base text-left font-light leading-none focus:ring placeholder-gray-200 disabled:placeholder-gray-200\"\n            type=\"button\"\n          >\n            <span\n              class=\"grow\"\n            >\n              Select...\n            </span>\n            <svg\n              class=\"ml-2 text-lg text-gray-50\"\n              fill=\"currentColor\"\n              height=\"1em\"\n              stroke=\"currentColor\"\n              stroke-width=\"0\"\n              viewBox=\"0 0 24 24\"\n              width=\"1em\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n            >\n              <g>\n                <path\n                  d=\"M0 0h24v24H0z\"\n                  fill=\"none\"\n                />\n                <path\n                  d=\"M12 16l-6-6h12z\"\n                />\n              </g>\n            </svg>\n          </button>\n        </label>\n        <div\n          class=\"z-20 absolute min-w-full shadow-md rounded bg-gray-700\"\n          style=\"position: absolute; left: 0px; top: 0px;\"\n        >\n          <ul\n            aria-activedescendant=\"headlessui-listbox-option-:r5:\"\n            aria-labelledby=\"headlessui-listbox-button-:r3:\"\n            aria-orientation=\"vertical\"\n            class=\"py-2\"\n            data-headlessui-state=\"open\"\n            id=\"headlessui-listbox-options-:r4:\"\n            role=\"listbox\"\n            style=\"\"\n            tabindex=\"0\"\n          >\n            <li\n              aria-selected=\"true\"\n              data-headlessui-state=\"active selected\"\n              id=\"headlessui-listbox-option-:r5:\"\n              role=\"option\"\n              tabindex=\"-1\"\n            >\n              <button\n                class=\"group flex justify-between items-center px-2.5 gap-x-2 w-full text-base leading-8 whitespace-nowrap bg-gray-500 text-white\"\n                type=\"button\"\n              >\n                <div\n                  class=\"flex gap-x-2 items-center\"\n                >\n                  <span>\n                    Option 1\n                  </span>\n                </div>\n                <span\n                  class=\"text-white\"\n                >\n                  <svg\n                    class=\"w-5 h-5\"\n                    fill=\"currentColor\"\n                    height=\"1em\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"0\"\n                    viewBox=\"0 0 24 24\"\n                    width=\"1em\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                  >\n                    <g>\n                      <path\n                        d=\"M0 0h24v24H0z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414z\"\n                      />\n                    </g>\n                  </svg>\n                </span>\n              </button>\n            </li>\n            <li\n              aria-disabled=\"true\"\n              aria-selected=\"false\"\n              data-headlessui-state=\"disabled\"\n              id=\"headlessui-listbox-option-:r6:\"\n              role=\"option\"\n            >\n              <button\n                class=\"group flex justify-between items-center px-2.5 gap-x-2 w-full text-base leading-8 whitespace-nowrap text-gray-25 hover:bg-gray-600 opacity-50\"\n                type=\"button\"\n              >\n                <div\n                  class=\"flex gap-x-2 items-center\"\n                >\n                  <span>\n                    Option 2\n                  </span>\n                </div>\n              </button>\n            </li>\n          </ul>\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative\"\n      data-headlessui-state=\"open\"\n    >\n      <label\n        aria-controls=\"headlessui-listbox-options-:r4:\"\n        aria-expanded=\"true\"\n        aria-haspopup=\"true\"\n        class=\"relative flex w-full flex-col\"\n        data-headlessui-state=\"open\"\n        id=\"headlessui-listbox-button-:r3:\"\n      >\n        <button\n          class=\"text-white bg-gray-500 border-gray-500 focus:border-cyan focus:ring-opacity-10 focus:ring-cyan py-2 px-4 h-10 flex items-center grow rounded border overflow-hidden text-base text-left font-light leading-none focus:ring placeholder-gray-200 disabled:placeholder-gray-200\"\n          type=\"button\"\n        >\n          <span\n            class=\"grow\"\n          >\n            Select...\n          </span>\n          <svg\n            class=\"ml-2 text-lg text-gray-50\"\n            fill=\"currentColor\"\n            height=\"1em\"\n            stroke=\"currentColor\"\n            stroke-width=\"0\"\n            viewBox=\"0 0 24 24\"\n            width=\"1em\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <g>\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M12 16l-6-6h12z\"\n              />\n            </g>\n          </svg>\n        </button>\n      </label>\n      <div\n        class=\"z-20 absolute min-w-full shadow-md rounded bg-gray-700\"\n        style=\"position: absolute; left: 0px; top: 0px;\"\n      >\n        <ul\n          aria-activedescendant=\"headlessui-listbox-option-:r5:\"\n          aria-labelledby=\"headlessui-listbox-button-:r3:\"\n          aria-orientation=\"vertical\"\n          class=\"py-2\"\n          data-headlessui-state=\"open\"\n          id=\"headlessui-listbox-options-:r4:\"\n          role=\"listbox\"\n          style=\"\"\n          tabindex=\"0\"\n        >\n          <li\n            aria-selected=\"true\"\n            data-headlessui-state=\"active selected\"\n            id=\"headlessui-listbox-option-:r5:\"\n            role=\"option\"\n            tabindex=\"-1\"\n          >\n            <button\n              class=\"group flex justify-between items-center px-2.5 gap-x-2 w-full text-base leading-8 whitespace-nowrap bg-gray-500 text-white\"\n              type=\"button\"\n            >\n              <div\n                class=\"flex gap-x-2 items-center\"\n              >\n                <span>\n                  Option 1\n                </span>\n              </div>\n              <span\n                class=\"text-white\"\n              >\n                <svg\n                  class=\"w-5 h-5\"\n                  fill=\"currentColor\"\n                  height=\"1em\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"0\"\n                  viewBox=\"0 0 24 24\"\n                  width=\"1em\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                >\n                  <g>\n                    <path\n                      d=\"M0 0h24v24H0z\"\n                      fill=\"none\"\n                    />\n                    <path\n                      d=\"M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414z\"\n                    />\n                  </g>\n                </svg>\n              </span>\n            </button>\n          </li>\n          <li\n            aria-disabled=\"true\"\n            aria-selected=\"false\"\n            data-headlessui-state=\"disabled\"\n            id=\"headlessui-listbox-option-:r6:\"\n            role=\"option\"\n          >\n            <button\n              class=\"group flex justify-between items-center px-2.5 gap-x-2 w-full text-base leading-8 whitespace-nowrap text-gray-25 hover:bg-gray-600 opacity-50\"\n              type=\"button\"\n            >\n              <div\n                class=\"flex gap-x-2 items-center\"\n              >\n                <span>\n                  Option 2\n                </span>\n              </div>\n            </button>\n          </li>\n        </ul>\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Listbox/index.ts",
    "content": "export { default as Listbox } from './Listbox'\n"
  },
  {
    "path": "libs/design-system/src/lib/LoadingPlaceholder/LoadingPlaceholder.spec.tsx",
    "content": "import { render } from '@testing-library/react'\nimport { LoadingPlaceholder } from './'\n\ndescribe('LoadingPlaceholder', () => {\n    it('should render properly', () => {\n        const component = render(<LoadingPlaceholder />)\n\n        expect(component).toMatchSnapshot()\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/LoadingPlaceholder/LoadingPlaceholder.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\n\nimport LoadingPlaceholder from './LoadingPlaceholder'\n\nexport default {\n    title: 'Components/LoadingPlaceholder',\n    component: LoadingPlaceholder,\n    parameters: { controls: { exclude: ['className', 'overlayClassName'] } },\n} as Meta\n\nexport const Base: Story = (args) => <LoadingPlaceholder {...args}>Content</LoadingPlaceholder>\n"
  },
  {
    "path": "libs/design-system/src/lib/LoadingPlaceholder/LoadingPlaceholder.tsx",
    "content": "import type { ReactNode } from 'react'\nimport classNames from 'classnames'\n\nexport default function LoadingPlaceholder({\n    isLoading = true,\n    children,\n    className,\n    overlayClassName,\n    maxContent = false,\n    placeholderContent,\n}: {\n    isLoading?: boolean\n    children?: ReactNode\n    className?: string\n    overlayClassName?: string\n    maxContent?: boolean\n    placeholderContent?: ReactNode\n}): JSX.Element {\n    return (\n        <div\n            className={classNames(\n                'relative overflow-hidden rounded',\n                maxContent && 'max-w-max',\n                className\n            )}\n        >\n            {isLoading && placeholderContent ? placeholderContent : children}\n            {isLoading && (\n                <>\n                    <div className={classNames('absolute inset-0 bg-gray-700', overlayClassName)} />\n                    <div className=\"absolute inset-0 bg-shine animate-shine\"></div>\n                </>\n            )}\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/LoadingPlaceholder/__snapshots__/LoadingPlaceholder.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`LoadingPlaceholder should render properly 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"relative overflow-hidden rounded\"\n      >\n        <div\n          class=\"absolute inset-0 bg-gray-700\"\n        />\n        <div\n          class=\"absolute inset-0 bg-shine animate-shine\"\n        />\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative overflow-hidden rounded\"\n    >\n      <div\n        class=\"absolute inset-0 bg-gray-700\"\n      />\n      <div\n        class=\"absolute inset-0 bg-shine animate-shine\"\n      />\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/LoadingPlaceholder/index.ts",
    "content": "export { default as LoadingPlaceholder } from './LoadingPlaceholder'\n"
  },
  {
    "path": "libs/design-system/src/lib/LoadingSpinner/LoadingSpinner.spec.tsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport { LoadingSpinner } from './'\n\ndescribe('LoadingSpinner', () => {\n    it('should render properly', () => {\n        const component = render(<LoadingSpinner />)\n\n        expect(screen.getByRole('img')).toBeInTheDocument()\n        expect(component).toMatchSnapshot()\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/LoadingSpinner/LoadingSpinner.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\n\nimport LoadingSpinner from './LoadingSpinner'\n\nexport default {\n    title: 'Components/LoadingSpinner',\n    component: LoadingSpinner,\n} as Meta\n\nexport const Base: Story = () => <LoadingSpinner />\n"
  },
  {
    "path": "libs/design-system/src/lib/LoadingSpinner/LoadingSpinner.tsx",
    "content": "/* eslint-disable react-hooks/exhaustive-deps */\nimport React, { useRef, useState } from 'react'\nimport classNames from 'classnames'\n\nexport interface LoadingSpinnerProps extends React.SVGAttributes<SVGElement> {\n    variant?: 'primary' | 'secondary'\n}\n\nexport default function LoadingSpinner({\n    variant = 'primary',\n    ...rest\n}: LoadingSpinnerProps): JSX.Element {\n    const cyan = variant === 'primary' ? '#4CC9F0' : '#48494B'\n    const blue = variant === 'primary' ? '#4361EE' : '#48494B'\n    const purple = variant === 'primary' ? '#7209B7' : '#48494B'\n    const pink = variant === 'primary' ? '#F72585' : '#48494B'\n\n    const [frame, setFrame] = useState(0)\n\n    const frameRequest = useRef<number>()\n    const currentFrameStartTime = useRef<number>()\n\n    const animate = (time: number) => {\n        if (currentFrameStartTime.current != null) {\n            if (time - currentFrameStartTime.current > 250) {\n                setFrame((prevFrame) => (prevFrame + 1) % 4)\n                currentFrameStartTime.current = time\n            }\n        } else {\n            currentFrameStartTime.current = time\n        }\n        frameRequest.current = requestAnimationFrame(animate)\n    }\n\n    React.useEffect(() => {\n        frameRequest.current = requestAnimationFrame(animate)\n        return () => {\n            if (frameRequest.current != null) cancelAnimationFrame(frameRequest.current)\n        }\n    }, [])\n\n    return (\n        <svg width={50} height={50} role=\"img\" aria-label=\"loading\" viewBox=\"0 0 39 25\" {...rest}>\n            {/* Gray background cells */}\n            <g fill=\"#34363A\">\n                <path d=\"m13.948 0.43726h-5.0748c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\" />\n                <path d=\"m29.71 0.43726h-5.0748c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\" />\n                <path d=\"m16.332 6.9539h-9.6145c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h9.6145c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641z\" />\n                <path d=\"m22.872 11.481h9.0005c1.246 0 2.256-1.0137 2.256-2.2642 0-1.2504-1.01-2.2641-2.256-2.2641h-9.0005c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2504 1.0101 2.2642 2.2561 2.2642z\" />\n                <path d=\"m7.5807 20.183h-5.2582c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2505 1.0101 2.2641 2.256 2.2641h5.2582c1.246 0 2.256-1.0136 2.256-2.2641 0-1.2504-1.0101-2.2641-2.256-2.2641z\" />\n                <path d=\"m31.099 24.71h5.2583c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641h-5.2583c-1.2459 0-2.256 1.0137-2.256 2.2641s1.0101 2.2641 2.256 2.2641z\" />\n                <path d=\"m21.466 20.167h-4.2804c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h4.2804c1.246 0 2.256-1.0137 2.256-2.2641s-1.01-2.2641-2.256-2.2641z\" />\n                <path d=\"m9.5383 13.666h-5.0635c-1.246 0-2.256 1.0136-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0635c1.2459 0 2.256-1.0137 2.256-2.2641 0-1.2505-1.0101-2.2641-2.256-2.2641z\" />\n                <path d=\"m29.054 18.194h5.0635c1.246 0 2.256-1.0136 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641h-5.0635c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2505 1.0101 2.2641 2.256 2.2641z\" />\n                <path d=\"m23.094 13.652h-7.5347c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2505 1.0101 2.2641 2.2561 2.2641h7.5347c1.246 0 2.2561-1.0136 2.2561-2.2641 0-1.2504-1.0101-2.2641-2.2561-2.2641z\" />\n            </g>\n\n            {/* Frame 1: left side colored */}\n            <g\n                className={classNames(\n                    'transition-opacity duration-[50ms]',\n                    frame !== 1 && 'opacity-0'\n                )}\n            >\n                <path\n                    fill={cyan}\n                    d=\"m13.948 0.43726h-5.0748c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n                />\n                <path\n                    fill={blue}\n                    d=\"m16.332 6.9539h-9.6145c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h9.6145c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641z\"\n                />\n                <path\n                    fill={pink}\n                    d=\"m7.5807 20.183h-5.2582c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2505 1.0101 2.2641 2.256 2.2641h5.2582c1.246 0 2.256-1.0136 2.256-2.2641 0-1.2504-1.0101-2.2641-2.256-2.2641z\"\n                />\n                <path\n                    fill={purple}\n                    d=\"m9.5383 13.666h-5.0635c-1.246 0-2.256 1.0136-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0635c1.2459 0 2.256-1.0137 2.256-2.2641 0-1.2505-1.0101-2.2641-2.256-2.2641z\"\n                />\n            </g>\n\n            {/* Frame 2: middle colored */}\n            <g\n                className={classNames(\n                    'transition-opacity duration-[50ms]',\n                    frame !== 2 && 'opacity-0'\n                )}\n            >\n                <path\n                    fill={cyan}\n                    d=\"m13.948 0.43726h-5.0748c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n                />\n                <path\n                    fill={blue}\n                    d=\"m16.332 6.9539h-9.6145c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h9.6145c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641z\"\n                />\n                <path\n                    fill={pink}\n                    d=\"m21.466 20.167h-4.2804c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h4.2804c1.246 0 2.256-1.0137 2.256-2.2641s-1.01-2.2641-2.256-2.2641z\"\n                />\n                <path\n                    fill={purple}\n                    d=\"m23.094 13.652h-7.5347c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2505 1.0101 2.2641 2.2561 2.2641h7.5347c1.246 0 2.2561-1.0136 2.2561-2.2641 0-1.2504-1.0101-2.2641-2.2561-2.2641z\"\n                />\n                <path\n                    fill={cyan}\n                    d=\"m29.71 0.43726h-5.0748c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n                />\n                <path\n                    fill={blue}\n                    d=\"m22.872 11.481h9.0005c1.246 0 2.256-1.0137 2.256-2.2642 0-1.2504-1.01-2.2641-2.256-2.2641h-9.0005c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2504 1.0101 2.2642 2.2561 2.2642z\"\n                />\n            </g>\n\n            {/* Frame 3: right side colored */}\n            <g\n                className={classNames(\n                    'transition-opacity duration-[50ms]',\n                    frame !== 3 && 'opacity-0'\n                )}\n            >\n                <path\n                    fill={cyan}\n                    d=\"m29.71 0.43726h-5.0748c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n                />\n                <path\n                    fill={blue}\n                    d=\"m22.872 11.481h9.0005c1.246 0 2.256-1.0137 2.256-2.2642 0-1.2504-1.01-2.2641-2.256-2.2641h-9.0005c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2504 1.0101 2.2642 2.2561 2.2642z\"\n                />\n                <path\n                    fill={purple}\n                    d=\"m29.054 18.194h5.0635c1.246 0 2.256-1.0136 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641h-5.0635c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2505 1.0101 2.2641 2.256 2.2641z\"\n                />\n                <path\n                    fill={pink}\n                    d=\"m31.099 24.71h5.2583c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641h-5.2583c-1.2459 0-2.256 1.0137-2.256 2.2641s1.0101 2.2641 2.256 2.2641z\"\n                />\n            </g>\n        </svg>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/LoadingSpinner/__snapshots__/LoadingSpinner.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`LoadingSpinner should render properly 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <svg\n        aria-label=\"loading\"\n        height=\"50\"\n        role=\"img\"\n        viewBox=\"0 0 39 25\"\n        width=\"50\"\n      >\n        <g\n          fill=\"#34363A\"\n        >\n          <path\n            d=\"m13.948 0.43726h-5.0748c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n          />\n          <path\n            d=\"m29.71 0.43726h-5.0748c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n          />\n          <path\n            d=\"m16.332 6.9539h-9.6145c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h9.6145c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641z\"\n          />\n          <path\n            d=\"m22.872 11.481h9.0005c1.246 0 2.256-1.0137 2.256-2.2642 0-1.2504-1.01-2.2641-2.256-2.2641h-9.0005c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2504 1.0101 2.2642 2.2561 2.2642z\"\n          />\n          <path\n            d=\"m7.5807 20.183h-5.2582c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2505 1.0101 2.2641 2.256 2.2641h5.2582c1.246 0 2.256-1.0136 2.256-2.2641 0-1.2504-1.0101-2.2641-2.256-2.2641z\"\n          />\n          <path\n            d=\"m31.099 24.71h5.2583c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641h-5.2583c-1.2459 0-2.256 1.0137-2.256 2.2641s1.0101 2.2641 2.256 2.2641z\"\n          />\n          <path\n            d=\"m21.466 20.167h-4.2804c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h4.2804c1.246 0 2.256-1.0137 2.256-2.2641s-1.01-2.2641-2.256-2.2641z\"\n          />\n          <path\n            d=\"m9.5383 13.666h-5.0635c-1.246 0-2.256 1.0136-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0635c1.2459 0 2.256-1.0137 2.256-2.2641 0-1.2505-1.0101-2.2641-2.256-2.2641z\"\n          />\n          <path\n            d=\"m29.054 18.194h5.0635c1.246 0 2.256-1.0136 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641h-5.0635c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2505 1.0101 2.2641 2.256 2.2641z\"\n          />\n          <path\n            d=\"m23.094 13.652h-7.5347c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2505 1.0101 2.2641 2.2561 2.2641h7.5347c1.246 0 2.2561-1.0136 2.2561-2.2641 0-1.2504-1.0101-2.2641-2.2561-2.2641z\"\n          />\n        </g>\n        <g\n          class=\"transition-opacity duration-[50ms] opacity-0\"\n        >\n          <path\n            d=\"m13.948 0.43726h-5.0748c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n            fill=\"#4CC9F0\"\n          />\n          <path\n            d=\"m16.332 6.9539h-9.6145c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h9.6145c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641z\"\n            fill=\"#4361EE\"\n          />\n          <path\n            d=\"m7.5807 20.183h-5.2582c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2505 1.0101 2.2641 2.256 2.2641h5.2582c1.246 0 2.256-1.0136 2.256-2.2641 0-1.2504-1.0101-2.2641-2.256-2.2641z\"\n            fill=\"#F72585\"\n          />\n          <path\n            d=\"m9.5383 13.666h-5.0635c-1.246 0-2.256 1.0136-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0635c1.2459 0 2.256-1.0137 2.256-2.2641 0-1.2505-1.0101-2.2641-2.256-2.2641z\"\n            fill=\"#7209B7\"\n          />\n        </g>\n        <g\n          class=\"transition-opacity duration-[50ms] opacity-0\"\n        >\n          <path\n            d=\"m13.948 0.43726h-5.0748c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n            fill=\"#4CC9F0\"\n          />\n          <path\n            d=\"m16.332 6.9539h-9.6145c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h9.6145c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641z\"\n            fill=\"#4361EE\"\n          />\n          <path\n            d=\"m21.466 20.167h-4.2804c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h4.2804c1.246 0 2.256-1.0137 2.256-2.2641s-1.01-2.2641-2.256-2.2641z\"\n            fill=\"#F72585\"\n          />\n          <path\n            d=\"m23.094 13.652h-7.5347c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2505 1.0101 2.2641 2.2561 2.2641h7.5347c1.246 0 2.2561-1.0136 2.2561-2.2641 0-1.2504-1.0101-2.2641-2.2561-2.2641z\"\n            fill=\"#7209B7\"\n          />\n          <path\n            d=\"m29.71 0.43726h-5.0748c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n            fill=\"#4CC9F0\"\n          />\n          <path\n            d=\"m22.872 11.481h9.0005c1.246 0 2.256-1.0137 2.256-2.2642 0-1.2504-1.01-2.2641-2.256-2.2641h-9.0005c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2504 1.0101 2.2642 2.2561 2.2642z\"\n            fill=\"#4361EE\"\n          />\n        </g>\n        <g\n          class=\"transition-opacity duration-[50ms] opacity-0\"\n        >\n          <path\n            d=\"m29.71 0.43726h-5.0748c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n            fill=\"#4CC9F0\"\n          />\n          <path\n            d=\"m22.872 11.481h9.0005c1.246 0 2.256-1.0137 2.256-2.2642 0-1.2504-1.01-2.2641-2.256-2.2641h-9.0005c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2504 1.0101 2.2642 2.2561 2.2642z\"\n            fill=\"#4361EE\"\n          />\n          <path\n            d=\"m29.054 18.194h5.0635c1.246 0 2.256-1.0136 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641h-5.0635c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2505 1.0101 2.2641 2.256 2.2641z\"\n            fill=\"#7209B7\"\n          />\n          <path\n            d=\"m31.099 24.71h5.2583c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641h-5.2583c-1.2459 0-2.256 1.0137-2.256 2.2641s1.0101 2.2641 2.256 2.2641z\"\n            fill=\"#F72585\"\n          />\n        </g>\n      </svg>\n    </div>\n  </body>,\n  \"container\": <div>\n    <svg\n      aria-label=\"loading\"\n      height=\"50\"\n      role=\"img\"\n      viewBox=\"0 0 39 25\"\n      width=\"50\"\n    >\n      <g\n        fill=\"#34363A\"\n      >\n        <path\n          d=\"m13.948 0.43726h-5.0748c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n        />\n        <path\n          d=\"m29.71 0.43726h-5.0748c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n        />\n        <path\n          d=\"m16.332 6.9539h-9.6145c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h9.6145c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641z\"\n        />\n        <path\n          d=\"m22.872 11.481h9.0005c1.246 0 2.256-1.0137 2.256-2.2642 0-1.2504-1.01-2.2641-2.256-2.2641h-9.0005c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2504 1.0101 2.2642 2.2561 2.2642z\"\n        />\n        <path\n          d=\"m7.5807 20.183h-5.2582c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2505 1.0101 2.2641 2.256 2.2641h5.2582c1.246 0 2.256-1.0136 2.256-2.2641 0-1.2504-1.0101-2.2641-2.256-2.2641z\"\n        />\n        <path\n          d=\"m31.099 24.71h5.2583c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641h-5.2583c-1.2459 0-2.256 1.0137-2.256 2.2641s1.0101 2.2641 2.256 2.2641z\"\n        />\n        <path\n          d=\"m21.466 20.167h-4.2804c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h4.2804c1.246 0 2.256-1.0137 2.256-2.2641s-1.01-2.2641-2.256-2.2641z\"\n        />\n        <path\n          d=\"m9.5383 13.666h-5.0635c-1.246 0-2.256 1.0136-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0635c1.2459 0 2.256-1.0137 2.256-2.2641 0-1.2505-1.0101-2.2641-2.256-2.2641z\"\n        />\n        <path\n          d=\"m29.054 18.194h5.0635c1.246 0 2.256-1.0136 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641h-5.0635c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2505 1.0101 2.2641 2.256 2.2641z\"\n        />\n        <path\n          d=\"m23.094 13.652h-7.5347c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2505 1.0101 2.2641 2.2561 2.2641h7.5347c1.246 0 2.2561-1.0136 2.2561-2.2641 0-1.2504-1.0101-2.2641-2.2561-2.2641z\"\n        />\n      </g>\n      <g\n        class=\"transition-opacity duration-[50ms] opacity-0\"\n      >\n        <path\n          d=\"m13.948 0.43726h-5.0748c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n          fill=\"#4CC9F0\"\n        />\n        <path\n          d=\"m16.332 6.9539h-9.6145c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h9.6145c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641z\"\n          fill=\"#4361EE\"\n        />\n        <path\n          d=\"m7.5807 20.183h-5.2582c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2505 1.0101 2.2641 2.256 2.2641h5.2582c1.246 0 2.256-1.0136 2.256-2.2641 0-1.2504-1.0101-2.2641-2.256-2.2641z\"\n          fill=\"#F72585\"\n        />\n        <path\n          d=\"m9.5383 13.666h-5.0635c-1.246 0-2.256 1.0136-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0635c1.2459 0 2.256-1.0137 2.256-2.2641 0-1.2505-1.0101-2.2641-2.256-2.2641z\"\n          fill=\"#7209B7\"\n        />\n      </g>\n      <g\n        class=\"transition-opacity duration-[50ms] opacity-0\"\n      >\n        <path\n          d=\"m13.948 0.43726h-5.0748c-1.246 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n          fill=\"#4CC9F0\"\n        />\n        <path\n          d=\"m16.332 6.9539h-9.6145c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h9.6145c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641z\"\n          fill=\"#4361EE\"\n        />\n        <path\n          d=\"m21.466 20.167h-4.2804c-1.246 0-2.256 1.0137-2.256 2.2641s1.01 2.2641 2.256 2.2641h4.2804c1.246 0 2.256-1.0137 2.256-2.2641s-1.01-2.2641-2.256-2.2641z\"\n          fill=\"#F72585\"\n        />\n        <path\n          d=\"m23.094 13.652h-7.5347c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2505 1.0101 2.2641 2.2561 2.2641h7.5347c1.246 0 2.2561-1.0136 2.2561-2.2641 0-1.2504-1.0101-2.2641-2.2561-2.2641z\"\n          fill=\"#7209B7\"\n        />\n        <path\n          d=\"m29.71 0.43726h-5.0748c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n          fill=\"#4CC9F0\"\n        />\n        <path\n          d=\"m22.872 11.481h9.0005c1.246 0 2.256-1.0137 2.256-2.2642 0-1.2504-1.01-2.2641-2.256-2.2641h-9.0005c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2504 1.0101 2.2642 2.2561 2.2642z\"\n          fill=\"#4361EE\"\n        />\n      </g>\n      <g\n        class=\"transition-opacity duration-[50ms] opacity-0\"\n      >\n        <path\n          d=\"m29.71 0.43726h-5.0748c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2504 1.0101 2.2641 2.256 2.2641h5.0748c1.246 0 2.256-1.0137 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641z\"\n          fill=\"#4CC9F0\"\n        />\n        <path\n          d=\"m22.872 11.481h9.0005c1.246 0 2.256-1.0137 2.256-2.2642 0-1.2504-1.01-2.2641-2.256-2.2641h-9.0005c-1.246 0-2.2561 1.0137-2.2561 2.2641 0 1.2504 1.0101 2.2642 2.2561 2.2642z\"\n          fill=\"#4361EE\"\n        />\n        <path\n          d=\"m29.054 18.194h5.0635c1.246 0 2.256-1.0136 2.256-2.2641 0-1.2504-1.01-2.2641-2.256-2.2641h-5.0635c-1.2459 0-2.256 1.0137-2.256 2.2641 0 1.2505 1.0101 2.2641 2.256 2.2641z\"\n          fill=\"#7209B7\"\n        />\n        <path\n          d=\"m31.099 24.71h5.2583c1.2459 0 2.256-1.0137 2.256-2.2641s-1.0101-2.2641-2.256-2.2641h-5.2583c-1.2459 0-2.256 1.0137-2.256 2.2641s1.0101 2.2641 2.256 2.2641z\"\n          fill=\"#F72585\"\n        />\n      </g>\n    </svg>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/LoadingSpinner/index.ts",
    "content": "export { default as LoadingSpinner } from './LoadingSpinner'\n"
  },
  {
    "path": "libs/design-system/src/lib/Menu/Menu.spec.tsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react'\nimport { Menu } from './'\n\n// Not tested too thoroughly, most logic is covered by Headless UI base\ndescribe('Menu', () => {\n    describe('when closed', () => {\n        it('should render the button', () => {\n            const component = render(\n                <Menu>\n                    <Menu.Button>Click Me</Menu.Button>\n                    <Menu.Items>\n                        <Menu.Item>Option 1</Menu.Item>\n                    </Menu.Items>\n                </Menu>\n            )\n\n            expect(screen.getByText('Click Me')).toBeVisible()\n            expect(screen.queryByText('Option 1')).not.toBeVisible()\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when opened', () => {\n        it('should render the button and the items', () => {\n            const component = render(\n                <Menu>\n                    <Menu.Button>Click Me</Menu.Button>\n                    <Menu.Items>\n                        <Menu.Item>Option 1</Menu.Item>\n                        <Menu.Item disabled={true}>Option 2</Menu.Item>\n                    </Menu.Items>\n                </Menu>\n            )\n\n            fireEvent.click(screen.getByText('Click Me'))\n            ;['Click Me', 'Option 1', 'Option 2'].forEach((text) =>\n                expect(screen.getByText(text)).toBeVisible()\n            )\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when an option is pressed', () => {\n        it('should call the `onClick` callback', () => {\n            const onClickMock = jest.fn()\n            render(\n                <Menu>\n                    <Menu.Button>Click Me</Menu.Button>\n                    <Menu.Items>\n                        <Menu.Item onClick={onClickMock}>Option 1</Menu.Item>\n                    </Menu.Items>\n                </Menu>\n            )\n\n            fireEvent.click(screen.getByText('Click Me'))\n            fireEvent.click(screen.getByText('Option 1'))\n            expect(onClickMock).toHaveBeenCalledTimes(1)\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Menu/Menu.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport {\n    RiArrowDownSLine,\n    RiCloseCircleLine,\n    RiDeleteBinLine,\n    RiInformationLine,\n} from 'react-icons/ri'\n\nimport Menu from './Menu'\n\nexport default {\n    title: 'Components/Menu',\n    component: Menu,\n    parameters: { controls: { exclude: ['className', 'as', 'refName'] } },\n    argTypes: {\n        variant: {\n            control: 'select',\n            options: ['primary', 'secondary'],\n            description: 'Variant for Button component',\n        },\n    },\n    args: {\n        variant: 'primary',\n    },\n} as Meta\n\nconst Template: Story = (args) => (\n    <div className=\"mb-32\">\n        <Menu>\n            <Menu.Button variant={args.variant}>\n                Click Me <RiArrowDownSLine />\n            </Menu.Button>\n            <Menu.Items>\n                <Menu.Item icon={<RiInformationLine />}>Option 1</Menu.Item>\n                <Menu.Item icon={<RiDeleteBinLine />} destructive={true}>\n                    Option 2 (Destructive)\n                </Menu.Item>\n                <Menu.Item icon={<RiCloseCircleLine />} disabled={true}>\n                    Option 3 (Disabled)\n                </Menu.Item>\n            </Menu.Items>\n        </Menu>\n    </div>\n)\n\nexport const Base = Template.bind({})\n"
  },
  {
    "path": "libs/design-system/src/lib/Menu/Menu.tsx",
    "content": "import React, { createContext, forwardRef, useContext, useEffect, useRef, useState } from 'react'\nimport type { ComponentProps, PropsWithChildren, Ref, RefObject } from 'react'\nimport { Menu as HeadlessMenu } from '@headlessui/react'\nimport type { PopperProps } from 'react-popper'\nimport { usePopper } from 'react-popper'\nimport classNames from 'classnames'\nimport Link from 'next/link'\nimport { Button as BaseButton } from '../../'\n\ntype ExtractProps<T> = T extends React.ComponentType<infer P> ? P : T\n\nconst PopoverContext = createContext<{\n    referenceElement?: RefObject<HTMLButtonElement>\n}>({})\n\nfunction Menu({ className, ...rest }: ExtractProps<typeof HeadlessMenu>) {\n    const referenceElement = useRef<HTMLButtonElement>(null)\n\n    return (\n        <PopoverContext.Provider value={{ referenceElement }}>\n            <HeadlessMenu as=\"div\" className={classNames(className, 'relative')} {...rest} />\n        </PopoverContext.Provider>\n    )\n}\n\nfunction Button({\n    ...rest\n}: ExtractProps<typeof HeadlessMenu.Button> & ComponentProps<typeof BaseButton>) {\n    const { referenceElement } = useContext(PopoverContext)\n\n    return <HeadlessMenu.Button as={BaseButton} ref={referenceElement} {...rest} />\n}\n\ntype ItemsProps = ExtractProps<typeof HeadlessMenu.Items>\n\nfunction Items({\n    className,\n    placement = 'bottom-start',\n    children,\n    ...rest\n}: { placement?: PopperProps<any>['placement'] } & (\n    | Omit<ItemsProps, 'static'>\n    | Omit<ItemsProps, 'unmount'>\n)) {\n    const { referenceElement } = useContext(PopoverContext)\n    const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)\n    const [isOpen, setIsOpen] = useState(false)\n\n    const isOpenRef = useRef(false)\n\n    const { styles, attributes, update } = usePopper(referenceElement?.current, popperElement, {\n        placement,\n        modifiers: [\n            {\n                name: 'offset',\n                options: {\n                    offset: [0, 8],\n                },\n            },\n        ],\n    })\n\n    useEffect(() => {\n        if (isOpen && update) update()\n        if (isOpenRef.current !== isOpen) setIsOpen(isOpenRef.current)\n    }, [isOpen, update])\n\n    return (\n        <div\n            ref={(el) => setPopperElement(el)}\n            className={classNames('z-10 absolute min-w-full', className)}\n            style={styles.popper}\n            {...attributes.popper}\n        >\n            <HeadlessMenu.Items\n                className={classNames(className, 'py-2 rounded bg-gray-700 shadow-md')}\n                unmount={false}\n                {...rest}\n            >\n                {(renderProps) => {\n                    isOpenRef.current = renderProps.open\n                    return typeof children === 'function' ? children(renderProps) : children\n                }}\n            </HeadlessMenu.Items>\n        </div>\n    )\n}\n\ntype ItemProps<T extends React.ElementType | React.ComponentType> = PropsWithChildren<{\n    as?: T\n    icon?: React.ReactNode\n    destructive?: boolean\n    disabled?: boolean\n}> &\n    React.ComponentPropsWithoutRef<T>\n\nfunction Item<T extends React.ElementType | React.ComponentType = 'button'>({\n    as,\n    className,\n    children,\n    icon,\n    destructive = false,\n    disabled = false,\n    ...rest\n}: ItemProps<T>) {\n    const InnerComponent = as || 'button'\n\n    return (\n        <HeadlessMenu.Item disabled={disabled}>\n            {({ active, disabled }) => (\n                <InnerComponent\n                    className={classNames(\n                        className,\n                        'flex items-center pl-3 pr-5 w-full text-base leading-8 whitespace-nowrap',\n                        destructive ? 'text-red' : 'text-gray-25',\n                        active && 'bg-gray-600',\n                        disabled ? 'opacity-50' : 'hover:bg-gray-600'\n                    )}\n                    {...rest}\n                >\n                    {icon && (\n                        <span\n                            className={classNames(\n                                'text-2xl mr-2.5',\n                                destructive ? 'text-red' : 'text-gray-50',\n                                disabled && 'opacity-50'\n                            )}\n                        >\n                            {icon}\n                        </span>\n                    )}\n                    {children}\n                </InnerComponent>\n            )}\n        </HeadlessMenu.Item>\n    )\n}\n\nconst NextLink = forwardRef(\n    (\n        {\n            href,\n            children,\n            ...rest\n        }: { href: string } & PropsWithChildren<React.ComponentPropsWithoutRef<'a'>>,\n        ref: Ref<HTMLAnchorElement>\n    ) => {\n        return (\n            <Link href={href} ref={ref} {...rest}>\n                {children}\n            </Link>\n        )\n    }\n)\n\nfunction ItemNextLink(props: Omit<ItemProps<typeof NextLink>, 'as'>) {\n    return <Item as={NextLink} {...props}></Item>\n}\n\nexport default Object.assign(Menu, { Button, Items, Item, ItemNextLink })\n"
  },
  {
    "path": "libs/design-system/src/lib/Menu/__snapshots__/Menu.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Menu when closed should render the button 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"relative\"\n        data-headlessui-state=\"\"\n      >\n        <button\n          aria-controls=\"headlessui-menu-items-:r1:\"\n          aria-expanded=\"false\"\n          aria-haspopup=\"true\"\n          class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n          data-headlessui-state=\"\"\n          id=\"headlessui-menu-button-:r0:\"\n          role=\"button\"\n          type=\"button\"\n        >\n          Click Me\n        </button>\n        <div\n          class=\"z-10 absolute min-w-full\"\n          style=\"position: absolute; left: 0px; top: 0px;\"\n        >\n          <div\n            aria-labelledby=\"headlessui-menu-button-:r0:\"\n            class=\"py-2 rounded bg-gray-700 shadow-md\"\n            data-headlessui-state=\"\"\n            hidden=\"\"\n            id=\"headlessui-menu-items-:r1:\"\n            role=\"menu\"\n            style=\"display: none;\"\n            tabindex=\"0\"\n          >\n            <button\n              class=\"flex items-center pl-3 pr-5 w-full text-base leading-8 whitespace-nowrap text-gray-25 hover:bg-gray-600\"\n              data-headlessui-state=\"\"\n              id=\"headlessui-menu-item-:r2:\"\n              role=\"menuitem\"\n              tabindex=\"-1\"\n            >\n              Option 1\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative\"\n      data-headlessui-state=\"\"\n    >\n      <button\n        aria-controls=\"headlessui-menu-items-:r1:\"\n        aria-expanded=\"false\"\n        aria-haspopup=\"true\"\n        class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n        data-headlessui-state=\"\"\n        id=\"headlessui-menu-button-:r0:\"\n        role=\"button\"\n        type=\"button\"\n      >\n        Click Me\n      </button>\n      <div\n        class=\"z-10 absolute min-w-full\"\n        style=\"position: absolute; left: 0px; top: 0px;\"\n      >\n        <div\n          aria-labelledby=\"headlessui-menu-button-:r0:\"\n          class=\"py-2 rounded bg-gray-700 shadow-md\"\n          data-headlessui-state=\"\"\n          hidden=\"\"\n          id=\"headlessui-menu-items-:r1:\"\n          role=\"menu\"\n          style=\"display: none;\"\n          tabindex=\"0\"\n        >\n          <button\n            class=\"flex items-center pl-3 pr-5 w-full text-base leading-8 whitespace-nowrap text-gray-25 hover:bg-gray-600\"\n            data-headlessui-state=\"\"\n            id=\"headlessui-menu-item-:r2:\"\n            role=\"menuitem\"\n            tabindex=\"-1\"\n          >\n            Option 1\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Menu when opened should render the button and the items 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"relative\"\n        data-headlessui-state=\"open\"\n      >\n        <button\n          aria-controls=\"headlessui-menu-items-:r4:\"\n          aria-expanded=\"true\"\n          aria-haspopup=\"true\"\n          class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n          data-headlessui-state=\"open\"\n          id=\"headlessui-menu-button-:r3:\"\n          role=\"button\"\n          type=\"button\"\n        >\n          Click Me\n        </button>\n        <div\n          class=\"z-10 absolute min-w-full\"\n          style=\"position: absolute; left: 0px; top: 0px;\"\n        >\n          <div\n            aria-labelledby=\"headlessui-menu-button-:r3:\"\n            class=\"py-2 rounded bg-gray-700 shadow-md\"\n            data-headlessui-state=\"open\"\n            id=\"headlessui-menu-items-:r4:\"\n            role=\"menu\"\n            style=\"\"\n            tabindex=\"0\"\n          >\n            <button\n              class=\"flex items-center pl-3 pr-5 w-full text-base leading-8 whitespace-nowrap text-gray-25 hover:bg-gray-600\"\n              data-headlessui-state=\"\"\n              id=\"headlessui-menu-item-:r5:\"\n              role=\"menuitem\"\n              tabindex=\"-1\"\n            >\n              Option 1\n            </button>\n            <button\n              aria-disabled=\"true\"\n              class=\"flex items-center pl-3 pr-5 w-full text-base leading-8 whitespace-nowrap text-gray-25 opacity-50\"\n              data-headlessui-state=\"disabled\"\n              id=\"headlessui-menu-item-:r6:\"\n              role=\"menuitem\"\n            >\n              Option 2\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative\"\n      data-headlessui-state=\"open\"\n    >\n      <button\n        aria-controls=\"headlessui-menu-items-:r4:\"\n        aria-expanded=\"true\"\n        aria-haspopup=\"true\"\n        class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n        data-headlessui-state=\"open\"\n        id=\"headlessui-menu-button-:r3:\"\n        role=\"button\"\n        type=\"button\"\n      >\n        Click Me\n      </button>\n      <div\n        class=\"z-10 absolute min-w-full\"\n        style=\"position: absolute; left: 0px; top: 0px;\"\n      >\n        <div\n          aria-labelledby=\"headlessui-menu-button-:r3:\"\n          class=\"py-2 rounded bg-gray-700 shadow-md\"\n          data-headlessui-state=\"open\"\n          id=\"headlessui-menu-items-:r4:\"\n          role=\"menu\"\n          style=\"\"\n          tabindex=\"0\"\n        >\n          <button\n            class=\"flex items-center pl-3 pr-5 w-full text-base leading-8 whitespace-nowrap text-gray-25 hover:bg-gray-600\"\n            data-headlessui-state=\"\"\n            id=\"headlessui-menu-item-:r5:\"\n            role=\"menuitem\"\n            tabindex=\"-1\"\n          >\n            Option 1\n          </button>\n          <button\n            aria-disabled=\"true\"\n            class=\"flex items-center pl-3 pr-5 w-full text-base leading-8 whitespace-nowrap text-gray-25 opacity-50\"\n            data-headlessui-state=\"disabled\"\n            id=\"headlessui-menu-item-:r6:\"\n            role=\"menuitem\"\n          >\n            Option 2\n          </button>\n        </div>\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Menu/index.ts",
    "content": "export { default as Menu } from './Menu'\n"
  },
  {
    "path": "libs/design-system/src/lib/Popover/Popover.spec.tsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react'\nimport { Popover } from './'\n\n// Not tested too thoroughly, most logic is covered by Headless UI base\ndescribe('Popover', () => {\n    describe('when closed', () => {\n        it('should render the button', () => {\n            const component = render(\n                <Popover>\n                    <Popover.Button>Click Me</Popover.Button>\n                    <Popover.Panel>Content</Popover.Panel>\n                </Popover>\n            )\n\n            expect(screen.getByText('Click Me')).toBeVisible()\n            expect(screen.queryByText('Content')).not.toBeVisible()\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when opened', () => {\n        it('should render the button and the panel', () => {\n            const component = render(\n                <Popover>\n                    <Popover.Button>Click Me</Popover.Button>\n                    <Popover.Panel>Content</Popover.Panel>\n                </Popover>\n            )\n\n            fireEvent.click(screen.getByText('Click Me'))\n            expect(screen.getByText('Click Me')).toBeVisible()\n            expect(screen.getByText('Content')).toBeVisible()\n            expect(component).toMatchSnapshot()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Popover/Popover.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\n\nimport Popover from './Popover'\n\nexport default {\n    title: 'Components/Popover',\n    component: Popover,\n    parameters: { controls: { exclude: ['className'] } },\n} as Meta\n\nconst Template: Story = (args) => (\n    <div className=\"mb-16\">\n        <Popover>\n            <Popover.Button {...args}>Click Me</Popover.Button>\n            <Popover.Panel>Panel Content</Popover.Panel>\n        </Popover>\n    </div>\n)\n\nexport const Base = Template.bind({})\n"
  },
  {
    "path": "libs/design-system/src/lib/Popover/Popover.tsx",
    "content": "import React, { createContext, useContext, useEffect, useRef, useState } from 'react'\nimport type { ComponentProps, RefObject } from 'react'\nimport type { PopperProps } from 'react-popper'\nimport { Popover as HeadlessPopover } from '@headlessui/react'\nimport { usePopper } from 'react-popper'\nimport classNames from 'classnames'\nimport { Button as BaseButton } from '../../'\nimport { RiArrowDownSFill } from 'react-icons/ri'\n\ntype ExtractProps<T> = T extends React.ComponentType<infer P> ? P : T\n\nconst PopoverContext = createContext<{\n    referenceElement?: RefObject<HTMLButtonElement>\n}>({})\n\nfunction Popover({ className, ...rest }: ExtractProps<typeof HeadlessPopover>) {\n    const referenceElement = useRef<HTMLButtonElement>(null)\n\n    return (\n        <PopoverContext.Provider value={{ referenceElement }}>\n            <HeadlessPopover\n                as=\"div\"\n                className={classNames(className, 'relative inline-block')}\n                {...rest}\n            />\n        </PopoverContext.Provider>\n    )\n}\n\nfunction Button({\n    variant,\n    hideRightIcon = false,\n    children,\n    ...rest\n}: { hideRightIcon?: boolean } & ExtractProps<typeof HeadlessPopover.Button> &\n    ComponentProps<typeof BaseButton>) {\n    const { referenceElement } = useContext(PopoverContext)\n\n    return (\n        <HeadlessPopover.Button as={BaseButton} ref={referenceElement} variant={variant} {...rest}>\n            <span className=\"grow\">{children}</span>\n            {!hideRightIcon && (\n                <RiArrowDownSFill\n                    className={classNames(\n                        'shrink-0 ml-2 text-lg',\n                        variant === 'secondary' && 'text-gray-50'\n                    )}\n                />\n            )}\n        </HeadlessPopover.Button>\n    )\n}\n\nfunction PanelButton(\n    props: ExtractProps<typeof HeadlessPopover.Button> & ComponentProps<typeof BaseButton>\n) {\n    return <HeadlessPopover.Button as={BaseButton} {...props} />\n}\n\ntype PopoverPanelProps = ExtractProps<typeof HeadlessPopover.Panel>\n\nfunction Panel({\n    children,\n    className,\n    placement = 'bottom-start',\n    ...rest\n}: (Omit<PopoverPanelProps, 'static'> | Omit<PopoverPanelProps, 'unmount'>) & {\n    placement?: PopperProps<any>['placement']\n}) {\n    const { referenceElement } = useContext(PopoverContext)\n    const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)\n    const [isOpen, setIsOpen] = useState(false)\n\n    const { styles, attributes, update } = usePopper(referenceElement?.current, popperElement, {\n        placement,\n        modifiers: [\n            {\n                name: 'offset',\n                options: {\n                    offset: [0, 8],\n                },\n            },\n        ],\n    })\n\n    useEffect(() => {\n        if (isOpen && update) update()\n    }, [isOpen, update])\n\n    return (\n        <div\n            ref={(el) => setPopperElement(el)}\n            className={classNames('z-20 absolute min-w-full', className)}\n            style={styles.popper}\n            {...attributes.popper}\n        >\n            <HeadlessPopover.Panel\n                className={classNames(\n                    'p-2 shadow-md rounded bg-gray-700 border border-gray-500 text-white',\n                    className\n                )}\n                unmount={false}\n                {...rest}\n            >\n                {(renderProps) => {\n                    setIsOpen(renderProps.open)\n                    return typeof children === 'function' ? children(renderProps) : children\n                }}\n            </HeadlessPopover.Panel>\n        </div>\n    )\n}\n\nexport default Object.assign(Popover, { Button, Panel, PanelButton })\n"
  },
  {
    "path": "libs/design-system/src/lib/Popover/__snapshots__/Popover.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Popover when closed should render the button 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"relative inline-block\"\n        data-headlessui-state=\"\"\n      >\n        <button\n          aria-controls=\"headlessui-popover-panel-:r1:\"\n          aria-expanded=\"false\"\n          class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n          data-headlessui-state=\"\"\n          id=\"headlessui-popover-button-:r0:\"\n          role=\"button\"\n          type=\"button\"\n        >\n          <span\n            class=\"grow\"\n          >\n            Click Me\n          </span>\n          <svg\n            class=\"shrink-0 ml-2 text-lg\"\n            fill=\"currentColor\"\n            height=\"1em\"\n            stroke=\"currentColor\"\n            stroke-width=\"0\"\n            viewBox=\"0 0 24 24\"\n            width=\"1em\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <g>\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M12 16l-6-6h12z\"\n              />\n            </g>\n          </svg>\n        </button>\n        <div\n          class=\"z-20 absolute min-w-full\"\n          style=\"position: absolute; left: 0px; top: 0px;\"\n        >\n          <div\n            class=\"p-2 shadow-md rounded bg-gray-700 border border-gray-500 text-white\"\n            data-headlessui-state=\"\"\n            hidden=\"\"\n            id=\"headlessui-popover-panel-:r1:\"\n            style=\"display: none;\"\n            tabindex=\"-1\"\n          >\n            Content\n          </div>\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative inline-block\"\n      data-headlessui-state=\"\"\n    >\n      <button\n        aria-controls=\"headlessui-popover-panel-:r1:\"\n        aria-expanded=\"false\"\n        class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n        data-headlessui-state=\"\"\n        id=\"headlessui-popover-button-:r0:\"\n        role=\"button\"\n        type=\"button\"\n      >\n        <span\n          class=\"grow\"\n        >\n          Click Me\n        </span>\n        <svg\n          class=\"shrink-0 ml-2 text-lg\"\n          fill=\"currentColor\"\n          height=\"1em\"\n          stroke=\"currentColor\"\n          stroke-width=\"0\"\n          viewBox=\"0 0 24 24\"\n          width=\"1em\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n        >\n          <g>\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M12 16l-6-6h12z\"\n            />\n          </g>\n        </svg>\n      </button>\n      <div\n        class=\"z-20 absolute min-w-full\"\n        style=\"position: absolute; left: 0px; top: 0px;\"\n      >\n        <div\n          class=\"p-2 shadow-md rounded bg-gray-700 border border-gray-500 text-white\"\n          data-headlessui-state=\"\"\n          hidden=\"\"\n          id=\"headlessui-popover-panel-:r1:\"\n          style=\"display: none;\"\n          tabindex=\"-1\"\n        >\n          Content\n        </div>\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Popover when opened should render the button and the panel 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"relative inline-block\"\n        data-headlessui-state=\"open\"\n      >\n        <button\n          aria-controls=\"headlessui-popover-panel-:r6:\"\n          aria-expanded=\"true\"\n          class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n          data-headlessui-state=\"open\"\n          id=\"headlessui-popover-button-:r5:\"\n          role=\"button\"\n          type=\"button\"\n        >\n          <span\n            class=\"grow\"\n          >\n            Click Me\n          </span>\n          <svg\n            class=\"shrink-0 ml-2 text-lg\"\n            fill=\"currentColor\"\n            height=\"1em\"\n            stroke=\"currentColor\"\n            stroke-width=\"0\"\n            viewBox=\"0 0 24 24\"\n            width=\"1em\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <g>\n              <path\n                d=\"M0 0h24v24H0z\"\n                fill=\"none\"\n              />\n              <path\n                d=\"M12 16l-6-6h12z\"\n              />\n            </g>\n          </svg>\n        </button>\n        <div\n          class=\"z-20 absolute min-w-full\"\n          style=\"position: absolute; left: 0px; top: 0px;\"\n        >\n          <div\n            class=\"p-2 shadow-md rounded bg-gray-700 border border-gray-500 text-white\"\n            data-headlessui-state=\"open\"\n            id=\"headlessui-popover-panel-:r6:\"\n            style=\"\"\n            tabindex=\"-1\"\n          >\n            Content\n          </div>\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative inline-block\"\n      data-headlessui-state=\"open\"\n    >\n      <button\n        aria-controls=\"headlessui-popover-panel-:r6:\"\n        aria-expanded=\"true\"\n        class=\"px-4 py-2 rounded text-base bg-cyan text-gray-700 shadow hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan inline-flex items-center justify-center text-center font-medium leading-6 whitespace-nowrap select-none cursor-pointer focus:outline-none focus:ring focus:ring-opacity-60 disabled:opacity-50 disabled:pointer-events-none transition-colors duration-200\"\n        data-headlessui-state=\"open\"\n        id=\"headlessui-popover-button-:r5:\"\n        role=\"button\"\n        type=\"button\"\n      >\n        <span\n          class=\"grow\"\n        >\n          Click Me\n        </span>\n        <svg\n          class=\"shrink-0 ml-2 text-lg\"\n          fill=\"currentColor\"\n          height=\"1em\"\n          stroke=\"currentColor\"\n          stroke-width=\"0\"\n          viewBox=\"0 0 24 24\"\n          width=\"1em\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n        >\n          <g>\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M12 16l-6-6h12z\"\n            />\n          </g>\n        </svg>\n      </button>\n      <div\n        class=\"z-20 absolute min-w-full\"\n        style=\"position: absolute; left: 0px; top: 0px;\"\n      >\n        <div\n          class=\"p-2 shadow-md rounded bg-gray-700 border border-gray-500 text-white\"\n          data-headlessui-state=\"open\"\n          id=\"headlessui-popover-panel-:r6:\"\n          style=\"\"\n          tabindex=\"-1\"\n        >\n          Content\n        </div>\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Popover/index.ts",
    "content": "export { default as Popover } from './Popover'\n"
  },
  {
    "path": "libs/design-system/src/lib/RTEditor/RTEditor.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport cn from 'classnames'\nimport { EditorContent, type Editor } from '@tiptap/react'\nimport {\n    RiItalic,\n    RiBold,\n    RiStrikethrough,\n    RiListOrdered,\n    RiListUnordered,\n    RiDoubleQuotesR,\n    RiHeading,\n} from 'react-icons/ri'\n\nexport type RTEditorProps = {\n    editor: Editor | null\n    className?: string\n    hideControls?: boolean\n}\n\nexport function RTEditor({ hideControls = false, editor, className }: RTEditorProps) {\n    return (\n        <>\n            {!hideControls && <RichTextEditorMenuBar editor={editor} />}\n            <EditorContent\n                editor={editor}\n                className={cn(\n                    className,\n                    'flex flex-col h-auto min-h-[80px] max-h-72 custom-gray-scroll border border-gray-700 rounded p-3 focus-within:border focus-within:border-cyan'\n                )}\n            />\n        </>\n    )\n}\n\nfunction RichTextEditorMenuBar({ editor }: { editor: Editor | null }) {\n    if (!editor) return null\n\n    return (\n        <div className=\"flex items-center space-x-2\">\n            <div className=\"flex space-x-0.5\">\n                <MenuButton editor={editor} mark=\"bold\" tooltip=\"Bold\">\n                    <RiBold size={16} />\n                </MenuButton>\n                <MenuButton editor={editor} mark=\"italic\" tooltip=\"Italicize\">\n                    <RiItalic size={16} />\n                </MenuButton>\n                <MenuButton editor={editor} mark=\"strike\" tooltip=\"Strikethrough\">\n                    <RiStrikethrough size={16} />\n                </MenuButton>\n            </div>\n            <div className=\"pl-2 border-l flex space-x-0.5\">\n                <MenuButton editor={editor} mark=\"orderedList\" tooltip=\"Numbered list\">\n                    <RiListOrdered size={16} />\n                </MenuButton>\n                <MenuButton editor={editor} mark=\"bulletList\" tooltip=\"Bulleted List\">\n                    <RiListUnordered size={16} />\n                </MenuButton>\n            </div>\n            <div className=\"pl-2 border-l flex space-x-0.5\">\n                <MenuButton\n                    editor={editor}\n                    mark=\"heading\"\n                    attributes={{ level: 1 }}\n                    tooltip=\"Heading\"\n                >\n                    <RiHeading size={16} />\n                </MenuButton>\n                <MenuButton editor={editor} mark=\"blockquote\" tooltip=\"Blockquote\">\n                    <RiDoubleQuotesR size={16} />\n                </MenuButton>\n            </div>\n        </div>\n    )\n}\n\nfunction MenuButton({\n    editor,\n    mark,\n    attributes,\n    tooltip,\n    children,\n}: PropsWithChildren<{ editor: Editor; mark: string; attributes?: any; tooltip?: string }>) {\n    const method = `toggle${mark[0].toUpperCase()}${mark.slice(1)}`\n    return (\n        <button\n            type=\"button\"\n            title={tooltip}\n            onClick={() => (editor.chain().focus() as any)[method](attributes).run()}\n            disabled={!(editor.can().chain().focus() as any)[method](attributes).run()}\n            className={cn(\n                'p-1.5 text-base text-gray-100 leading-none rounded hover:bg-gray-600',\n                editor.isActive(mark) ? 'bg-gray-600' : ''\n            )}\n        >\n            {children}\n        </button>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/RTEditor/index.ts",
    "content": "export * from './RTEditor'\n"
  },
  {
    "path": "libs/design-system/src/lib/RadioGroup/RadioGroup.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport { useState } from 'react'\n\nimport RadioGroup from './RadioGroup'\n\nexport default {\n    title: 'Components/RadioGroup',\n    component: RadioGroup,\n    argTypes: {},\n    args: {},\n} as Meta\n\nexport const Base: Story = (args) => {\n    const [value, setValue] = useState('option-1')\n\n    return (\n        <div className=\"flex items-center justify-center text-gray-100\">\n            <RadioGroup {...(args as any)} value={value} onChange={setValue}>\n                <RadioGroup.Label className=\"sr-only\">Selections</RadioGroup.Label>\n\n                <RadioGroup.Option value=\"option-1\">\n                    <RadioGroup.Label>Option 1</RadioGroup.Label>\n                </RadioGroup.Option>\n\n                <RadioGroup.Option value=\"option-2\">\n                    <RadioGroup.Label>Option 2</RadioGroup.Label>\n                </RadioGroup.Option>\n\n                <RadioGroup.Option value=\"option-3\">\n                    <RadioGroup.Label>Option 3</RadioGroup.Label>\n                </RadioGroup.Option>\n            </RadioGroup>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/RadioGroup/RadioGroup.tsx",
    "content": "import { RadioGroup as HeadlessRadio } from '@headlessui/react'\nimport classNames from 'classnames'\nimport type { PropsWithChildren } from 'react'\n\ntype ExtractProps<T> = T extends React.ComponentType<infer P> ? P : T\n\nfunction RadioGroup(props: ExtractProps<typeof HeadlessRadio>) {\n    return <HeadlessRadio {...props} />\n}\n\nfunction RadioOption({\n    children,\n    ...rest\n}: PropsWithChildren<ExtractProps<typeof HeadlessRadio.Option>>) {\n    return (\n        <HeadlessRadio.Option {...rest}>\n            {({ checked }) => (\n                <div className=\"flex items-center gap-2\">\n                    {/* Circle centered inside a circle */}\n                    <span\n                        className={classNames(\n                            'relative inline-block w-4 h-4 rounded-full border',\n                            checked ? 'border-cyan' : 'border-gray-300'\n                        )}\n                    >\n                        <span\n                            className={classNames(\n                                'absolute w-[10px] h-[10px] top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-full z-10',\n                                checked ? 'bg-cyan' : 'bg-transparent'\n                            )}\n                        />\n                    </span>\n                    {children}\n                </div>\n            )}\n        </HeadlessRadio.Option>\n    )\n}\n\nexport default Object.assign(RadioGroup, { Label: HeadlessRadio.Label, Option: RadioOption })\n"
  },
  {
    "path": "libs/design-system/src/lib/RadioGroup/index.ts",
    "content": "export { default as RadioGroup } from './RadioGroup'\n"
  },
  {
    "path": "libs/design-system/src/lib/Slider/Slider.spec.tsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport { Slider } from '.'\n\ndescribe('Slider', () => {\n    // Limited test cases we can do here without implementing a rather complex drag-n-drop test case\n    it('should render correctly', () => {\n        const onChangeMock = jest.fn()\n        const component = render(<Slider initialValue={[10]} onChange={onChangeMock} />)\n\n        const handle = screen.getByTestId('handle-0')\n        expect(handle).toBeInTheDocument()\n\n        // We only provided 1 handle in the `initialValue` array, so this shouldn't be there\n        expect(screen.queryByTestId('handle-1')).not.toBeInTheDocument()\n\n        expect(screen.getByTestId('slider-track')).toBeInTheDocument()\n        expect(component).toMatchSnapshot()\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Slider/Slider.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\n\nimport Slider from './Slider'\n\nexport default {\n    title: 'Components/Slider',\n    argTypes: {},\n    args: {},\n} as Meta\n\nexport const Base: Story = (_args) => (\n    <div className=\"flex items-center justify-center\">\n        <div className=\"w-48 mx-auto w-full\">\n            <Slider initialValue={[]} onChange={(values: number[]) => console.log(values)} />\n        </div>\n    </div>\n)\n"
  },
  {
    "path": "libs/design-system/src/lib/Slider/Slider.tsx",
    "content": "import type { RangerOptions } from 'react-ranger'\nimport { useRanger } from 'react-ranger'\nimport classNames from 'classnames'\nimport { useState } from 'react'\nimport isEqual from 'lodash/isEqual'\n\nconst SliderHandleClassNames = Object.freeze({\n    small: 'w-2 h-2 rounded-lg',\n    default: 'w-3 h-3 rounded-lg',\n    large: 'w-4 h-4 rounded-lg',\n})\n\nconst SliderTrackClassNames = Object.freeze({\n    small: 'h-1 rounded-lg',\n    default: 'h-1.5 rounded-lg',\n    large: 'h-2 rounded-lg',\n})\n\nconst SliderVariantColor = Object.freeze({\n    default: 'bg-cyan',\n})\n\nexport type SliderProps = {\n    variant?: 'default'\n    size?: 'small' | 'default' | 'large'\n    className?: string\n    initialValue: number[]\n    updateOnDrag?: boolean\n    onChange: (values: number[]) => void\n    rangerOptions?: Partial<Omit<RangerOptions, 'values' | 'onChange'>>\n}\n\nexport default function Slider({\n    variant = 'default',\n    size = 'default',\n    initialValue = [0],\n    updateOnDrag = false,\n    onChange,\n    className,\n    rangerOptions,\n}: SliderProps) {\n    const [internalValues, setInternalValues] = useState(initialValue)\n    const [isDragging, setIsDragging] = useState(false)\n\n    const { getTrackProps, segments, handles } = useRanger({\n        values: internalValues,\n        onChange: (values: number[]) => {\n            onChange(values)\n            setIsDragging(false)\n        },\n        min: rangerOptions?.min ?? 0,\n        max: rangerOptions?.max ?? 100,\n        stepSize: rangerOptions?.stepSize ?? 1,\n        onDrag: (values: number[]) => {\n            setInternalValues(values)\n\n            // Possible react-ranger bug: seems to fire even after release, so use this workaround\n            if (!isEqual(values, internalValues)) {\n                setIsDragging(true)\n            }\n\n            if (updateOnDrag) {\n                onChange(values)\n            }\n        },\n    })\n\n    return (\n        <div className={className}>\n            <div\n                {...getTrackProps({\n                    className: classNames(\n                        SliderVariantColor[variant],\n                        'bg-opacity-10',\n                        SliderTrackClassNames[size]\n                    ),\n                })}\n                data-testid=\"slider-track\"\n            >\n                <div\n                    {...segments[0].getSegmentProps({\n                        className: classNames('h-full rounded-lg bg-cyan'),\n                    })}\n                />\n\n                {handles.map(({ getHandleProps }) => {\n                    const handleProps = getHandleProps({\n                        className: classNames(\n                            SliderVariantColor[variant],\n                            SliderHandleClassNames[size],\n                            isDragging ? 'cursor-grabbing' : 'cursor-grab'\n                        ),\n                    })\n\n                    return <div {...handleProps} data-testid={`handle-${handleProps.key}`} />\n                })}\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/Slider/__snapshots__/Slider.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Slider should render correctly 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div>\n        <div\n          class=\"bg-cyan bg-opacity-10 h-1.5 rounded-lg\"\n          data-testid=\"slider-track\"\n          style=\"position: relative; user-select: none;\"\n        >\n          <div\n            class=\"h-full rounded-lg bg-cyan\"\n            style=\"position: absolute; left: 0%; width: 10%;\"\n          />\n          <div\n            aria-valuemax=\"100\"\n            aria-valuemin=\"0\"\n            aria-valuenow=\"10\"\n            class=\"bg-cyan w-3 h-3 rounded-lg cursor-grab\"\n            data-testid=\"handle-0\"\n            role=\"slider\"\n            style=\"position: absolute; top: 50%; left: 10%; z-index: 0; transform: translate(-50%, -50%);\"\n          />\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div>\n      <div\n        class=\"bg-cyan bg-opacity-10 h-1.5 rounded-lg\"\n        data-testid=\"slider-track\"\n        style=\"position: relative; user-select: none;\"\n      >\n        <div\n          class=\"h-full rounded-lg bg-cyan\"\n          style=\"position: absolute; left: 0%; width: 10%;\"\n        />\n        <div\n          aria-valuemax=\"100\"\n          aria-valuemin=\"0\"\n          aria-valuenow=\"10\"\n          class=\"bg-cyan w-3 h-3 rounded-lg cursor-grab\"\n          data-testid=\"handle-0\"\n          role=\"slider\"\n          style=\"position: absolute; top: 50%; left: 10%; z-index: 0; transform: translate(-50%, -50%);\"\n        />\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Slider/index.ts",
    "content": "export { default as Slider } from './Slider'\nexport type { SliderProps } from './Slider'\n"
  },
  {
    "path": "libs/design-system/src/lib/Step/Step.spec.tsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react'\nimport { useState } from 'react'\nimport { Step } from '.'\n\ndescribe('Steps', () => {\n    describe('when linear', () => {\n        it('should render correctly', () => {\n            const component = render(\n                <Step.Group currentStep={0}>\n                    <Step.List>\n                        <Step>Step 1</Step>\n                        <Step>Step 2</Step>\n                    </Step.List>\n                    <Step.Panels>\n                        <Step.Panel>Content 1</Step.Panel>\n                        <Step.Panel>Content 2</Step.Panel>\n                    </Step.Panels>\n                </Step.Group>\n            )\n\n            expect(screen.getByText('Step 1')).toBeInTheDocument()\n            expect(screen.queryByText('Content 1')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n\n        it('should not allow navigation', () => {\n            render(\n                <Step.Group currentStep={0}>\n                    <Step.List>\n                        <Step>Step 1</Step>\n                        <Step>Step 2</Step>\n                    </Step.List>\n                    <Step.Panels>\n                        <Step.Panel>Content 1</Step.Panel>\n                        <Step.Panel>Content 2</Step.Panel>\n                    </Step.Panels>\n                </Step.Group>\n            )\n\n            // Content 1 visible, Content 2 hidden\n            expect(screen.getByText('Content 1')).toBeInTheDocument()\n            expect(screen.queryByText('Content 2')).not.toBeInTheDocument()\n\n            fireEvent.click(screen.getByText('Step 2'))\n\n            // Content 1 visible, content 2 hidden\n            expect(screen.getByText('Content 1')).toBeInTheDocument()\n            expect(screen.queryByText('Content 2')).not.toBeInTheDocument()\n        })\n    })\n\n    describe('when non-linear', () => {\n        it('should render correctly when non-linear', () => {\n            const component = render(\n                <Step.Group linear={false} currentStep={0}>\n                    <Step.List>\n                        <Step>Step 1</Step>\n                        <Step>Step 2</Step>\n                    </Step.List>\n                    <Step.Panels>\n                        <Step.Panel>Content 1</Step.Panel>\n                        <Step.Panel>Content 2</Step.Panel>\n                    </Step.Panels>\n                </Step.Group>\n            )\n\n            expect(screen.getByText('Step 1')).toBeInTheDocument()\n            expect(screen.queryByText('Content 1')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n\n        it('should allow navigation', () => {\n            const Component = () => {\n                const [currentStep, setCurrentStep] = useState(0)\n                return (\n                    <Step.Group linear={false} currentStep={currentStep} onChange={setCurrentStep}>\n                        <Step.List>\n                            <Step>Step 1</Step>\n                            <Step>Step 2</Step>\n                        </Step.List>\n                        <Step.Panels>\n                            <Step.Panel>Content 1</Step.Panel>\n                            <Step.Panel>Content 2</Step.Panel>\n                        </Step.Panels>\n                    </Step.Group>\n                )\n            }\n\n            render(<Component />)\n\n            // Content 1 visible, Content 2 hidden\n            expect(screen.getByText('Content 1')).toBeInTheDocument()\n            expect(screen.queryByText('Content 2')).not.toBeInTheDocument()\n\n            fireEvent.click(screen.getByText('Step 2'))\n\n            // Content 2 visible, content 1 hidden\n            expect(screen.getByText('Content 2')).toBeInTheDocument()\n            expect(screen.queryByText('Content 1')).not.toBeInTheDocument()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Step/Step.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport { useState } from 'react'\nimport { Button } from '../Button'\n\nimport Step from './Step'\n\nexport default {\n    title: 'Components/Steps',\n} as Meta\n\nexport const Base: Story = () => {\n    const [currentStep, setCurrentStep] = useState(0)\n\n    return (\n        <>\n            <Step.Group currentStep={currentStep}>\n                <Step.List>\n                    <Step>Step 1</Step>\n                    <Step>Step 2</Step>\n                    <Step>Step 3</Step>\n                </Step.List>\n                <Step.Panels className=\"my-4 text-white\">\n                    <Step.Panel>Step 1 Content</Step.Panel>\n                    <Step.Panel>Step 2 Content</Step.Panel>\n                    <Step.Panel>Step 3 Content</Step.Panel>\n                </Step.Panels>\n            </Step.Group>\n            <div className=\"space-x-3\">\n                <Button\n                    variant=\"secondary\"\n                    disabled={currentStep <= 0}\n                    onClick={() => setCurrentStep((currentStep) => currentStep - 1)}\n                >\n                    Back\n                </Button>\n                <Button\n                    disabled={currentStep >= 2}\n                    onClick={() => setCurrentStep((currentStep) => currentStep + 1)}\n                >\n                    Next\n                </Button>\n            </div>\n        </>\n    )\n}\n\nexport const NonLinear: Story = () => {\n    const [currentStep, setCurrentStep] = useState(0)\n\n    return (\n        <Step.Group linear={false} currentStep={currentStep} onChange={setCurrentStep}>\n            <Step.List>\n                <Step>Step 1</Step>\n                <Step>Step 2</Step>\n                <Step>Step 3</Step>\n            </Step.List>\n            <Step.Panels className=\"my-4 text-white\">\n                <Step.Panel>Step 1 Content</Step.Panel>\n                <Step.Panel>Step 2 Content</Step.Panel>\n                <Step.Panel>Step 3 Content</Step.Panel>\n            </Step.Panels>\n        </Step.Group>\n    )\n}\n\nexport const StepStatuses: Story = () => {\n    return (\n        <Step.Group currentStep={2}>\n            <Step.List>\n                <Step status=\"complete\">Step 1</Step>\n                <Step status=\"error\">Step 2</Step>\n                <Step status=\"incomplete\">Step 3</Step>\n                <Step status=\"incomplete\">Step 4</Step>\n            </Step.List>\n            <Step.Panels className=\"my-4 text-white\">\n                <Step.Panel>Step 1 Content</Step.Panel>\n                <Step.Panel>Step 2 Content</Step.Panel>\n                <Step.Panel>Step 3 Content</Step.Panel>\n            </Step.Panels>\n        </Step.Group>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/Step/Step.tsx",
    "content": "import { Tab as HeadlessTab } from '@headlessui/react'\nimport classNames from 'classnames'\nimport type { PropsWithChildren } from 'react'\nimport { useContext, useReducer, useRef, useEffect } from 'react'\nimport React, { createContext } from 'react'\nimport { RiCheckFill, RiCloseFill } from 'react-icons/ri'\n\ntype ExtractProps<T> = T extends React.ComponentType<infer P> ? P : T\n\nconst StepGroupContext = createContext({\n    linear: true,\n    groupComplete: false,\n    selectedIndex: 0,\n    steps: [] as HTMLElement[],\n    registerStep: (element: HTMLElement) => {\n        console.warn('Step registration failed', element)\n    },\n})\n\nfunction Step({\n    status: statusProp,\n    className,\n    children,\n    ...rest\n}: { status?: 'complete' | 'incomplete' | 'error'; disabled?: boolean } & ExtractProps<\n    typeof HeadlessTab\n>): JSX.Element {\n    const { linear, selectedIndex, steps, registerStep, groupComplete } =\n        useContext(StepGroupContext)\n    const ref = useRef<HTMLElement>(null)\n\n    useEffect(() => {\n        if (ref.current) {\n            registerStep(ref.current)\n        }\n    }, [registerStep])\n\n    const index = ref.current ? steps.indexOf(ref.current) : -1\n\n    const status = statusProp ?? (linear && index < selectedIndex ? 'complete' : 'incomplete')\n\n    return (\n        <HeadlessTab\n            ref={ref}\n            as={linear ? 'div' : 'button'}\n            className={({ selected }) =>\n                classNames(\n                    className,\n                    'flex items-center rounded-md font-medium text-base focus:outline-none',\n                    !linear && 'focus:ring-2 focus:ring-gray-400 focus:ring-opacity-60',\n                    groupComplete\n                        ? 'text-teal'\n                        : selected\n                        ? 'text-cyan'\n                        : {\n                              complete: 'text-teal',\n                              incomplete: 'text-white',\n                              error: 'text-gray-100',\n                          }[status]\n                )\n            }\n            {...(rest as any)}\n        >\n            {({ selected }) => (\n                <>\n                    {index > 0 && <div className=\"mr-4 h-px w-6 bg-gray-500\" />}\n                    <span\n                        className={classNames(\n                            'flex items-center justify-center w-6 h-6 mr-3 text-sm rounded-md',\n                            groupComplete\n                                ? 'text-teal'\n                                : selected\n                                ? 'text-cyan bg-cyan bg-opacity-10'\n                                : {\n                                      complete: 'text-teal bg-teal bg-opacity-10',\n                                      incomplete: 'text-gray-100 bg-gray-700',\n                                      error: 'text-gray-800 bg-red',\n                                  }[status]\n                        )}\n                    >\n                        {groupComplete ? (\n                            <RiCheckFill className=\"w-5 h-5\" />\n                        ) : (\n                            {\n                                complete: <RiCheckFill className=\"w-5 h-5 text-teal\" />,\n                                incomplete: index + 1,\n                                error: <RiCloseFill className=\"w-5 h-5\" />,\n                            }[status]\n                        )}\n                    </span>\n                    {children}\n                </>\n            )}\n        </HeadlessTab>\n    )\n}\n\nfunction registerStepReducer(state: HTMLElement[], stepElement: HTMLElement) {\n    if (!state.includes(stepElement)) {\n        return [...state, stepElement]\n    }\n\n    return state\n}\n\nfunction Group({\n    linear = true,\n    complete = false,\n    currentStep,\n    children,\n    onChange,\n    ...rest\n}: { currentStep: number; linear?: boolean; complete?: boolean } & PropsWithChildren<\n    Omit<ExtractProps<typeof HeadlessTab.Group>, 'selectedIndex' | 'children'>\n>): JSX.Element {\n    const [steps, registerStep] = useReducer(registerStepReducer, [])\n\n    return (\n        <HeadlessTab.Group\n            selectedIndex={currentStep}\n            onChange={(...args) => {\n                if (!linear) onChange(...args)\n            }}\n            {...rest}\n        >\n            {({ selectedIndex }) => (\n                <StepGroupContext.Provider\n                    value={{\n                        linear,\n                        groupComplete: complete,\n                        selectedIndex,\n                        steps,\n                        registerStep,\n                    }}\n                >\n                    {children}\n                </StepGroupContext.Provider>\n            )}\n        </HeadlessTab.Group>\n    )\n}\n\nfunction List({ className, ...rest }: ExtractProps<typeof HeadlessTab.List>): JSX.Element {\n    return (\n        <HeadlessTab.List\n            className={classNames(className, 'inline-flex items-center space-x-4')}\n            {...rest}\n        />\n    )\n}\n\nfunction Panels({ className, ...rest }: ExtractProps<typeof HeadlessTab.Panels>): JSX.Element {\n    return <HeadlessTab.Panels className={classNames(className)} {...rest} />\n}\n\nfunction Panel({ className, ...rest }: ExtractProps<typeof HeadlessTab.Panel>): JSX.Element {\n    return <HeadlessTab.Panel className={classNames(className, 'focus:outline-none')} {...rest} />\n}\n\nexport default Object.assign(Step, { Group, List, Panels, Panel })\n"
  },
  {
    "path": "libs/design-system/src/lib/Step/__snapshots__/Step.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Steps when linear should render correctly 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        aria-orientation=\"horizontal\"\n        class=\"inline-flex items-center space-x-4\"\n        role=\"tablist\"\n      >\n        <div\n          aria-controls=\"headlessui-tabs-panel-:r2:\"\n          aria-selected=\"true\"\n          class=\"flex items-center rounded-md font-medium text-base focus:outline-none text-cyan\"\n          data-headlessui-state=\"selected\"\n          id=\"headlessui-tabs-tab-:r0:\"\n          role=\"tab\"\n          tabindex=\"0\"\n        >\n          <span\n            class=\"flex items-center justify-center w-6 h-6 mr-3 text-sm rounded-md text-cyan bg-cyan bg-opacity-10\"\n          >\n            1\n          </span>\n          Step 1\n        </div>\n        <div\n          aria-controls=\"headlessui-tabs-panel-:r3:\"\n          aria-selected=\"false\"\n          class=\"flex items-center rounded-md font-medium text-base focus:outline-none text-white\"\n          data-headlessui-state=\"\"\n          id=\"headlessui-tabs-tab-:r1:\"\n          role=\"tab\"\n          tabindex=\"-1\"\n        >\n          <div\n            class=\"mr-4 h-px w-6 bg-gray-500\"\n          />\n          <span\n            class=\"flex items-center justify-center w-6 h-6 mr-3 text-sm rounded-md text-gray-100 bg-gray-700\"\n          >\n            2\n          </span>\n          Step 2\n        </div>\n      </div>\n      <div\n        class=\"\"\n      >\n        <div\n          aria-labelledby=\"headlessui-tabs-tab-:r0:\"\n          class=\"focus:outline-none\"\n          data-headlessui-state=\"selected\"\n          id=\"headlessui-tabs-panel-:r2:\"\n          role=\"tabpanel\"\n          tabindex=\"0\"\n        >\n          Content 1\n        </div>\n        <span\n          aria-labelledby=\"headlessui-tabs-tab-:r1:\"\n          id=\"headlessui-tabs-panel-:r3:\"\n          role=\"tabpanel\"\n          style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px;\"\n          tabindex=\"-1\"\n        />\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      aria-orientation=\"horizontal\"\n      class=\"inline-flex items-center space-x-4\"\n      role=\"tablist\"\n    >\n      <div\n        aria-controls=\"headlessui-tabs-panel-:r2:\"\n        aria-selected=\"true\"\n        class=\"flex items-center rounded-md font-medium text-base focus:outline-none text-cyan\"\n        data-headlessui-state=\"selected\"\n        id=\"headlessui-tabs-tab-:r0:\"\n        role=\"tab\"\n        tabindex=\"0\"\n      >\n        <span\n          class=\"flex items-center justify-center w-6 h-6 mr-3 text-sm rounded-md text-cyan bg-cyan bg-opacity-10\"\n        >\n          1\n        </span>\n        Step 1\n      </div>\n      <div\n        aria-controls=\"headlessui-tabs-panel-:r3:\"\n        aria-selected=\"false\"\n        class=\"flex items-center rounded-md font-medium text-base focus:outline-none text-white\"\n        data-headlessui-state=\"\"\n        id=\"headlessui-tabs-tab-:r1:\"\n        role=\"tab\"\n        tabindex=\"-1\"\n      >\n        <div\n          class=\"mr-4 h-px w-6 bg-gray-500\"\n        />\n        <span\n          class=\"flex items-center justify-center w-6 h-6 mr-3 text-sm rounded-md text-gray-100 bg-gray-700\"\n        >\n          2\n        </span>\n        Step 2\n      </div>\n    </div>\n    <div\n      class=\"\"\n    >\n      <div\n        aria-labelledby=\"headlessui-tabs-tab-:r0:\"\n        class=\"focus:outline-none\"\n        data-headlessui-state=\"selected\"\n        id=\"headlessui-tabs-panel-:r2:\"\n        role=\"tabpanel\"\n        tabindex=\"0\"\n      >\n        Content 1\n      </div>\n      <span\n        aria-labelledby=\"headlessui-tabs-tab-:r1:\"\n        id=\"headlessui-tabs-panel-:r3:\"\n        role=\"tabpanel\"\n        style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px;\"\n        tabindex=\"-1\"\n      />\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Steps when non-linear should render correctly when non-linear 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        aria-orientation=\"horizontal\"\n        class=\"inline-flex items-center space-x-4\"\n        role=\"tablist\"\n      >\n        <button\n          aria-controls=\"headlessui-tabs-panel-:ra:\"\n          aria-selected=\"true\"\n          class=\"flex items-center rounded-md font-medium text-base focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-opacity-60 text-cyan\"\n          data-headlessui-state=\"selected\"\n          id=\"headlessui-tabs-tab-:r8:\"\n          role=\"tab\"\n          tabindex=\"0\"\n          type=\"button\"\n        >\n          <span\n            class=\"flex items-center justify-center w-6 h-6 mr-3 text-sm rounded-md text-cyan bg-cyan bg-opacity-10\"\n          >\n            1\n          </span>\n          Step 1\n        </button>\n        <button\n          aria-controls=\"headlessui-tabs-panel-:rb:\"\n          aria-selected=\"false\"\n          class=\"flex items-center rounded-md font-medium text-base focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-opacity-60 text-white\"\n          data-headlessui-state=\"\"\n          id=\"headlessui-tabs-tab-:r9:\"\n          role=\"tab\"\n          tabindex=\"-1\"\n          type=\"button\"\n        >\n          <div\n            class=\"mr-4 h-px w-6 bg-gray-500\"\n          />\n          <span\n            class=\"flex items-center justify-center w-6 h-6 mr-3 text-sm rounded-md text-gray-100 bg-gray-700\"\n          >\n            2\n          </span>\n          Step 2\n        </button>\n      </div>\n      <div\n        class=\"\"\n      >\n        <div\n          aria-labelledby=\"headlessui-tabs-tab-:r8:\"\n          class=\"focus:outline-none\"\n          data-headlessui-state=\"selected\"\n          id=\"headlessui-tabs-panel-:ra:\"\n          role=\"tabpanel\"\n          tabindex=\"0\"\n        >\n          Content 1\n        </div>\n        <span\n          aria-labelledby=\"headlessui-tabs-tab-:r9:\"\n          id=\"headlessui-tabs-panel-:rb:\"\n          role=\"tabpanel\"\n          style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px;\"\n          tabindex=\"-1\"\n        />\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      aria-orientation=\"horizontal\"\n      class=\"inline-flex items-center space-x-4\"\n      role=\"tablist\"\n    >\n      <button\n        aria-controls=\"headlessui-tabs-panel-:ra:\"\n        aria-selected=\"true\"\n        class=\"flex items-center rounded-md font-medium text-base focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-opacity-60 text-cyan\"\n        data-headlessui-state=\"selected\"\n        id=\"headlessui-tabs-tab-:r8:\"\n        role=\"tab\"\n        tabindex=\"0\"\n        type=\"button\"\n      >\n        <span\n          class=\"flex items-center justify-center w-6 h-6 mr-3 text-sm rounded-md text-cyan bg-cyan bg-opacity-10\"\n        >\n          1\n        </span>\n        Step 1\n      </button>\n      <button\n        aria-controls=\"headlessui-tabs-panel-:rb:\"\n        aria-selected=\"false\"\n        class=\"flex items-center rounded-md font-medium text-base focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-opacity-60 text-white\"\n        data-headlessui-state=\"\"\n        id=\"headlessui-tabs-tab-:r9:\"\n        role=\"tab\"\n        tabindex=\"-1\"\n        type=\"button\"\n      >\n        <div\n          class=\"mr-4 h-px w-6 bg-gray-500\"\n        />\n        <span\n          class=\"flex items-center justify-center w-6 h-6 mr-3 text-sm rounded-md text-gray-100 bg-gray-700\"\n        >\n          2\n        </span>\n        Step 2\n      </button>\n    </div>\n    <div\n      class=\"\"\n    >\n      <div\n        aria-labelledby=\"headlessui-tabs-tab-:r8:\"\n        class=\"focus:outline-none\"\n        data-headlessui-state=\"selected\"\n        id=\"headlessui-tabs-panel-:ra:\"\n        role=\"tabpanel\"\n        tabindex=\"0\"\n      >\n        Content 1\n      </div>\n      <span\n        aria-labelledby=\"headlessui-tabs-tab-:r9:\"\n        id=\"headlessui-tabs-panel-:rb:\"\n        role=\"tabpanel\"\n        style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px;\"\n        tabindex=\"-1\"\n      />\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Step/index.ts",
    "content": "export { default as Step } from './Step'\n"
  },
  {
    "path": "libs/design-system/src/lib/Tab/Tab.spec.tsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react'\nimport { Tab } from './'\n\n// Not tested too thoroughly, most logic is covered by Headless UI base\ndescribe('Tab', () => {\n    it('should render correctly', () => {\n        const component = render(\n            <Tab.Group>\n                <Tab.List>\n                    <Tab>Tab 1</Tab>\n                    <Tab>Tab 2</Tab>\n                </Tab.List>\n                <Tab.Panels>\n                    <Tab.Panel>Content 1</Tab.Panel>\n                    <Tab.Panel>Content 2</Tab.Panel>\n                </Tab.Panels>\n            </Tab.Group>\n        )\n\n        expect(screen.getByText('Tab 1')).toBeInTheDocument()\n        expect(screen.queryByText('Content 1')).toBeInTheDocument()\n        expect(component).toMatchSnapshot()\n    })\n\n    describe('when toggled', () => {\n        it('should render the correct panel', () => {\n            render(\n                <Tab.Group>\n                    <Tab.List>\n                        <Tab>Tab 1</Tab>\n                        <Tab>Tab 2</Tab>\n                    </Tab.List>\n                    <Tab.Panels>\n                        <Tab.Panel>Content 1</Tab.Panel>\n                        <Tab.Panel>Content 2</Tab.Panel>\n                    </Tab.Panels>\n                </Tab.Group>\n            )\n\n            // Content 1 visible, Content 2 hidden\n            expect(screen.getByText('Content 1')).toBeInTheDocument()\n            expect(screen.queryByText('Content 2')).not.toBeInTheDocument()\n\n            fireEvent.click(screen.getByText('Tab 2'))\n\n            // Content 2 visible, content 1 hidden\n            expect(screen.getByText('Content 2')).toBeInTheDocument()\n            expect(screen.queryByText('Content 1')).not.toBeInTheDocument()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Tab/Tab.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport { RiLineChartLine, RiBarChartFill, RiPieChartFill } from 'react-icons/ri'\n\nimport Tab from './Tab'\n\nexport default {\n    title: 'Components/Tabs',\n    argTypes: {},\n    args: {},\n} as Meta\n\nexport const Base: Story = (_args) => (\n    <Tab.Group>\n        <Tab.List>\n            <Tab>Tab 1</Tab>\n            <Tab>Tab 2</Tab>\n            <Tab>Tab 3</Tab>\n        </Tab.List>\n    </Tab.Group>\n)\n\nexport const WithIcons: Story = (_args) => (\n    <Tab.Group>\n        <Tab.List>\n            <Tab icon={<RiLineChartLine />}>Line</Tab>\n            <Tab icon={<RiBarChartFill />}>Bar</Tab>\n            <Tab icon={<RiPieChartFill />}>Pie</Tab>\n        </Tab.List>\n    </Tab.Group>\n)\n\nexport const WithPanels: Story = (_args) => (\n    <Tab.Group>\n        <Tab.List>\n            <Tab>Tab 1</Tab>\n            <Tab>Tab 2</Tab>\n            <Tab>Tab 3</Tab>\n        </Tab.List>\n        <Tab.Panels>\n            <Tab.Panel>Content 1</Tab.Panel>\n            <Tab.Panel>Content 2</Tab.Panel>\n            <Tab.Panel>Content 3</Tab.Panel>\n        </Tab.Panels>\n    </Tab.Group>\n)\n"
  },
  {
    "path": "libs/design-system/src/lib/Tab/Tab.tsx",
    "content": "import type { PropsWithChildren } from 'react'\nimport { Tab as HeadlessTab } from '@headlessui/react'\nimport classNames from 'classnames'\n\ntype ExtractProps<T> = T extends React.ComponentType<infer P> ? P : T\n\nfunction Tab({\n    className,\n    children,\n    icon,\n    ...rest\n}: { icon?: React.ReactNode } & PropsWithChildren<ExtractProps<typeof HeadlessTab>>): JSX.Element {\n    return (\n        <HeadlessTab\n            className={({ selected }) =>\n                classNames(\n                    className,\n                    'grow flex items-center justify-center text-base py-1 px-4 rounded focus:outline-none focus:ring-2 focus:ring-opacity-60',\n                    'focus:ring-gray-400',\n                    selected\n                        ? 'bg-gray-400 hover:bg-gray-300 text-white shadow'\n                        : 'hover:bg-gray-600 text-gray-100'\n                )\n            }\n            {...(rest as any)}\n        >\n            {icon && <span className={classNames('text-lg', children && 'mr-2')}>{icon}</span>}\n            {children}\n        </HeadlessTab>\n    )\n}\n\nfunction Group(props: ExtractProps<typeof HeadlessTab.Group>): JSX.Element {\n    return <HeadlessTab.Group {...props} />\n}\n\nfunction List({ className, ...rest }: ExtractProps<typeof HeadlessTab.List>): JSX.Element {\n    return (\n        <HeadlessTab.List\n            className={classNames(\n                className,\n                'inline-flex items-center space-x-1 p-1 rounded-lg bg-gray-700 text-white'\n            )}\n            {...rest}\n        />\n    )\n}\n\nfunction Panels({ className, ...rest }: ExtractProps<typeof HeadlessTab.Panels>): JSX.Element {\n    return <HeadlessTab.Panels className={classNames(className)} {...rest} />\n}\n\nfunction Panel({ className, ...rest }: ExtractProps<typeof HeadlessTab.Panel>): JSX.Element {\n    return <HeadlessTab.Panel className={classNames(className, 'focus:outline-none')} {...rest} />\n}\n\nexport default Object.assign(Tab, { Group, List, Panels, Panel })\n"
  },
  {
    "path": "libs/design-system/src/lib/Tab/__snapshots__/Tab.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Tab should render correctly 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        aria-orientation=\"horizontal\"\n        class=\"inline-flex items-center space-x-1 p-1 rounded-lg bg-gray-700 text-white\"\n        role=\"tablist\"\n      >\n        <button\n          aria-controls=\"headlessui-tabs-panel-:r2:\"\n          aria-selected=\"true\"\n          class=\"grow flex items-center justify-center text-base py-1 px-4 rounded focus:outline-none focus:ring-2 focus:ring-opacity-60 focus:ring-gray-400 bg-gray-400 hover:bg-gray-300 text-white shadow\"\n          data-headlessui-state=\"selected\"\n          id=\"headlessui-tabs-tab-:r0:\"\n          role=\"tab\"\n          tabindex=\"0\"\n          type=\"button\"\n        >\n          Tab 1\n        </button>\n        <button\n          aria-controls=\"headlessui-tabs-panel-:r3:\"\n          aria-selected=\"false\"\n          class=\"grow flex items-center justify-center text-base py-1 px-4 rounded focus:outline-none focus:ring-2 focus:ring-opacity-60 focus:ring-gray-400 hover:bg-gray-600 text-gray-100\"\n          data-headlessui-state=\"\"\n          id=\"headlessui-tabs-tab-:r1:\"\n          role=\"tab\"\n          tabindex=\"-1\"\n          type=\"button\"\n        >\n          Tab 2\n        </button>\n      </div>\n      <div\n        class=\"\"\n      >\n        <div\n          aria-labelledby=\"headlessui-tabs-tab-:r0:\"\n          class=\"focus:outline-none\"\n          data-headlessui-state=\"selected\"\n          id=\"headlessui-tabs-panel-:r2:\"\n          role=\"tabpanel\"\n          tabindex=\"0\"\n        >\n          Content 1\n        </div>\n        <span\n          aria-labelledby=\"headlessui-tabs-tab-:r1:\"\n          id=\"headlessui-tabs-panel-:r3:\"\n          role=\"tabpanel\"\n          style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px;\"\n          tabindex=\"-1\"\n        />\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      aria-orientation=\"horizontal\"\n      class=\"inline-flex items-center space-x-1 p-1 rounded-lg bg-gray-700 text-white\"\n      role=\"tablist\"\n    >\n      <button\n        aria-controls=\"headlessui-tabs-panel-:r2:\"\n        aria-selected=\"true\"\n        class=\"grow flex items-center justify-center text-base py-1 px-4 rounded focus:outline-none focus:ring-2 focus:ring-opacity-60 focus:ring-gray-400 bg-gray-400 hover:bg-gray-300 text-white shadow\"\n        data-headlessui-state=\"selected\"\n        id=\"headlessui-tabs-tab-:r0:\"\n        role=\"tab\"\n        tabindex=\"0\"\n        type=\"button\"\n      >\n        Tab 1\n      </button>\n      <button\n        aria-controls=\"headlessui-tabs-panel-:r3:\"\n        aria-selected=\"false\"\n        class=\"grow flex items-center justify-center text-base py-1 px-4 rounded focus:outline-none focus:ring-2 focus:ring-opacity-60 focus:ring-gray-400 hover:bg-gray-600 text-gray-100\"\n        data-headlessui-state=\"\"\n        id=\"headlessui-tabs-tab-:r1:\"\n        role=\"tab\"\n        tabindex=\"-1\"\n        type=\"button\"\n      >\n        Tab 2\n      </button>\n    </div>\n    <div\n      class=\"\"\n    >\n      <div\n        aria-labelledby=\"headlessui-tabs-tab-:r0:\"\n        class=\"focus:outline-none\"\n        data-headlessui-state=\"selected\"\n        id=\"headlessui-tabs-panel-:r2:\"\n        role=\"tabpanel\"\n        tabindex=\"0\"\n      >\n        Content 1\n      </div>\n      <span\n        aria-labelledby=\"headlessui-tabs-tab-:r1:\"\n        id=\"headlessui-tabs-panel-:r3:\"\n        role=\"tabpanel\"\n        style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px;\"\n        tabindex=\"-1\"\n      />\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Tab/index.ts",
    "content": "export { default as Tab } from './Tab'\n"
  },
  {
    "path": "libs/design-system/src/lib/Takeover/Takeover.spec.tsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport { Takeover } from './'\n\ndescribe('Takeover', () => {\n    describe('when rendered with `open` prop true', () => {\n        it('should display the contents', () => {\n            const onToggleMock = jest.fn()\n            const component = render(\n                <Takeover open={true} onClose={onToggleMock}>\n                    Takeover content\n                </Takeover>\n            )\n\n            expect(screen.getByText('Takeover content')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Takeover/Takeover.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { TakeoverProps } from './Takeover'\nimport * as React from 'react'\n\nimport Takeover from './Takeover'\nimport { Button } from '../../'\n\nexport default {\n    title: 'Components/Takeover',\n    component: Takeover,\n    parameters: {\n        controls: { include: ['as'] },\n    },\n} as Meta\n\nexport const Base: Story<TakeoverProps> = (args) => {\n    const [isOpen, setIsOpen] = React.useState(false)\n\n    return (\n        <div className=\"h-48 flex items-center justify-center\">\n            <Button onClick={() => setIsOpen(true)}>Open Takeover</Button>\n            <Takeover {...args} open={isOpen} onClose={() => setIsOpen(false)}>\n                <div className=\"flex justify-center w-full pt-8 text-white\">Press ESC to close</div>\n            </Takeover>\n        </div>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/Takeover/Takeover.tsx",
    "content": "import type { RefObject } from 'react'\nimport { Fragment } from 'react'\nimport { Dialog, Transition } from '@headlessui/react'\n\ntype ExtractProps<T> = T extends React.ComponentType<infer P> ? P : T\n\nexport type TakeoverProps = ExtractProps<typeof Dialog> & {\n    scrollContainerRef?: RefObject<HTMLDivElement>\n}\n\nexport default function Takeover({\n    open,\n    onClose,\n    children,\n    scrollContainerRef,\n    ...rest\n}: TakeoverProps) {\n    return (\n        <Transition.Root\n            show={open}\n            as={Fragment}\n            enter=\"ease-in duration-100\"\n            enterFrom=\"opacity-0\"\n            enterTo=\"opacity-100\"\n            leave=\"ease-in duration-100\"\n            leaveFrom=\"opacity-100\"\n            leaveTo=\"opacity-0\"\n        >\n            <Dialog onClose={onClose} {...rest}>\n                <div\n                    className=\"z-50 fixed inset-0 bg-black custom-gray-scroll\"\n                    ref={scrollContainerRef}\n                >\n                    <Transition.Child\n                        as={Fragment}\n                        enter=\"ease-out duration-300\"\n                        enterFrom=\"sm:scale-95\"\n                        enterTo=\"sm:scale-100\"\n                        leave=\"ease-in duration-200\"\n                        leaveFrom=\"sm:scale-100\"\n                        leaveTo=\"sm:scale-95\"\n                    >\n                        <Dialog.Panel className=\"inset-0 overflow-hidden\">{children}</Dialog.Panel>\n                    </Transition.Child>\n                </div>\n            </Dialog>\n        </Transition.Root>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/Takeover/__snapshots__/Takeover.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Takeover when rendered with \\`open\\` prop true should display the contents 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px; display: none;\"\n      />\n    </div>\n    <div\n      id=\"headlessui-portal-root\"\n    >\n      <div\n        data-headlessui-portal=\"\"\n      >\n        <button\n          aria-hidden=\"true\"\n          style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px;\"\n          type=\"button\"\n        />\n        <div>\n          <div\n            aria-modal=\"true\"\n            data-headlessui-state=\"open\"\n            id=\"headlessui-dialog-:r0:\"\n            role=\"dialog\"\n          >\n            <div\n              class=\"z-50 fixed inset-0 bg-black custom-gray-scroll\"\n            >\n              <div\n                class=\"inset-0 overflow-hidden\"\n                data-headlessui-state=\"open\"\n                id=\"headlessui-dialog-panel-:r1:\"\n              >\n                Takeover content\n              </div>\n            </div>\n          </div>\n        </div>\n        <button\n          aria-hidden=\"true\"\n          style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px;\"\n          type=\"button\"\n        />\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      style=\"position: fixed; top: 1px; left: 1px; width: 1px; height: 0px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; border-width: 0px; display: none;\"\n    />\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Takeover/index.ts",
    "content": "export { default as Takeover } from './Takeover'\n"
  },
  {
    "path": "libs/design-system/src/lib/Toast/Toast.spec.tsx",
    "content": "import type { ToastVariant } from '.'\nimport { render, screen } from '@testing-library/react'\nimport { Toast } from '.'\n\ndescribe('Toast', () => {\n    describe('when rendered with text', () => {\n        it('should display the text', () => {\n            render(<Toast>Hello, World!</Toast>)\n\n            expect(screen.getByText('Hello, World!')).toBeInTheDocument()\n        })\n    })\n\n    const variants: ToastVariant[] = ['info', 'success', 'error']\n\n    test.each(variants)('should render properly as the %s variant', (variant) => {\n        const component = render(<Toast variant={variant}>Hello, World!</Toast>)\n        expect(component).toMatchSnapshot()\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Toast/Toast.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { ToastProps } from './Toast'\n\nimport Toast from './Toast'\n\nexport default {\n    title: 'Components/Toast',\n    component: Toast,\n    parameters: { controls: { exclude: ['className', 'onClick'] } },\n    argTypes: {\n        children: {\n            control: 'text',\n            description: 'Toast content',\n        },\n        variant: {\n            control: { type: 'select' },\n            description: 'Toast variant',\n        },\n    },\n    args: {\n        children: 'Toast message',\n    },\n} as Meta\n\nconst Template: Story<ToastProps> = (args) => <Toast {...args} />\n\nexport const Base = Template.bind({})\n\nexport const Info = Template.bind({})\nInfo.args = { variant: 'info' }\n\nexport const Success = Template.bind({})\nSuccess.args = { variant: 'success' }\n\nexport const Error = Template.bind({})\nError.args = { variant: 'error' }\n"
  },
  {
    "path": "libs/design-system/src/lib/Toast/Toast.tsx",
    "content": "import type { ReactNode, HTMLAttributes } from 'react'\nimport classNames from 'classnames'\nimport {\n    RiInformationLine as IconInfo,\n    RiCheckboxCircleLine as IconSuccess,\n    RiErrorWarningLine as IconError,\n} from 'react-icons/ri'\n\nconst ToastVariants = Object.freeze({\n    info: {\n        className: 'text-white bg-gray-500',\n        icon: IconInfo,\n        role: 'status',\n    },\n    success: {\n        className: 'text-black bg-teal',\n        icon: IconSuccess,\n        role: 'status',\n    },\n    error: {\n        className: 'text-black bg-red',\n        icon: IconError,\n        role: 'alert',\n    },\n})\n\nexport type ToastVariant = keyof typeof ToastVariants\n\nexport interface ToastProps extends HTMLAttributes<HTMLDivElement> {\n    variant?: ToastVariant\n\n    onClick?: () => void\n\n    className?: string\n\n    children?: ReactNode\n}\n\nfunction Toast({ variant = 'info', children, className, ...rest }: ToastProps): JSX.Element {\n    const combinedClassName = classNames(\n        className,\n        ToastVariants[variant].className,\n        'flex items-center py-2 px-4 rounded shadow-md'\n    )\n\n    const Icon = ToastVariants[variant].icon\n\n    return (\n        <div className={combinedClassName} {...rest}>\n            <Icon className=\"shrink-0 mr-2 text-xl\" />\n            <output className=\"text-base\" role={ToastVariants[variant].role}>\n                {children}\n            </output>\n        </div>\n    )\n}\n\nexport default Toast\n"
  },
  {
    "path": "libs/design-system/src/lib/Toast/__snapshots__/Toast.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Toast should render properly as the error variant 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"text-black bg-red flex items-center py-2 px-4 rounded shadow-md\"\n      >\n        <svg\n          class=\"shrink-0 mr-2 text-xl\"\n          fill=\"currentColor\"\n          height=\"1em\"\n          stroke=\"currentColor\"\n          stroke-width=\"0\"\n          viewBox=\"0 0 24 24\"\n          width=\"1em\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n        >\n          <g>\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm-1-5h2v2h-2v-2zm0-8h2v6h-2V7z\"\n            />\n          </g>\n        </svg>\n        <output\n          class=\"text-base\"\n          role=\"alert\"\n        >\n          Hello, World!\n        </output>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"text-black bg-red flex items-center py-2 px-4 rounded shadow-md\"\n    >\n      <svg\n        class=\"shrink-0 mr-2 text-xl\"\n        fill=\"currentColor\"\n        height=\"1em\"\n        stroke=\"currentColor\"\n        stroke-width=\"0\"\n        viewBox=\"0 0 24 24\"\n        width=\"1em\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <g>\n          <path\n            d=\"M0 0h24v24H0z\"\n            fill=\"none\"\n          />\n          <path\n            d=\"M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm-1-5h2v2h-2v-2zm0-8h2v6h-2V7z\"\n          />\n        </g>\n      </svg>\n      <output\n        class=\"text-base\"\n        role=\"alert\"\n      >\n        Hello, World!\n      </output>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Toast should render properly as the info variant 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"text-white bg-gray-500 flex items-center py-2 px-4 rounded shadow-md\"\n      >\n        <svg\n          class=\"shrink-0 mr-2 text-xl\"\n          fill=\"currentColor\"\n          height=\"1em\"\n          stroke=\"currentColor\"\n          stroke-width=\"0\"\n          viewBox=\"0 0 24 24\"\n          width=\"1em\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n        >\n          <g>\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM11 7h2v2h-2V7zm0 4h2v6h-2v-6z\"\n            />\n          </g>\n        </svg>\n        <output\n          class=\"text-base\"\n          role=\"status\"\n        >\n          Hello, World!\n        </output>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"text-white bg-gray-500 flex items-center py-2 px-4 rounded shadow-md\"\n    >\n      <svg\n        class=\"shrink-0 mr-2 text-xl\"\n        fill=\"currentColor\"\n        height=\"1em\"\n        stroke=\"currentColor\"\n        stroke-width=\"0\"\n        viewBox=\"0 0 24 24\"\n        width=\"1em\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <g>\n          <path\n            d=\"M0 0h24v24H0z\"\n            fill=\"none\"\n          />\n          <path\n            d=\"M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM11 7h2v2h-2V7zm0 4h2v6h-2v-6z\"\n          />\n        </g>\n      </svg>\n      <output\n        class=\"text-base\"\n        role=\"status\"\n      >\n        Hello, World!\n      </output>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Toast should render properly as the success variant 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"text-black bg-teal flex items-center py-2 px-4 rounded shadow-md\"\n      >\n        <svg\n          class=\"shrink-0 mr-2 text-xl\"\n          fill=\"currentColor\"\n          height=\"1em\"\n          stroke=\"currentColor\"\n          stroke-width=\"0\"\n          viewBox=\"0 0 24 24\"\n          width=\"1em\"\n          xmlns=\"http://www.w3.org/2000/svg\"\n        >\n          <g>\n            <path\n              d=\"M0 0h24v24H0z\"\n              fill=\"none\"\n            />\n            <path\n              d=\"M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm-.997-4L6.76 11.757l1.414-1.414 2.829 2.829 5.656-5.657 1.415 1.414L11.003 16z\"\n            />\n          </g>\n        </svg>\n        <output\n          class=\"text-base\"\n          role=\"status\"\n        >\n          Hello, World!\n        </output>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"text-black bg-teal flex items-center py-2 px-4 rounded shadow-md\"\n    >\n      <svg\n        class=\"shrink-0 mr-2 text-xl\"\n        fill=\"currentColor\"\n        height=\"1em\"\n        stroke=\"currentColor\"\n        stroke-width=\"0\"\n        viewBox=\"0 0 24 24\"\n        width=\"1em\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <g>\n          <path\n            d=\"M0 0h24v24H0z\"\n            fill=\"none\"\n          />\n          <path\n            d=\"M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm-.997-4L6.76 11.757l1.414-1.414 2.829 2.829 5.656-5.657 1.415 1.414L11.003 16z\"\n          />\n        </g>\n      </svg>\n      <output\n        class=\"text-base\"\n        role=\"status\"\n      >\n        Hello, World!\n      </output>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Toast/index.ts",
    "content": "export { default as Toast } from './Toast'\nexport type { ToastProps, ToastVariant } from './Toast'\n"
  },
  {
    "path": "libs/design-system/src/lib/Toggle/Toggle.spec.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-empty-function */\nimport { fireEvent, render, screen } from '@testing-library/react'\nimport { Toggle } from '.'\n\ndescribe('Toggle', () => {\n    describe('when rendered with a screenreader label', () => {\n        it('should display the label to screenreaders', () => {\n            const component = render(<Toggle onChange={() => {}} screenReaderLabel=\"SRLabel\" />)\n\n            expect(screen.getByText('SRLabel')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when pressed', () => {\n        it('should call the `onClick` callback', () => {\n            const onClickMock = jest.fn()\n            render(<Toggle screenReaderLabel=\"SRLabel\" onChange={onClickMock} />)\n\n            fireEvent.click(screen.getByRole('switch'))\n            expect(onClickMock).toHaveBeenCalledTimes(1)\n        })\n    })\n\n    describe('when toggle is checked', () => {\n        it('should have the toggle disc moved to the right', () => {\n            const component = render(\n                <Toggle screenReaderLabel=\"SRLabel\" onChange={() => {}} checked />\n            )\n\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when passing a className', () => {\n        it('should merge them and show all classNames', () => {\n            render(\n                <Toggle\n                    screenReaderLabel=\"SRLabel\"\n                    onChange={() => {}}\n                    className=\"test-classname\"\n                />\n            )\n\n            expect(screen.getByRole('switch')).toHaveClass('test-classname')\n            // className cursor-pointer exists on the toggle by default\n            expect(screen.getByRole('switch')).toHaveClass('cursor-pointer')\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Toggle/Toggle.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { ToggleProps } from './Toggle'\nimport { useState } from 'react'\n\nimport Toggle from './Toggle'\n\nexport default {\n    title: 'Components/Toggle',\n    component: Toggle,\n    parameters: { controls: { exclude: ['className', 'onClick'] } },\n    args: {\n        screenReaderLabel: 'Toggle something',\n    },\n} as Meta\n\nconst Template: Story<ToggleProps> = (args) => {\n    const [enabled, setEnabled] = useState(args.checked)\n\n    return <Toggle {...args} checked={enabled} onChange={(checked) => setEnabled(checked)} />\n}\n\nexport const Base = Template.bind({})\n"
  },
  {
    "path": "libs/design-system/src/lib/Toggle/Toggle.tsx",
    "content": "import classNames from 'classnames'\nimport { Switch } from '@headlessui/react'\n\nconst ToggleSizes = Object.freeze({\n    small: {\n        outer: 'h-6 w-11',\n        inner: 'h-4 w-4',\n        innerChecked: 'translate-x-5',\n    },\n    medium: {\n        outer: 'h-8 w-14',\n        inner: 'h-6 w-6',\n        innerChecked: 'translate-x-6',\n    },\n})\n\nexport type ToggleSize = keyof typeof ToggleSizes\n\nexport interface ToggleProps {\n    onChange(checked: boolean): void\n    checked?: boolean\n    size?: ToggleSize\n    screenReaderLabel?: string\n    className?: string\n    disabled?: boolean\n}\n\nexport default function Toggle({\n    checked = false,\n    disabled = false,\n    size = 'medium',\n    className,\n    screenReaderLabel,\n    ...rest\n}: ToggleProps) {\n    return (\n        <Switch\n            checked={checked}\n            disabled={disabled}\n            className={classNames(\n                className,\n                checked ? 'bg-cyan focus:ring-cyan' : 'bg-gray-600 focus:ring-gray',\n                disabled && 'bg-gray-500 cursor-not-allowed',\n                'relative inline-flex shrink-0 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-2 focus:outline-none focus:ring-opacity-60 p-0.5',\n                ToggleSizes[size].outer\n            )}\n            {...rest}\n        >\n            {screenReaderLabel && <span className=\"sr-only\">{screenReaderLabel}</span>}\n            <span\n                className={classNames(\n                    checked\n                        ? [ToggleSizes[size].innerChecked, 'bg-white']\n                        : 'translate-x-0 bg-gray-200',\n                    disabled && 'bg-gray-300',\n                    'pointer-events-none inline-block rounded-full shadow transform ring-0 transition ease-in-out duration-200',\n                    ToggleSizes[size].inner\n                )}\n            />\n        </Switch>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/Toggle/__snapshots__/Toggle.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Toggle when rendered with a screenreader label should display the label to screenreaders 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <button\n        aria-checked=\"false\"\n        class=\"bg-gray-600 focus:ring-gray relative inline-flex shrink-0 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-2 focus:outline-none focus:ring-opacity-60 p-0.5 h-8 w-14\"\n        data-headlessui-state=\"\"\n        id=\"headlessui-switch-:r0:\"\n        role=\"switch\"\n        tabindex=\"0\"\n        type=\"button\"\n      >\n        <span\n          class=\"sr-only\"\n        >\n          SRLabel\n        </span>\n        <span\n          class=\"translate-x-0 bg-gray-200 pointer-events-none inline-block rounded-full shadow transform ring-0 transition ease-in-out duration-200 h-6 w-6\"\n        />\n      </button>\n    </div>\n  </body>,\n  \"container\": <div>\n    <button\n      aria-checked=\"false\"\n      class=\"bg-gray-600 focus:ring-gray relative inline-flex shrink-0 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-2 focus:outline-none focus:ring-opacity-60 p-0.5 h-8 w-14\"\n      data-headlessui-state=\"\"\n      id=\"headlessui-switch-:r0:\"\n      role=\"switch\"\n      tabindex=\"0\"\n      type=\"button\"\n    >\n      <span\n        class=\"sr-only\"\n      >\n        SRLabel\n      </span>\n      <span\n        class=\"translate-x-0 bg-gray-200 pointer-events-none inline-block rounded-full shadow transform ring-0 transition ease-in-out duration-200 h-6 w-6\"\n      />\n    </button>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Toggle when toggle is checked should have the toggle disc moved to the right 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <button\n        aria-checked=\"true\"\n        class=\"bg-cyan focus:ring-cyan relative inline-flex shrink-0 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-2 focus:outline-none focus:ring-opacity-60 p-0.5 h-8 w-14\"\n        data-headlessui-state=\"checked\"\n        id=\"headlessui-switch-:r2:\"\n        role=\"switch\"\n        tabindex=\"0\"\n        type=\"button\"\n      >\n        <span\n          class=\"sr-only\"\n        >\n          SRLabel\n        </span>\n        <span\n          class=\"translate-x-6 bg-white pointer-events-none inline-block rounded-full shadow transform ring-0 transition ease-in-out duration-200 h-6 w-6\"\n        />\n      </button>\n    </div>\n  </body>,\n  \"container\": <div>\n    <button\n      aria-checked=\"true\"\n      class=\"bg-cyan focus:ring-cyan relative inline-flex shrink-0 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:ring-2 focus:outline-none focus:ring-opacity-60 p-0.5 h-8 w-14\"\n      data-headlessui-state=\"checked\"\n      id=\"headlessui-switch-:r2:\"\n      role=\"switch\"\n      tabindex=\"0\"\n      type=\"button\"\n    >\n      <span\n        class=\"sr-only\"\n      >\n        SRLabel\n      </span>\n      <span\n        class=\"translate-x-6 bg-white pointer-events-none inline-block rounded-full shadow transform ring-0 transition ease-in-out duration-200 h-6 w-6\"\n      />\n    </button>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Toggle/index.ts",
    "content": "export { default as Toggle } from './Toggle'\nexport type { ToggleProps } from './Toggle'\n"
  },
  {
    "path": "libs/design-system/src/lib/Tooltip/Tooltip.spec.tsx",
    "content": "import { render, screen, waitFor } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\nimport { act } from 'react-dom/test-utils'\nimport { Tooltip } from '.'\n\ndescribe('Tooltip', () => {\n    describe('when rendered', () => {\n        it('should show tooltip content on tooltip trigger `focus`', async () => {\n            let component\n            await act(async () => {\n                component = render(\n                    <Tooltip content=\"Tooltip Content\">\n                        <button data-testid=\"tooltip-trigger\">Tooltip trigger</button>\n                    </Tooltip>\n                )\n            })\n\n            const tooltipTrrigger = screen.getByTestId('tooltip-trigger')\n\n            tooltipTrrigger.focus()\n            await waitFor(() => {\n                expect(screen.getByText(/^tooltip content$/i)).toBeVisible()\n            })\n\n            expect(component).toMatchSnapshot()\n        })\n        it('should show tooltip on tooltip trigger `hover`', async () => {\n            let component\n            await act(async () => {\n                component = render(\n                    <Tooltip content=\"Tooltip Content\">\n                        <button data-testid=\"tooltip-trigger\">Tooltip trigger</button>\n                    </Tooltip>\n                )\n            })\n\n            const tooltipTrrigger = screen.getByTestId('tooltip-trigger')\n\n            userEvent.hover(tooltipTrrigger)\n            await waitFor(() => {\n                expect(screen.getByText(/^tooltip content$/i)).toBeVisible()\n            })\n\n            expect(component).toMatchSnapshot()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/Tooltip/Tooltip.stories.tsx",
    "content": "import type { Meta, Story } from '@storybook/react'\nimport type { TooltipProps } from './Tooltip'\nimport { RiInformationLine as IconInfo, RiLinksLine as IconLink } from 'react-icons/ri'\nimport Tooltip from './Tooltip'\n\nexport default {\n    title: 'Components/Tooltip',\n    component: Tooltip,\n} as Meta\n\nconst Template: Story<TooltipProps> = (args) => {\n    return (\n        <div className=\"flex justify-center py-20\">\n            <Tooltip {...args}>\n                <button className=\"block transition-color duration-100 text-gray-50 hover:text-white\">\n                    <IconInfo className=\"h-5 w-5\" />\n                </button>\n            </Tooltip>\n        </div>\n    )\n}\n\nexport const Base = Template.bind({})\nBase.args = { content: 'Short content' }\n\nexport const Multiline = Template.bind({})\nMultiline.args = {\n    content:\n        'This is the cumulative investments (or fiat) that you have invested in your cryptocurrency portfolio.',\n}\n\nexport const WithMarkup = Template.bind({})\nWithMarkup.args = {\n    content: (\n        <div>\n            <p className=\"font-medium\">More information</p>\n            <div className=\"flex w-full items-center mt-1\">\n                <IconInfo />\n                <p className=\"pl-1\">hello world</p>\n            </div>\n        </div>\n    ),\n}\n\nexport const Interactive = Template.bind({})\nInteractive.args = {\n    interactive: true,\n    content: (\n        <div>\n            <p className=\"font-medium\">Link to resource</p>\n            <a\n                href=\"https://maybe.co\"\n                target=\"_blank\"\n                rel=\"noreferrer\"\n                className=\"flex w-full items-center text-teal mt-1\"\n            >\n                <IconLink />\n                <p className=\"pl-1\">hello world</p>\n            </a>\n        </div>\n    ),\n}\n\nexport const Delayed = Template.bind({})\nDelayed.args = {\n    delay: 500,\n    content: 'Delayed information ⏰',\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/Tooltip/Tooltip.tsx",
    "content": "import type { TippyProps } from '@tippyjs/react/headless'\nimport Tippy from '@tippyjs/react/headless'\nimport cn from 'classnames'\n\nexport type TooltipProps = TippyProps\n\nexport default function Tooltip({ content, children, className, ...rest }: TooltipProps) {\n    return (\n        <Tippy\n            render={(attrs) => (\n                <div\n                    className={cn(\n                        'px-2 py-1 rounded bg-gray-700 border border-gray-600 shadow max-w-[264px] text-sm font-light text-gray-50',\n                        className\n                    )}\n                    role=\"tooltip\"\n                    tabIndex={-1}\n                    {...attrs}\n                >\n                    {content}\n                </div>\n            )}\n            {...rest}\n        >\n            {children}\n        </Tippy>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/Tooltip/__snapshots__/Tooltip.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Tooltip when rendered should show tooltip content on tooltip trigger \\`focus\\` 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <button\n        data-testid=\"tooltip-trigger\"\n      >\n        Tooltip trigger\n      </button>\n    </div>\n    <div\n      data-tippy-root=\"\"\n      id=\"tippy-1\"\n      style=\"pointer-events: none; z-index: 9999; transition: none; position: absolute; left: 0px; top: 0px; margin: 0px; bottom: 0px; transform: translate(0px, -10px);\"\n    >\n      <div\n        class=\"px-2 py-1 rounded bg-gray-700 border border-gray-600 shadow max-w-[264px] text-sm font-light text-gray-50\"\n        role=\"tooltip\"\n        tabindex=\"-1\"\n      >\n        Tooltip Content\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <button\n      data-testid=\"tooltip-trigger\"\n    >\n      Tooltip trigger\n    </button>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Tooltip when rendered should show tooltip on tooltip trigger \\`hover\\` 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <button\n        data-testid=\"tooltip-trigger\"\n      >\n        Tooltip trigger\n      </button>\n    </div>\n    <div\n      data-tippy-root=\"\"\n      id=\"tippy-2\"\n      style=\"pointer-events: none; z-index: 9999; transition: none; position: absolute; left: 0px; top: 0px; margin: 0px; bottom: 0px; transform: translate(0px, -10px);\"\n    >\n      <div\n        class=\"px-2 py-1 rounded bg-gray-700 border border-gray-600 shadow max-w-[264px] text-sm font-light text-gray-50\"\n        role=\"tooltip\"\n        tabindex=\"-1\"\n      >\n        Tooltip Content\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <button\n      data-testid=\"tooltip-trigger\"\n    >\n      Tooltip trigger\n    </button>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/Tooltip/index.ts",
    "content": "export { default as Tooltip } from './Tooltip'\nexport type { TooltipProps } from './Tooltip'\n"
  },
  {
    "path": "libs/design-system/src/lib/TrendLine/TrendLine.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { TrendLineProps } from '.'\n\nimport TrendLine from './TrendLine'\n\nconst data = Array.from({ length: 30 }, () => Math.random() * 10000).map((value, index) => ({\n    key: index,\n    value,\n}))\n\nexport default {\n    title: 'Components/TrendLine',\n    component: TrendLine,\n    parameters: { controls: { exclude: ['className'] } },\n    args: {\n        inverted: false,\n        data,\n    },\n} as Meta\n\nexport const Base: Story<TrendLineProps> = (props) => (\n    <div className=\"w-16 h-4\">\n        <TrendLine {...props} />\n    </div>\n)\n"
  },
  {
    "path": "libs/design-system/src/lib/TrendLine/TrendLine.tsx",
    "content": "import { useMemo } from 'react'\nimport { ParentSize } from '@visx/responsive'\nimport { scaleBand, scaleLinear } from '@visx/scale'\nimport { Group } from '@visx/group'\nimport { Area, Circle } from '@visx/shape'\nimport classNames from 'classnames'\n\nconst CIRCLE_RADIUS = 1\n\nexport interface TrendLineProps extends React.HTMLAttributes<HTMLDivElement> {\n    data: {\n        key: { toString(): string }\n        value: number\n    }[]\n    /** Whether a negative trendline should be treated positively (teal vs. red) */\n    inverted?: boolean\n}\n\nexport default function TrendLine({\n    data,\n    className,\n    inverted = false,\n    ...rest\n}: TrendLineProps): JSX.Element | null {\n    const xScale = useMemo(\n        () =>\n            scaleBand<string>({\n                domain: data.map(({ key }) => key.toString()),\n                round: false,\n            }),\n        [data]\n    )\n    const yScale = useMemo(() => {\n        const yDomain = data.map(({ value }) => value)\n\n        return scaleLinear<number>({\n            domain: [Math.min(...yDomain), Math.max(...yDomain)],\n            round: true,\n        })\n    }, [data])\n\n    const change = data[data.length - 1].value - data[0].value\n\n    let isPositive = change > 0\n    if (inverted) isPositive = !isPositive\n\n    return data.length > 0 ? (\n        <ParentSize className={classNames('relative', className)} {...rest}>\n            {({ width, height }) => {\n                xScale.range([0, width - CIRCLE_RADIUS * 2])\n                yScale.range([height - CIRCLE_RADIUS * 2, 0])\n\n                return (\n                    <svg {...{ width, height }}>\n                        <Group left={CIRCLE_RADIUS} top={CIRCLE_RADIUS}>\n                            <>\n                                <Area\n                                    data={data}\n                                    x={({ key }) => xScale(key.toString()) || 0}\n                                    y={({ value }) => yScale(value)}\n                                    className={\n                                        change === 0\n                                            ? 'text-gray-200'\n                                            : isPositive\n                                            ? 'text-teal'\n                                            : 'text-red'\n                                    }\n                                    stroke=\"currentColor\"\n                                    strokeWidth={1}\n                                    strokeLinejoin=\"round\"\n                                />\n                                <Circle\n                                    r={CIRCLE_RADIUS}\n                                    cx={xScale(data[data.length - 1].key.toString())}\n                                    cy={yScale(data[data.length - 1].value)}\n                                    className={\n                                        change === 0\n                                            ? 'text-gray-200'\n                                            : isPositive\n                                            ? 'text-teal'\n                                            : 'text-red'\n                                    }\n                                    fill=\"currentColor\"\n                                />\n                            </>\n                        </Group>\n                    </svg>\n                )\n            }}\n        </ParentSize>\n    ) : null\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/TrendLine/Trendline.spec.tsx",
    "content": "import { render } from '@testing-library/react'\nimport TrendLine from './TrendLine'\n\ndescribe('TrendLine', () => {\n    describe('when rendered with a positive trendline', () => {\n        it('should render correcly', () => {\n            const component = render(\n                <TrendLine\n                    data={[\n                        { key: 1, value: 1 },\n                        { key: 2, value: 2 },\n                        { key: 3, value: 3 },\n                    ]}\n                />\n            )\n\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when rendered with a negative trendline', () => {\n        it('should render correcly', () => {\n            const component = render(\n                <TrendLine\n                    data={[\n                        { key: 3, value: 3 },\n                        { key: 2, value: 2 },\n                        { key: 1, value: 1 },\n                    ]}\n                />\n            )\n\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when rendered with a neutral trendline', () => {\n        it('should render correcly', () => {\n            const component = render(\n                <TrendLine\n                    data={[\n                        { key: 1, value: 1 },\n                        { key: 2, value: 2 },\n                        { key: 3, value: 1 },\n                    ]}\n                />\n            )\n\n            expect(component).toMatchSnapshot()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/TrendLine/__snapshots__/Trendline.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`TrendLine when rendered with a negative trendline should render correcly 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"relative\"\n        style=\"width: 100%; height: 100%;\"\n      >\n        <svg\n          height=\"0\"\n          width=\"0\"\n        >\n          <g\n            class=\"visx-group\"\n            transform=\"translate(1, 1)\"\n          >\n            <path\n              class=\"visx-area text-red\"\n              d=\"M-0.6666666666666667,0L-1.3333333333333335,-1L-2,-2L-2,-2L-1.3333333333333335,-1L-0.6666666666666667,0Z\"\n              stroke=\"currentColor\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"1\"\n            />\n            <circle\n              class=\"visx-circle text-red\"\n              cx=\"-2\"\n              cy=\"-2\"\n              fill=\"currentColor\"\n              r=\"1\"\n            />\n          </g>\n        </svg>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative\"\n      style=\"width: 100%; height: 100%;\"\n    >\n      <svg\n        height=\"0\"\n        width=\"0\"\n      >\n        <g\n          class=\"visx-group\"\n          transform=\"translate(1, 1)\"\n        >\n          <path\n            class=\"visx-area text-red\"\n            d=\"M-0.6666666666666667,0L-1.3333333333333335,-1L-2,-2L-2,-2L-1.3333333333333335,-1L-0.6666666666666667,0Z\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"1\"\n          />\n          <circle\n            class=\"visx-circle text-red\"\n            cx=\"-2\"\n            cy=\"-2\"\n            fill=\"currentColor\"\n            r=\"1\"\n          />\n        </g>\n      </svg>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`TrendLine when rendered with a neutral trendline should render correcly 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"relative\"\n        style=\"width: 100%; height: 100%;\"\n      >\n        <svg\n          height=\"0\"\n          width=\"0\"\n        >\n          <g\n            class=\"visx-group\"\n            transform=\"translate(1, 1)\"\n          >\n            <path\n              class=\"visx-area text-gray-200\"\n              d=\"M-0.6666666666666667,-2L-1.3333333333333335,0L-2,-2L-2,-2L-1.3333333333333335,0L-0.6666666666666667,-2Z\"\n              stroke=\"currentColor\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"1\"\n            />\n            <circle\n              class=\"visx-circle text-gray-200\"\n              cx=\"-2\"\n              cy=\"-2\"\n              fill=\"currentColor\"\n              r=\"1\"\n            />\n          </g>\n        </svg>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative\"\n      style=\"width: 100%; height: 100%;\"\n    >\n      <svg\n        height=\"0\"\n        width=\"0\"\n      >\n        <g\n          class=\"visx-group\"\n          transform=\"translate(1, 1)\"\n        >\n          <path\n            class=\"visx-area text-gray-200\"\n            d=\"M-0.6666666666666667,-2L-1.3333333333333335,0L-2,-2L-2,-2L-1.3333333333333335,0L-0.6666666666666667,-2Z\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"1\"\n          />\n          <circle\n            class=\"visx-circle text-gray-200\"\n            cx=\"-2\"\n            cy=\"-2\"\n            fill=\"currentColor\"\n            r=\"1\"\n          />\n        </g>\n      </svg>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`TrendLine when rendered with a positive trendline should render correcly 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div\n        class=\"relative\"\n        style=\"width: 100%; height: 100%;\"\n      >\n        <svg\n          height=\"0\"\n          width=\"0\"\n        >\n          <g\n            class=\"visx-group\"\n            transform=\"translate(1, 1)\"\n          >\n            <path\n              class=\"visx-area text-teal\"\n              d=\"M-0.6666666666666667,-2L-1.3333333333333335,-1L-2,0L-2,0L-1.3333333333333335,-1L-0.6666666666666667,-2Z\"\n              stroke=\"currentColor\"\n              stroke-linejoin=\"round\"\n              stroke-width=\"1\"\n            />\n            <circle\n              class=\"visx-circle text-teal\"\n              cx=\"-2\"\n              cy=\"0\"\n              fill=\"currentColor\"\n              r=\"1\"\n            />\n          </g>\n        </svg>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div\n      class=\"relative\"\n      style=\"width: 100%; height: 100%;\"\n    >\n      <svg\n        height=\"0\"\n        width=\"0\"\n      >\n        <g\n          class=\"visx-group\"\n          transform=\"translate(1, 1)\"\n        >\n          <path\n            class=\"visx-area text-teal\"\n            d=\"M-0.6666666666666667,-2L-1.3333333333333335,-1L-2,0L-2,0L-1.3333333333333335,-1L-0.6666666666666667,-2Z\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"1\"\n          />\n          <circle\n            class=\"visx-circle text-teal\"\n            cx=\"-2\"\n            cy=\"0\"\n            fill=\"currentColor\"\n            r=\"1\"\n          />\n        </g>\n      </svg>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/TrendLine/index.ts",
    "content": "export { default as TrendLine } from './TrendLine'\nexport type { TrendLineProps } from './TrendLine'\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/Input/Input.spec.tsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react'\nimport { Input } from '..'\n\ndescribe('Input', () => {\n    describe('when rendered', () => {\n        it('should display a placeholder when passed', () => {\n            const component = render(<Input type=\"text\" placeholder=\"Placeholder\" />)\n\n            expect(screen.getByPlaceholderText('Placeholder')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n        it('should display a label when passed', () => {\n            const component = render(<Input type=\"text\" label=\"Label\" />)\n\n            expect(screen.getByLabelText('Label')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n        it('should display a color hint when passed', () => {\n            const component = render(<Input type=\"text\" colorHint=\"teal\" />)\n\n            expect(component).toMatchSnapshot()\n        })\n        it('should render plainly when no extra props are passed', () => {\n            const component = render(<Input type=\"text\" />)\n\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when interacted with', () => {\n        it('should call `onFocus` and `onBlur` callbacks', () => {\n            const onFocusMock = jest.fn(),\n                onBlurMock = jest.fn()\n            render(\n                <Input data-testid=\"input\" type=\"text\" onFocus={onFocusMock} onBlur={onBlurMock} />\n            )\n            const input = screen.getByTestId('input')\n\n            fireEvent.focus(input)\n            expect(onFocusMock).toHaveBeenCalledTimes(1)\n            fireEvent.blur(input)\n            expect(onBlurMock).toHaveBeenCalledTimes(1)\n        })\n\n        it('should call the `onChange` callback on change', () => {\n            const onChangeMock = jest.fn()\n            render(<Input data-testid=\"input\" type=\"text\" onChange={onChangeMock} />)\n            const input = screen.getByTestId('input')\n\n            fireEvent.focus(input)\n            fireEvent.change(input, {\n                target: { value: 'abc' },\n            })\n            expect(onChangeMock).toHaveBeenCalledTimes(1)\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/Input/Input.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { InputProps } from '..'\n\nimport { Input } from '..'\nimport { FormGroup } from '../../FormGroup'\nimport { RiTerminalBoxFill, RiWifiFill } from 'react-icons/ri'\n\nexport default {\n    title: 'Components/Inputs/Input',\n    component: Input,\n    parameters: {\n        controls: { exclude: ['variant', 'className', 'labelClassName'] },\n    },\n    argTypes: {\n        type: {\n            control: {\n                type: 'select',\n                options: ['text', 'number', 'password', 'email'],\n            },\n        },\n        colorHint: {\n            control: {\n                type: 'text',\n                description: 'Color to display on the right side of the input',\n            },\n        },\n        fixedLeftOverride: {\n            control: {\n                type: 'text',\n                description: 'Override content placed on the left side of the input',\n            },\n        },\n        fixedRightOverride: {\n            control: {\n                type: 'text',\n                description: 'Override content placed on the right side of the input',\n            },\n        },\n        disabled: {\n            control: {\n                type: 'boolean',\n            },\n        },\n        readOnly: {\n            control: {\n                type: 'boolean',\n            },\n        },\n        hint: {\n            control: {\n                type: 'text',\n            },\n        },\n        error: {\n            control: {\n                type: 'text',\n            },\n        },\n    },\n    args: {\n        label: 'Label',\n        placeholder: 'Placeholder',\n        type: 'text',\n    },\n} as Meta\n\nconst Template: Story<InputProps> = (args) => (\n    <FormGroup className=\"w-60\">\n        <Input {...args} />\n    </FormGroup>\n)\n\nexport const Base = Template.bind({})\n\nexport const WithColorHint = Template.bind({})\nWithColorHint.args = { colorHint: 'cyan' }\n\nexport const WithIcons = Template.bind({})\nWithIcons.args = {\n    fixedLeftOverride: <RiTerminalBoxFill className=\"text-lg\" />,\n    fixedRightOverride: <RiWifiFill className=\"text-lg\" />,\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/Input/Input.tsx",
    "content": "import type { InputColorHintColor } from '../'\nimport type { InputHTMLAttributes, ReactNode } from 'react'\nimport classNames from 'classnames'\nimport { InputColorHint, InputHint } from '../'\nimport React, { forwardRef } from 'react'\n\nconst InputVariants = Object.freeze({\n    default: 'focus-within:border-cyan focus-within:ring-cyan',\n    positive: 'focus-within:border-teal focus-within:ring-teal',\n    negative: 'focus-within:border-red focus-within:ring-red',\n})\n\nexport type InputVariant = keyof typeof InputVariants\n\nexport interface InputProps extends InputHTMLAttributes<HTMLInputElement> {\n    variant?: InputVariant\n\n    /** Label that shows up over input */\n    label?: string\n\n    /** Color to appear on the right side of the input */\n    colorHint?: InputColorHintColor\n\n    labelClassName?: string\n\n    inputClassName?: string\n\n    /** Hint message that appears below the input */\n    hint?: string\n\n    /** Error message that appears below the input */\n    error?: string\n\n    /** If there's no error message, but you want to color the border as red, pass `true` */\n    hasError?: boolean\n\n    /** Override content appearing on the left side of the input */\n    fixedLeftOverride?: ReactNode\n\n    /** Override content appearing on the right side of the input */\n    fixedRightOverride?: ReactNode\n}\n\n/**\n * Simple input component\n */\nfunction Input(\n    {\n        variant = 'default',\n        disabled,\n        readOnly,\n        colorHint,\n        className,\n        label,\n        labelClassName,\n        inputClassName,\n        hint,\n        error,\n        hasError,\n        fixedLeftOverride,\n        fixedRightOverride,\n        ...rest\n    }: InputProps,\n    ref: React.Ref<HTMLInputElement>\n): JSX.Element {\n    const fixedLeft = fixedLeftOverride\n    const fixedRight =\n        fixedRightOverride || (colorHint ? <InputColorHint color={colorHint} /> : null)\n\n    const bgClass = readOnly ? 'bg-gray-600' : 'bg-gray-500'\n\n    return (\n        <label className={classNames(className, 'flex w-full flex-col')}>\n            {label && (\n                <span\n                    className={classNames(\n                        labelClassName,\n                        'block mb-1 text-base text-gray-50 font-light leading-6'\n                    )}\n                >\n                    {label}\n                </span>\n            )}\n\n            <div\n                className={classNames(\n                    'flex h-10 text-white rounded border overflow-hidden relative',\n                    error || hasError ? 'border-red' : 'border-gray-700',\n                    'focus-within:ring focus-within:ring-opacity-10',\n                    InputVariants[variant],\n                    (disabled || readOnly) && 'text-gray-100'\n                )}\n            >\n                {fixedLeft && (\n                    <span\n                        className={classNames(\n                            'absolute left-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none',\n                            bgClass\n                        )}\n                    >\n                        {fixedLeft}\n                    </span>\n                )}\n\n                <input\n                    className={classNames(\n                        inputClassName,\n                        'min-w-0 py-0 w-full', // Allows the input's flex container to work properly - https://stackoverflow.com/a/42421490/7437737\n                        'text-base font-light leading-none border-0',\n                        'focus:outline-none focus:ring-0',\n                        'placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100',\n                        bgClass,\n                        fixedLeft ? 'pl-8' : 'pl-3',\n                        fixedRight ? 'pr-8' : 'pr-3'\n                    )}\n                    ref={ref}\n                    disabled={disabled}\n                    readOnly={readOnly}\n                    {...rest}\n                />\n\n                {fixedRight && (\n                    <span\n                        className={classNames(\n                            'absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none',\n                            bgClass\n                        )}\n                    >\n                        {fixedRight}\n                    </span>\n                )}\n            </div>\n\n            {hint && !error && <InputHint disabled={disabled}>{hint}</InputHint>}\n            {error && (\n                <InputHint error={true} disabled={disabled}>\n                    {error}\n                </InputHint>\n            )}\n        </label>\n    )\n}\n\nexport default forwardRef<HTMLInputElement, InputProps>(Input)\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/Input/__snapshots__/Input.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`Input when rendered should display a color hint when passed 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <label\n        class=\"flex w-full flex-col\"\n      >\n        <div\n          class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n        >\n          <input\n            class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n            type=\"text\"\n          />\n          <span\n            class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n          >\n            <span\n              class=\"bg-teal block w-1.5 rounded-lg leading-none\"\n            >\n                \n            </span>\n          </span>\n        </div>\n      </label>\n    </div>\n  </body>,\n  \"container\": <div>\n    <label\n      class=\"flex w-full flex-col\"\n    >\n      <div\n        class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n      >\n        <input\n          class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n          type=\"text\"\n        />\n        <span\n          class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n        >\n          <span\n            class=\"bg-teal block w-1.5 rounded-lg leading-none\"\n          >\n              \n          </span>\n        </span>\n      </div>\n    </label>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Input when rendered should display a label when passed 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <label\n        class=\"flex w-full flex-col\"\n      >\n        <span\n          class=\"block mb-1 text-base text-gray-50 font-light leading-6\"\n        >\n          Label\n        </span>\n        <div\n          class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n        >\n          <input\n            class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-3\"\n            type=\"text\"\n          />\n        </div>\n      </label>\n    </div>\n  </body>,\n  \"container\": <div>\n    <label\n      class=\"flex w-full flex-col\"\n    >\n      <span\n        class=\"block mb-1 text-base text-gray-50 font-light leading-6\"\n      >\n        Label\n      </span>\n      <div\n        class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n      >\n        <input\n          class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-3\"\n          type=\"text\"\n        />\n      </div>\n    </label>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Input when rendered should display a placeholder when passed 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <label\n        class=\"flex w-full flex-col\"\n      >\n        <div\n          class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n        >\n          <input\n            class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-3\"\n            placeholder=\"Placeholder\"\n            type=\"text\"\n          />\n        </div>\n      </label>\n    </div>\n  </body>,\n  \"container\": <div>\n    <label\n      class=\"flex w-full flex-col\"\n    >\n      <div\n        class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n      >\n        <input\n          class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-3\"\n          placeholder=\"Placeholder\"\n          type=\"text\"\n        />\n      </div>\n    </label>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`Input when rendered should render plainly when no extra props are passed 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <label\n        class=\"flex w-full flex-col\"\n      >\n        <div\n          class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n        >\n          <input\n            class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-3\"\n            type=\"text\"\n          />\n        </div>\n      </label>\n    </div>\n  </body>,\n  \"container\": <div>\n    <label\n      class=\"flex w-full flex-col\"\n    >\n      <div\n        class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n      >\n        <input\n          class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-3\"\n          type=\"text\"\n        />\n      </div>\n    </label>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/Input/index.ts",
    "content": "export { default as Input } from './Input'\nexport type { InputProps } from './Input'\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputColorHint/InputColorHint.tsx",
    "content": "import type { HTMLAttributes } from 'react'\nimport classNames from 'classnames'\n\nconst InputColorHintColors = Object.freeze({\n    cyan: 'bg-cyan',\n    blue: 'bg-blue',\n    pink: 'bg-pink',\n    teal: 'bg-teal',\n    green: 'bg-green',\n    red: 'bg-red',\n    yellow: 'bg-yellow',\n})\n\nexport type InputColorHintColor = keyof typeof InputColorHintColors\n\nexport interface InputColorHintProps extends HTMLAttributes<HTMLSpanElement> {\n    color: InputColorHintColor\n    className?: string\n}\n\nexport default function InputColorHint({\n    color,\n    className,\n    ...rest\n}: InputColorHintProps): JSX.Element {\n    const combinedClassName = classNames(\n        className,\n        InputColorHintColors[color],\n        'block w-1.5 rounded-lg leading-none'\n    )\n\n    return (\n        <span className={combinedClassName} {...rest}>\n            &nbsp; {/* Used to force a height matching the font size */}\n        </span>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputColorHint/index.ts",
    "content": "export { default as InputColorHint } from './InputColorHint'\nexport type { InputColorHintColor, InputColorHintProps } from './InputColorHint'\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputCurrency/InputCurrency.spec.tsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react'\nimport { useState } from 'react'\nimport { InputCurrency } from '..'\n\nfunction ControlledInputCurrency(props: { [key: string]: unknown }): JSX.Element {\n    const [value, setValue] = useState<number | null>(20)\n    return <InputCurrency value={value} onChange={setValue} {...props} />\n}\n\ndescribe('InputCurrency', () => {\n    describe('when rendered', () => {\n        it('should display a dollar sign by default', () => {\n            const component = render(<InputCurrency value={null} onChange={() => null} />)\n\n            expect(screen.getByText('$')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n        it('should display a specified currency symbol', () => {\n            const component = render(\n                <InputCurrency symbol=\"symbol\" value={null} onChange={() => null} />\n            )\n\n            expect(screen.getByText('symbol')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n    })\n\n    describe('when interacted with', () => {\n        it('should call `onChange` when the entered number changes', () => {\n            const onChangeMock = jest.fn()\n\n            render(<ControlledInputCurrency data-testid=\"input\" onChange={onChangeMock} />)\n            const input = screen.getByTestId('input')\n\n            fireEvent.focus(input)\n            fireEvent.change(input, { target: { value: -123 } })\n            expect(onChangeMock).toHaveBeenCalledWith(123) // Negative inputs not accepted, will be converted to positive\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputCurrency/InputCurrency.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { InputCurrencyProps } from '..'\nimport InputStories from '../Input/Input.stories'\n\nimport { FormGroup } from '../../FormGroup'\nimport { InputCurrency } from '..'\nimport React, { useState } from 'react'\n\nconst inheritedArgTypes = InputStories.argTypes || {}\ndelete inheritedArgTypes.type\ndelete inheritedArgTypes.fixedLeftOverride\ndelete inheritedArgTypes.fixedRightOverride\n\nexport default {\n    title: 'Components/Inputs/InputCurrency',\n    component: InputCurrency,\n    parameters: {\n        controls: { exclude: ['className', 'labelClassName', 'onValueChange'] },\n    },\n    argTypes: {\n        ...inheritedArgTypes,\n    },\n    args: {\n        label: 'Currency',\n        placeholder: 'Amount',\n    },\n} as Meta\n\nconst Template: Story<InputCurrencyProps> = (args) => {\n    const [value, setValue] = useState<number | null>(null)\n\n    return (\n        <FormGroup className=\"w-60\">\n            <InputCurrency {...args} value={value} onChange={setValue} />\n        </FormGroup>\n    )\n}\n\nexport const Base = Template.bind({})\n\nexport const WithColorHint = Template.bind({})\nWithColorHint.args = { colorHint: 'cyan' }\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputCurrency/InputCurrency.tsx",
    "content": "import React, { forwardRef } from 'react'\nimport { NumericFormat, type NumericFormatProps } from 'react-number-format'\nimport { Input, type InputProps } from '..'\n\nexport type InputCurrencyProps = Omit<InputProps, 'value' | 'defaultValue' | 'onChange'> &\n    Omit<NumericFormatProps, 'value' | 'onChange' | 'type'> & {\n        value: number | null\n        onChange(value: number | null): void\n\n        /** Currency symbol to appear on the left side of the input */\n        symbol?: string\n    }\n\n/**\n * Controlled input for numerical currency values\n */\nfunction InputCurrency(\n    { value, onChange, type, symbol = '$', allowNegative = false, ...rest }: InputCurrencyProps,\n    ref: React.Ref<HTMLInputElement>\n) {\n    // https://github.com/s-yadav/react-number-format#custom-inputs\n    return (\n        <NumericFormat\n            customInput={Input}\n            getInputRef={ref}\n            value={value}\n            onValueChange={(value) => {\n                onChange(value.floatValue ?? null)\n            }}\n            decimalScale={2}\n            thousandSeparator\n            allowNegative={allowNegative}\n            fixedLeftOverride={symbol}\n            {...rest}\n            type={type as any} // conflicting types between React and Number format\n        />\n    )\n}\n\nexport default forwardRef<HTMLInputElement, InputCurrencyProps>(InputCurrency)\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputCurrency/__snapshots__/InputCurrency.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`InputCurrency when rendered should display a dollar sign by default 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <label\n        class=\"flex w-full flex-col\"\n      >\n        <div\n          class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n        >\n          <span\n            class=\"absolute left-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n          >\n            $\n          </span>\n          <input\n            class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-8 pr-3\"\n            inputmode=\"numeric\"\n            type=\"text\"\n            value=\"\"\n          />\n        </div>\n      </label>\n    </div>\n  </body>,\n  \"container\": <div>\n    <label\n      class=\"flex w-full flex-col\"\n    >\n      <div\n        class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n      >\n        <span\n          class=\"absolute left-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n        >\n          $\n        </span>\n        <input\n          class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-8 pr-3\"\n          inputmode=\"numeric\"\n          type=\"text\"\n          value=\"\"\n        />\n      </div>\n    </label>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`InputCurrency when rendered should display a specified currency symbol 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <label\n        class=\"flex w-full flex-col\"\n      >\n        <div\n          class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n        >\n          <span\n            class=\"absolute left-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n          >\n            symbol\n          </span>\n          <input\n            class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-8 pr-3\"\n            inputmode=\"numeric\"\n            type=\"text\"\n            value=\"\"\n          />\n        </div>\n      </label>\n    </div>\n  </body>,\n  \"container\": <div>\n    <label\n      class=\"flex w-full flex-col\"\n    >\n      <div\n        class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n      >\n        <span\n          class=\"absolute left-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n        >\n          symbol\n        </span>\n        <input\n          class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-8 pr-3\"\n          inputmode=\"numeric\"\n          type=\"text\"\n          value=\"\"\n        />\n      </div>\n    </label>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputCurrency/index.ts",
    "content": "export { default as InputCurrency } from './InputCurrency'\nexport type { InputCurrencyProps } from './InputCurrency'\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputHint/InputHint.tsx",
    "content": "import type { ReactNode } from 'react'\nimport classNames from 'classnames'\n\nexport interface InputHintProps {\n    error?: boolean\n    disabled?: boolean\n    children: ReactNode\n}\n\nexport default function InputHint({\n    error = false,\n    disabled = false,\n    children,\n}: InputHintProps): JSX.Element {\n    return (\n        <span\n            className={classNames(\n                'ml-1 text-sm leading-4 mt-1',\n                disabled ? 'text-gray-100' : error ? 'text-red' : 'text-gray-100'\n            )}\n        >\n            {children}\n        </span>\n    )\n}\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputHint/index.ts",
    "content": "export { default as InputHint } from './InputHint'\nexport type { InputHintProps } from './InputHint'\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputPassword/InputPassword.spec.tsx",
    "content": "import { render, screen, waitFor } from '@testing-library/react'\nimport userEvent from '@testing-library/user-event'\nimport { act } from 'react-dom/test-utils'\nimport { InputPassword } from '..'\n\ndescribe('InputPassword', () => {\n    describe('when rendered', () => {\n        it('should render plainly when reveal button and complexity bar are disabled', () => {\n            const component = render(\n                <InputPassword showRevealButton={false} showComplexityBar={false} />\n            )\n\n            expect(component.queryByTestId('reveal-password-button')).not.toBeInTheDocument()\n            expect(component.queryByTitle('Password is very weak')).not.toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n        it('should render the reveal button when enabled', async () => {\n            let component\n            await act(async () => {\n                component = render(<InputPassword showRevealButton={true} data-testid=\"input\" />)\n            })\n\n            expect(screen.getByTestId('reveal-password-button')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n        it('should render the complexity bar when enabled', async () => {\n            let component\n            await act(async () => {\n                component = render(<InputPassword showComplexityBar={true} />)\n            })\n\n            expect(screen.getByTitle('Password is very weak')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n        it('should render a complexity based on the calculated score', async () => {\n            let component\n            await act(async () => {\n                component = render(\n                    <InputPassword\n                        showComplexityBar={true}\n                        value=\"Hello, World!\"\n                        onChange={() => null}\n                        passwordComplexity={() => 3}\n                    />\n                )\n            })\n\n            expect(screen.getByTitle('Password is strong')).toBeInTheDocument()\n            expect(component).toMatchSnapshot()\n        })\n        it('should show password requirements on `onFocus`', async () => {\n            let component\n            await act(async () => {\n                component = render(\n                    <InputPassword placeholder=\"Password\" showPasswordRequirements={true} />\n                )\n            })\n\n            const inputElement = screen.getByPlaceholderText('Password')\n\n            inputElement.focus()\n            await waitFor(() => {\n                expect(screen.getByText('Password requirements')).toBeVisible()\n            })\n\n            expect(component).toMatchSnapshot()\n        })\n        it('should show tooltip on reveal button hover', async () => {\n            let component\n            await act(async () => {\n                component = render(<InputPassword showRevealButton={true} />)\n            })\n\n            userEvent.hover(screen.getByTestId('reveal-password-button'))\n            await waitFor(() => {\n                expect(screen.getByText(/^show password$/i)).toBeVisible()\n            })\n\n            expect(component).toMatchSnapshot()\n        })\n    })\n})\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputPassword/InputPassword.stories.tsx",
    "content": "import type { Story, Meta } from '@storybook/react'\nimport type { InputPasswordProps } from '..'\nimport InputStories from '../Input/Input.stories'\n\nimport { FormGroup } from '../../FormGroup'\nimport { InputPassword } from '..'\nimport React, { useState } from 'react'\n\nconst inheritedArgTypes = InputStories.argTypes || {}\ndelete inheritedArgTypes.type\ndelete inheritedArgTypes.fixedLeftOverride\ndelete inheritedArgTypes.fixedRightOverride\ndelete inheritedArgTypes.colorHint\n\nexport default {\n    title: 'Components/Inputs/InputPassword',\n    component: InputPassword,\n    parameters: {\n        controls: {\n            exclude: ['className', 'labelClassName', 'onValueChange', 'passwordComplexity'],\n        },\n    },\n    argTypes: {\n        ...inheritedArgTypes,\n    },\n    args: {\n        label: 'Password',\n    },\n} as Meta\n\nconst Template: Story<InputPasswordProps> = (args) => {\n    const [value, setValue] = useState<string>('')\n\n    return (\n        <FormGroup className=\"w-96 h-40\">\n            <InputPassword\n                {...args}\n                value={value}\n                onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}\n            />\n        </FormGroup>\n    )\n}\n\nexport const Base = Template.bind({})\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputPassword/InputPassword.tsx",
    "content": "import type { InputProps } from '..'\nimport { useEffect, useMemo, useState } from 'react'\nimport classNames from 'classnames'\nimport type zxcvbnType from 'zxcvbn'\nimport {\n    RiEyeLine as IconReveal,\n    RiEyeOffLine as IconHide,\n    RiCloseFill as IconClose,\n    RiCheckFill as IconCheck,\n} from 'react-icons/ri'\nimport { Input } from '..'\nimport { InputHint } from '../InputHint'\nimport { Tooltip } from '../../Tooltip/'\n\ninterface PasswordValidation {\n    isValid: boolean\n    message: string\n}\n\ninterface PasswordValidator {\n    (password: string): PasswordValidation\n}\n\nexport interface InputPasswordProps extends Omit<InputProps, 'type'> {\n    /** Whether to show and enable the button to reveal/hide the password */\n    showRevealButton?: boolean\n\n    /** Whether to show the complexity measurement bar */\n    showComplexityBar?: boolean\n\n    /** Custom password complexity function, returning a score from 0 to 4 */\n    passwordComplexity?: (password: string) => number\n\n    /** Whether or not to show a password requirements popover */\n    showPasswordRequirements?: boolean\n\n    /** Fires when password validity changes */\n    onValidityChange?: (validations: { isValid: boolean; message: string }[]) => void\n}\n\nconst COMPLEXITY_SCORE_NAMES = ['very weak', 'weak', 'fair', 'strong', 'very strong']\n\ndeclare const zxcvbn: typeof zxcvbnType\n\n/**\n * Input for password values\n */\nfunction InputPassword({\n    value,\n    showRevealButton = true,\n    showComplexityBar = true,\n    showPasswordRequirements = false,\n    passwordComplexity,\n    disabled,\n    hint,\n    error,\n    onValidityChange,\n    ...rest\n}: InputPasswordProps): JSX.Element {\n    const [revealPassword, setRevealPassword] = useState(false)\n    const [complexityScore, setComplexityScore] = useState(0)\n    const [validations, setValidations] = useState<PasswordValidation[]>([])\n\n    const _passwordComplexity = useMemo(\n        () => passwordComplexity ?? ((password: string) => zxcvbn(password).score),\n        [passwordComplexity]\n    )\n\n    const passwordRequirements = useMemo(() => {\n        const validators: PasswordValidator[] = [\n            (p) => ({\n                isValid: p.length >= 8 && p.length <= 64,\n                message: 'Between 8 - 64 characters',\n            }),\n            (p) => ({ isValid: /[a-z]+/.test(p), message: 'Contains 1+ lowercase characters' }),\n            (p) => ({ isValid: /[A-Z]+/.test(p), message: 'Contains 1+ uppercase characters' }),\n            (p) => ({\n                isValid: /[*!@#$%^&(){}:;<>,.?/~_+-=|]+/.test(p),\n                message: 'Contains 1+ special characters',\n            }),\n        ]\n\n        return validators\n    }, [])\n\n    useEffect(() => {\n        setComplexityScore(value ? _passwordComplexity(value as string) : 0)\n    }, [value, _passwordComplexity])\n\n    // Using Auth0 \"Good\" password requirements - https://auth0.com/docs/connections/database/password-strength#password-policies\n    useEffect(() => {\n        if (onValidityChange) {\n            const checks = passwordRequirements.map((validatorFn) =>\n                validatorFn((value as string) || '')\n            )\n            onValidityChange(checks)\n            setValidations(checks)\n        }\n    }, [value, passwordRequirements, onValidityChange])\n\n    return (\n        <div>\n            <Tooltip\n                trigger=\"focusin\"\n                hideOnClick={false}\n                placement=\"bottom-start\"\n                offset={({ placement }) => {\n                    if (placement.includes('bottom')) {\n                        return showComplexityBar ? [0, 20] : [0, 8]\n                    } else {\n                        return [0, 4]\n                    }\n                }}\n                disabled={!showPasswordRequirements}\n                content={\n                    <div>\n                        <p className=\"text-gray-25\">Password requirements</p>\n\n                        <div className=\"mt-2\">\n                            {validations\n                                .sort((a, b) => {\n                                    const aVal = a.isValid ? 1 : 0\n                                    const bVal = b.isValid ? 1 : 0\n                                    return bVal - aVal\n                                })\n                                .map((validation) => {\n                                    return (\n                                        <div key={validation.message}>\n                                            <span className=\"inline-block pr-1\">\n                                                {validation.isValid ? (\n                                                    <IconCheck className=\"w-3 h-3 text-green\" />\n                                                ) : (\n                                                    <IconClose className=\"w-3 h-3 text-red\" />\n                                                )}\n                                            </span>\n                                            <span\n                                                className={\n                                                    validation.isValid\n                                                        ? 'text-gray-200'\n                                                        : 'text-gray-25'\n                                                }\n                                            >\n                                                {validation.message}\n                                            </span>\n                                        </div>\n                                    )\n                                })}\n                        </div>\n                    </div>\n                }\n            >\n                <div>\n                    <Input\n                        value={value}\n                        type={revealPassword ? 'text' : 'password'}\n                        fixedRightOverride={\n                            showRevealButton ? (\n                                <Tooltip\n                                    content={revealPassword ? 'Hide password' : 'Show password'}\n                                    delay={300}\n                                    hideOnClick={false}\n                                    offset={[0, 6]}\n                                >\n                                    <button\n                                        type=\"button\"\n                                        onClick={() => setRevealPassword(!revealPassword)}\n                                        aria-label={\n                                            revealPassword ? 'Hide Password' : 'Show Password'\n                                        }\n                                        data-testid=\"reveal-password-button\"\n                                        className=\"text-xl text-gray-100 hover:text-gray-50 focus:text-gray-50 focus:outline-none\"\n                                    >\n                                        {revealPassword ? <IconHide /> : <IconReveal />}\n                                    </button>\n                                </Tooltip>\n                            ) : null\n                        }\n                        hasError={!!error}\n                        disabled={disabled}\n                        hint={hint}\n                        error={error}\n                        {...rest}\n                    />\n                </div>\n            </Tooltip>\n            {showComplexityBar && (\n                <div\n                    className=\"flex pt-2\"\n                    title={'Password is ' + COMPLEXITY_SCORE_NAMES[complexityScore]}\n                >\n                    {['bg-red', 'bg-orange', 'bg-yellow', 'bg-green'].map((bg, index) => (\n                        <div\n                            key={bg}\n                            className={classNames(\n                                'w-1/4 h-1 rounded transition-colors',\n                                value && complexityScore > index ? bg : 'bg-gray-300',\n                                index < 3 && 'mr-1.5'\n                            )}\n                        ></div>\n                    ))}\n                </div>\n            )}\n\n            {hint && !error && <InputHint disabled={disabled}>{hint}</InputHint>}\n            {error && (\n                <InputHint error={true} disabled={disabled}>\n                    {error}\n                </InputHint>\n            )}\n        </div>\n    )\n}\n\nexport default InputPassword\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputPassword/__snapshots__/InputPassword.spec.tsx.snap",
    "content": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`InputPassword when rendered should render a complexity based on the calculated score 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div>\n        <div>\n          <label\n            class=\"flex w-full flex-col\"\n          >\n            <div\n              class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n            >\n              <input\n                class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n                type=\"password\"\n                value=\"Hello, World!\"\n              />\n              <span\n                class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n              >\n                <button\n                  aria-label=\"Show Password\"\n                  class=\"text-xl text-gray-100 hover:text-gray-50 focus:text-gray-50 focus:outline-none\"\n                  data-testid=\"reveal-password-button\"\n                  type=\"button\"\n                >\n                  <svg\n                    fill=\"currentColor\"\n                    height=\"1em\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"0\"\n                    viewBox=\"0 0 24 24\"\n                    width=\"1em\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                  >\n                    <g>\n                      <path\n                        d=\"M0 0h24v24H0z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M12 3c5.392 0 9.878 3.88 10.819 9-.94 5.12-5.427 9-10.819 9-5.392 0-9.878-3.88-10.819-9C2.121 6.88 6.608 3 12 3zm0 16a9.005 9.005 0 0 0 8.777-7 9.005 9.005 0 0 0-17.554 0A9.005 9.005 0 0 0 12 19zm0-2.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zm0-2a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z\"\n                      />\n                    </g>\n                  </svg>\n                </button>\n              </span>\n            </div>\n          </label>\n        </div>\n        <div\n          class=\"flex pt-2\"\n          title=\"Password is strong\"\n        >\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-red mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-orange mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-yellow mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300\"\n          />\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div>\n      <div>\n        <label\n          class=\"flex w-full flex-col\"\n        >\n          <div\n            class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n          >\n            <input\n              class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n              type=\"password\"\n              value=\"Hello, World!\"\n            />\n            <span\n              class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n            >\n              <button\n                aria-label=\"Show Password\"\n                class=\"text-xl text-gray-100 hover:text-gray-50 focus:text-gray-50 focus:outline-none\"\n                data-testid=\"reveal-password-button\"\n                type=\"button\"\n              >\n                <svg\n                  fill=\"currentColor\"\n                  height=\"1em\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"0\"\n                  viewBox=\"0 0 24 24\"\n                  width=\"1em\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                >\n                  <g>\n                    <path\n                      d=\"M0 0h24v24H0z\"\n                      fill=\"none\"\n                    />\n                    <path\n                      d=\"M12 3c5.392 0 9.878 3.88 10.819 9-.94 5.12-5.427 9-10.819 9-5.392 0-9.878-3.88-10.819-9C2.121 6.88 6.608 3 12 3zm0 16a9.005 9.005 0 0 0 8.777-7 9.005 9.005 0 0 0-17.554 0A9.005 9.005 0 0 0 12 19zm0-2.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zm0-2a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z\"\n                    />\n                  </g>\n                </svg>\n              </button>\n            </span>\n          </div>\n        </label>\n      </div>\n      <div\n        class=\"flex pt-2\"\n        title=\"Password is strong\"\n      >\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-red mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-orange mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-yellow mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300\"\n        />\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`InputPassword when rendered should render plainly when reveal button and complexity bar are disabled 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div>\n        <div>\n          <label\n            class=\"flex w-full flex-col\"\n          >\n            <div\n              class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n            >\n              <input\n                class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-3\"\n                type=\"password\"\n                value=\"\"\n              />\n            </div>\n          </label>\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div>\n      <div>\n        <label\n          class=\"flex w-full flex-col\"\n        >\n          <div\n            class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n          >\n            <input\n              class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-3\"\n              type=\"password\"\n              value=\"\"\n            />\n          </div>\n        </label>\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`InputPassword when rendered should render the complexity bar when enabled 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div>\n        <div>\n          <label\n            class=\"flex w-full flex-col\"\n          >\n            <div\n              class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n            >\n              <input\n                class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n                type=\"password\"\n                value=\"\"\n              />\n              <span\n                class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n              >\n                <button\n                  aria-label=\"Show Password\"\n                  class=\"text-xl text-gray-100 hover:text-gray-50 focus:text-gray-50 focus:outline-none\"\n                  data-testid=\"reveal-password-button\"\n                  type=\"button\"\n                >\n                  <svg\n                    fill=\"currentColor\"\n                    height=\"1em\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"0\"\n                    viewBox=\"0 0 24 24\"\n                    width=\"1em\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                  >\n                    <g>\n                      <path\n                        d=\"M0 0h24v24H0z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M12 3c5.392 0 9.878 3.88 10.819 9-.94 5.12-5.427 9-10.819 9-5.392 0-9.878-3.88-10.819-9C2.121 6.88 6.608 3 12 3zm0 16a9.005 9.005 0 0 0 8.777-7 9.005 9.005 0 0 0-17.554 0A9.005 9.005 0 0 0 12 19zm0-2.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zm0-2a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z\"\n                      />\n                    </g>\n                  </svg>\n                </button>\n              </span>\n            </div>\n          </label>\n        </div>\n        <div\n          class=\"flex pt-2\"\n          title=\"Password is very weak\"\n        >\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300\"\n          />\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div>\n      <div>\n        <label\n          class=\"flex w-full flex-col\"\n        >\n          <div\n            class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n          >\n            <input\n              class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n              type=\"password\"\n              value=\"\"\n            />\n            <span\n              class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n            >\n              <button\n                aria-label=\"Show Password\"\n                class=\"text-xl text-gray-100 hover:text-gray-50 focus:text-gray-50 focus:outline-none\"\n                data-testid=\"reveal-password-button\"\n                type=\"button\"\n              >\n                <svg\n                  fill=\"currentColor\"\n                  height=\"1em\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"0\"\n                  viewBox=\"0 0 24 24\"\n                  width=\"1em\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                >\n                  <g>\n                    <path\n                      d=\"M0 0h24v24H0z\"\n                      fill=\"none\"\n                    />\n                    <path\n                      d=\"M12 3c5.392 0 9.878 3.88 10.819 9-.94 5.12-5.427 9-10.819 9-5.392 0-9.878-3.88-10.819-9C2.121 6.88 6.608 3 12 3zm0 16a9.005 9.005 0 0 0 8.777-7 9.005 9.005 0 0 0-17.554 0A9.005 9.005 0 0 0 12 19zm0-2.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zm0-2a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z\"\n                    />\n                  </g>\n                </svg>\n              </button>\n            </span>\n          </div>\n        </label>\n      </div>\n      <div\n        class=\"flex pt-2\"\n        title=\"Password is very weak\"\n      >\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300\"\n        />\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`InputPassword when rendered should render the reveal button when enabled 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div>\n        <div>\n          <label\n            class=\"flex w-full flex-col\"\n          >\n            <div\n              class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n            >\n              <input\n                class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n                data-testid=\"input\"\n                type=\"password\"\n                value=\"\"\n              />\n              <span\n                class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n              >\n                <button\n                  aria-label=\"Show Password\"\n                  class=\"text-xl text-gray-100 hover:text-gray-50 focus:text-gray-50 focus:outline-none\"\n                  data-testid=\"reveal-password-button\"\n                  type=\"button\"\n                >\n                  <svg\n                    fill=\"currentColor\"\n                    height=\"1em\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"0\"\n                    viewBox=\"0 0 24 24\"\n                    width=\"1em\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                  >\n                    <g>\n                      <path\n                        d=\"M0 0h24v24H0z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M12 3c5.392 0 9.878 3.88 10.819 9-.94 5.12-5.427 9-10.819 9-5.392 0-9.878-3.88-10.819-9C2.121 6.88 6.608 3 12 3zm0 16a9.005 9.005 0 0 0 8.777-7 9.005 9.005 0 0 0-17.554 0A9.005 9.005 0 0 0 12 19zm0-2.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zm0-2a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z\"\n                      />\n                    </g>\n                  </svg>\n                </button>\n              </span>\n            </div>\n          </label>\n        </div>\n        <div\n          class=\"flex pt-2\"\n          title=\"Password is very weak\"\n        >\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300\"\n          />\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div>\n      <div>\n        <label\n          class=\"flex w-full flex-col\"\n        >\n          <div\n            class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n          >\n            <input\n              class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n              data-testid=\"input\"\n              type=\"password\"\n              value=\"\"\n            />\n            <span\n              class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n            >\n              <button\n                aria-label=\"Show Password\"\n                class=\"text-xl text-gray-100 hover:text-gray-50 focus:text-gray-50 focus:outline-none\"\n                data-testid=\"reveal-password-button\"\n                type=\"button\"\n              >\n                <svg\n                  fill=\"currentColor\"\n                  height=\"1em\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"0\"\n                  viewBox=\"0 0 24 24\"\n                  width=\"1em\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                >\n                  <g>\n                    <path\n                      d=\"M0 0h24v24H0z\"\n                      fill=\"none\"\n                    />\n                    <path\n                      d=\"M12 3c5.392 0 9.878 3.88 10.819 9-.94 5.12-5.427 9-10.819 9-5.392 0-9.878-3.88-10.819-9C2.121 6.88 6.608 3 12 3zm0 16a9.005 9.005 0 0 0 8.777-7 9.005 9.005 0 0 0-17.554 0A9.005 9.005 0 0 0 12 19zm0-2.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zm0-2a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z\"\n                    />\n                  </g>\n                </svg>\n              </button>\n            </span>\n          </div>\n        </label>\n      </div>\n      <div\n        class=\"flex pt-2\"\n        title=\"Password is very weak\"\n      >\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300\"\n        />\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`InputPassword when rendered should show password requirements on \\`onFocus\\` 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div>\n        <div>\n          <label\n            class=\"flex w-full flex-col\"\n          >\n            <div\n              class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n            >\n              <input\n                class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n                placeholder=\"Password\"\n                type=\"password\"\n                value=\"\"\n              />\n              <span\n                class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n              >\n                <button\n                  aria-label=\"Show Password\"\n                  class=\"text-xl text-gray-100 hover:text-gray-50 focus:text-gray-50 focus:outline-none\"\n                  data-testid=\"reveal-password-button\"\n                  type=\"button\"\n                >\n                  <svg\n                    fill=\"currentColor\"\n                    height=\"1em\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"0\"\n                    viewBox=\"0 0 24 24\"\n                    width=\"1em\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                  >\n                    <g>\n                      <path\n                        d=\"M0 0h24v24H0z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M12 3c5.392 0 9.878 3.88 10.819 9-.94 5.12-5.427 9-10.819 9-5.392 0-9.878-3.88-10.819-9C2.121 6.88 6.608 3 12 3zm0 16a9.005 9.005 0 0 0 8.777-7 9.005 9.005 0 0 0-17.554 0A9.005 9.005 0 0 0 12 19zm0-2.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zm0-2a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z\"\n                      />\n                    </g>\n                  </svg>\n                </button>\n              </span>\n            </div>\n          </label>\n        </div>\n        <div\n          class=\"flex pt-2\"\n          title=\"Password is very weak\"\n        >\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300\"\n          />\n        </div>\n      </div>\n    </div>\n    <div\n      data-tippy-root=\"\"\n      id=\"tippy-9\"\n      style=\"pointer-events: none; z-index: 9999; transition: none; position: absolute; left: 0px; top: 0px; margin: 0px; transform: translate(0px, 20px);\"\n    >\n      <div\n        class=\"px-2 py-1 rounded bg-gray-700 border border-gray-600 shadow max-w-[264px] text-sm font-light text-gray-50\"\n        role=\"tooltip\"\n        tabindex=\"-1\"\n      >\n        <div>\n          <p\n            class=\"text-gray-25\"\n          >\n            Password requirements\n          </p>\n          <div\n            class=\"mt-2\"\n          />\n        </div>\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div>\n      <div>\n        <label\n          class=\"flex w-full flex-col\"\n        >\n          <div\n            class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n          >\n            <input\n              class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n              placeholder=\"Password\"\n              type=\"password\"\n              value=\"\"\n            />\n            <span\n              class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n            >\n              <button\n                aria-label=\"Show Password\"\n                class=\"text-xl text-gray-100 hover:text-gray-50 focus:text-gray-50 focus:outline-none\"\n                data-testid=\"reveal-password-button\"\n                type=\"button\"\n              >\n                <svg\n                  fill=\"currentColor\"\n                  height=\"1em\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"0\"\n                  viewBox=\"0 0 24 24\"\n                  width=\"1em\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                >\n                  <g>\n                    <path\n                      d=\"M0 0h24v24H0z\"\n                      fill=\"none\"\n                    />\n                    <path\n                      d=\"M12 3c5.392 0 9.878 3.88 10.819 9-.94 5.12-5.427 9-10.819 9-5.392 0-9.878-3.88-10.819-9C2.121 6.88 6.608 3 12 3zm0 16a9.005 9.005 0 0 0 8.777-7 9.005 9.005 0 0 0-17.554 0A9.005 9.005 0 0 0 12 19zm0-2.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zm0-2a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z\"\n                    />\n                  </g>\n                </svg>\n              </button>\n            </span>\n          </div>\n        </label>\n      </div>\n      <div\n        class=\"flex pt-2\"\n        title=\"Password is very weak\"\n      >\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300\"\n        />\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n\nexports[`InputPassword when rendered should show tooltip on reveal button hover 1`] = `\nObject {\n  \"asFragment\": [Function],\n  \"baseElement\": <body>\n    <div>\n      <div>\n        <div>\n          <label\n            class=\"flex w-full flex-col\"\n          >\n            <div\n              class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n            >\n              <input\n                class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n                type=\"password\"\n                value=\"\"\n              />\n              <span\n                class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n              >\n                <button\n                  aria-label=\"Show Password\"\n                  class=\"text-xl text-gray-100 hover:text-gray-50 focus:text-gray-50 focus:outline-none\"\n                  data-testid=\"reveal-password-button\"\n                  type=\"button\"\n                >\n                  <svg\n                    fill=\"currentColor\"\n                    height=\"1em\"\n                    stroke=\"currentColor\"\n                    stroke-width=\"0\"\n                    viewBox=\"0 0 24 24\"\n                    width=\"1em\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                  >\n                    <g>\n                      <path\n                        d=\"M0 0h24v24H0z\"\n                        fill=\"none\"\n                      />\n                      <path\n                        d=\"M12 3c5.392 0 9.878 3.88 10.819 9-.94 5.12-5.427 9-10.819 9-5.392 0-9.878-3.88-10.819-9C2.121 6.88 6.608 3 12 3zm0 16a9.005 9.005 0 0 0 8.777-7 9.005 9.005 0 0 0-17.554 0A9.005 9.005 0 0 0 12 19zm0-2.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zm0-2a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z\"\n                      />\n                    </g>\n                  </svg>\n                </button>\n              </span>\n            </div>\n          </label>\n        </div>\n        <div\n          class=\"flex pt-2\"\n          title=\"Password is very weak\"\n        >\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n          />\n          <div\n            class=\"w-1/4 h-1 rounded transition-colors bg-gray-300\"\n          />\n        </div>\n      </div>\n    </div>\n    <div\n      data-tippy-root=\"\"\n      id=\"tippy-10\"\n      style=\"pointer-events: none; z-index: 9999; transition: none; position: absolute; left: 0px; top: 0px; margin: 0px; bottom: 0px; transform: translate(0px, -6px);\"\n    >\n      <div\n        class=\"px-2 py-1 rounded bg-gray-700 border border-gray-600 shadow max-w-[264px] text-sm font-light text-gray-50\"\n        role=\"tooltip\"\n        tabindex=\"-1\"\n      >\n        Show password\n      </div>\n    </div>\n  </body>,\n  \"container\": <div>\n    <div>\n      <div>\n        <label\n          class=\"flex w-full flex-col\"\n        >\n          <div\n            class=\"flex h-10 text-white rounded border overflow-hidden relative border-gray-700 focus-within:ring focus-within:ring-opacity-10 focus-within:border-cyan focus-within:ring-cyan\"\n          >\n            <input\n              class=\"min-w-0 py-0 w-full text-base font-light leading-none border-0 focus:outline-none focus:ring-0 placeholder-gray-100 disabled:placeholder-gray-200 disabled:text-gray-100 bg-gray-500 pl-3 pr-8\"\n              type=\"password\"\n              value=\"\"\n            />\n            <span\n              class=\"absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center text-gray-50 text-base select-none bg-gray-500\"\n            >\n              <button\n                aria-label=\"Show Password\"\n                class=\"text-xl text-gray-100 hover:text-gray-50 focus:text-gray-50 focus:outline-none\"\n                data-testid=\"reveal-password-button\"\n                type=\"button\"\n              >\n                <svg\n                  fill=\"currentColor\"\n                  height=\"1em\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"0\"\n                  viewBox=\"0 0 24 24\"\n                  width=\"1em\"\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                >\n                  <g>\n                    <path\n                      d=\"M0 0h24v24H0z\"\n                      fill=\"none\"\n                    />\n                    <path\n                      d=\"M12 3c5.392 0 9.878 3.88 10.819 9-.94 5.12-5.427 9-10.819 9-5.392 0-9.878-3.88-10.819-9C2.121 6.88 6.608 3 12 3zm0 16a9.005 9.005 0 0 0 8.777-7 9.005 9.005 0 0 0-17.554 0A9.005 9.005 0 0 0 12 19zm0-2.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9zm0-2a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z\"\n                    />\n                  </g>\n                </svg>\n              </button>\n            </span>\n          </div>\n        </label>\n      </div>\n      <div\n        class=\"flex pt-2\"\n        title=\"Password is very weak\"\n      >\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300 mr-1.5\"\n        />\n        <div\n          class=\"w-1/4 h-1 rounded transition-colors bg-gray-300\"\n        />\n      </div>\n    </div>\n  </div>,\n  \"debug\": [Function],\n  \"findAllByAltText\": [Function],\n  \"findAllByDisplayValue\": [Function],\n  \"findAllByLabelText\": [Function],\n  \"findAllByPlaceholderText\": [Function],\n  \"findAllByRole\": [Function],\n  \"findAllByTestId\": [Function],\n  \"findAllByText\": [Function],\n  \"findAllByTitle\": [Function],\n  \"findByAltText\": [Function],\n  \"findByDisplayValue\": [Function],\n  \"findByLabelText\": [Function],\n  \"findByPlaceholderText\": [Function],\n  \"findByRole\": [Function],\n  \"findByTestId\": [Function],\n  \"findByText\": [Function],\n  \"findByTitle\": [Function],\n  \"getAllByAltText\": [Function],\n  \"getAllByDisplayValue\": [Function],\n  \"getAllByLabelText\": [Function],\n  \"getAllByPlaceholderText\": [Function],\n  \"getAllByRole\": [Function],\n  \"getAllByTestId\": [Function],\n  \"getAllByText\": [Function],\n  \"getAllByTitle\": [Function],\n  \"getByAltText\": [Function],\n  \"getByDisplayValue\": [Function],\n  \"getByLabelText\": [Function],\n  \"getByPlaceholderText\": [Function],\n  \"getByRole\": [Function],\n  \"getByTestId\": [Function],\n  \"getByText\": [Function],\n  \"getByTitle\": [Function],\n  \"queryAllByAltText\": [Function],\n  \"queryAllByDisplayValue\": [Function],\n  \"queryAllByLabelText\": [Function],\n  \"queryAllByPlaceholderText\": [Function],\n  \"queryAllByRole\": [Function],\n  \"queryAllByTestId\": [Function],\n  \"queryAllByText\": [Function],\n  \"queryAllByTitle\": [Function],\n  \"queryByAltText\": [Function],\n  \"queryByDisplayValue\": [Function],\n  \"queryByLabelText\": [Function],\n  \"queryByPlaceholderText\": [Function],\n  \"queryByRole\": [Function],\n  \"queryByTestId\": [Function],\n  \"queryByText\": [Function],\n  \"queryByTitle\": [Function],\n  \"rerender\": [Function],\n  \"unmount\": [Function],\n}\n`;\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/InputPassword/index.ts",
    "content": "export { default as InputPassword } from './InputPassword'\nexport type { InputPasswordProps } from './InputPassword'\n"
  },
  {
    "path": "libs/design-system/src/lib/inputs/index.ts",
    "content": "export * from './Input'\nexport type { InputProps } from './Input'\n\nexport * from './InputColorHint'\nexport type { InputColorHintColor, InputColorHintProps } from './InputColorHint'\n\nexport * from './InputCurrency'\nexport type { InputCurrencyProps } from './InputCurrency'\n\nexport * from './InputHint'\nexport type { InputHintProps } from './InputHint'\n\nexport * from './InputPassword'\nexport type { InputPasswordProps } from './InputPassword'\n"
  },
  {
    "path": "libs/design-system/tailwind.config.js",
    "content": "const { join } = require('path')\nconst defaultTheme = require('tailwindcss/defaultTheme')\n\n/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n    content: [join(__dirname, 'src/**/*.tsx'), join(__dirname, 'docs/**/*.{tsx,mdx}')],\n    theme: {\n        screens: {\n            xs: '375px',\n            sm: '640px',\n            md: '744px',\n            lg: '1024px',\n            xl: '1440px',\n        },\n        colors: {\n            transparent: 'transparent',\n            current: 'currentColor',\n            black: '#16161A',\n            white: '#F8F9FA',\n            gray: {\n                DEFAULT: '#34363C',\n                25: '#DEE2E6',\n                50: '#ADB5BD',\n                100: '#868E96',\n                200: '#4B4F55',\n                300: '#44474C',\n                400: '#3D4045',\n                500: '#34363C',\n                600: '#2C2D32',\n                700: '#232428',\n                800: '#1C1C20',\n            },\n            cyan: {\n                DEFAULT: '#3BC9DB',\n                50: '#D7F6FA',\n                300: '#99E9F2',\n                400: '#66D9E8',\n                500: '#3BC9DB',\n            },\n            red: {\n                DEFAULT: '#FF8787',\n                50: '#FFE8E8',\n                300: '#FFC9C9',\n                400: '#FFA8A8',\n                500: '#FF8787',\n            },\n            teal: {\n                DEFAULT: '#38D9A9',\n                50: '#D6FAEE',\n                300: '#96F2D7',\n                400: '#63E6BE',\n                500: '#38D9A9',\n            },\n            yellow: {\n                DEFAULT: '#FFCA28',\n                50: '#FFF2CB',\n                300: '#FFE082',\n                400: '#FFD54F',\n                500: '#FFCA28',\n            },\n            blue: {\n                DEFAULT: '#4DABF7',\n                50: '#DBEEFF',\n                300: '#A5D8FF',\n                400: '#74C0FC',\n                500: '#4DABF7',\n            },\n            orange: {\n                DEFAULT: '#FFA94D',\n                50: '#FFEEDA',\n                300: '#FFD8A8',\n                400: '#FFC078',\n                500: '#FFA94D',\n            },\n            pink: {\n                DEFAULT: '#F783AC',\n                50: '#FFE5EE',\n                300: '#FCC2D7',\n                400: '#FAA2C1',\n                500: '#F783AC',\n            },\n            grape: {\n                DEFAULT: '#DA77F2',\n                50: '#F9E4FD',\n                300: '#EEBEFA',\n                400: '#E599F7',\n                500: '#DA77F2',\n            },\n            indigo: {\n                DEFAULT: '#748FFC',\n                50: '#E3E8FF',\n                300: '#BAC8FF',\n                400: '#91A7FF',\n                500: '#748FFC',\n            },\n            green: {\n                DEFAULT: '#66BB6A',\n                50: '#E3F2E3',\n                300: '#A5D6A7',\n                400: '#81C784',\n                500: '#66BB6A',\n            },\n        },\n        fontFamily: {\n            sans: ['Inter', ...defaultTheme.fontFamily.sans],\n            display: ['Monument Extended', ...defaultTheme.fontFamily.sans],\n            mono: defaultTheme.fontFamily.mono,\n        },\n        fontSize: {\n            sm: ['0.75rem', '1rem'],\n            base: ['0.875rem', '1.5rem'],\n            lg: ['1rem', '1.5rem'],\n            xl: ['1.125rem', '1.5rem'],\n            '2xl': ['1.25rem', '2rem'],\n            '3xl': ['1.5rem', '2rem'],\n            '4xl': ['1.875rem', '2.5rem'],\n            '5xl': ['2.5rem', '3.5rem'],\n        },\n        extend: {\n            boxShadow: {\n                DEFAULT: '0px 1px 2px 0px rgba(0, 0, 0, 0.1)',\n                md: '0px 1px 4px 0px rgba(0, 0, 0, 0.25)',\n                lg: '0px 2px 4px 1px rgba(0, 0, 0, 0.3)',\n            },\n            backgroundImage: {\n                shine: 'linear-gradient(to right, transparent, transparent, #FFF1, transparent, transparent)',\n            },\n            keyframes: {\n                shine: {\n                    '0%': {\n                        transform: 'translateX(-100%)',\n                    },\n                    '100%': {\n                        transform: 'translateX(100%)',\n                    },\n                },\n                appearUp: {\n                    '0%': {\n                        transformOrigin: 'center',\n                        transform: 'translateY(50%) scale(0.8)',\n                        opacity: 0,\n                    },\n                    '100%': {\n                        transformOrigin: 'center',\n                        transform: 'translateY(0) scale(1)',\n                        opacity: 1,\n                    },\n                },\n            },\n            animation: {\n                shine: 'shine 1.8s infinite',\n                appearUp: 'appearUp 0.3s',\n            },\n        },\n    },\n}\n"
  },
  {
    "path": "libs/design-system/tsconfig.json",
    "content": "{\n    \"extends\": \"../../tsconfig.base.json\",\n    \"compilerOptions\": {\n        \"jsx\": \"react-jsx\",\n        \"allowJs\": true,\n        \"esModuleInterop\": true,\n        \"allowSyntheticDefaultImports\": true,\n        \"forceConsistentCasingInFileNames\": true,\n        \"strict\": true,\n        \"noImplicitReturns\": true,\n        \"noFallthroughCasesInSwitch\": true\n    },\n    \"files\": [],\n    \"include\": [],\n    \"references\": [\n        {\n            \"path\": \"./tsconfig.lib.json\"\n        },\n        {\n            \"path\": \"./tsconfig.spec.json\"\n        },\n        {\n            \"path\": \"./.storybook/tsconfig.json\"\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/design-system/tsconfig.lib.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../dist/out-tsc\",\n        \"types\": [\"node\"]\n    },\n    \"files\": [\n        \"../../node_modules/@nrwl/react/typings/cssmodule.d.ts\",\n        \"../../node_modules/@nrwl/react/typings/image.d.ts\"\n    ],\n    \"exclude\": [\n        \"docs/**/*\",\n        \"**/*.spec.ts\",\n        \"**/*.test.ts\",\n        \"**/*.spec.tsx\",\n        \"**/*.test.tsx\",\n        \"**/*.stories.ts\",\n        \"**/*.stories.js\",\n        \"**/*.stories.jsx\",\n        \"**/*.stories.tsx\",\n        \"jest.config.ts\"\n    ],\n    \"include\": [\"**/*.js\", \"**/*.jsx\", \"**/*.ts\", \"**/*.tsx\"]\n}\n"
  },
  {
    "path": "libs/design-system/tsconfig.spec.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../dist/out-tsc\",\n        \"module\": \"commonjs\",\n        \"types\": [\"jest\", \"node\"]\n    },\n    \"include\": [\n        \"**/*.spec.ts\",\n        \"**/*.test.ts\",\n        \"**/*.spec.tsx\",\n        \"**/*.test.tsx\",\n        \"**/*.spec.js\",\n        \"**/*.test.js\",\n        \"**/*.spec.jsx\",\n        \"**/*.test.jsx\",\n        \"**/*.d.ts\",\n        \"jest.setup.js\",\n        \"jest.config.ts\"\n    ]\n}\n"
  },
  {
    "path": "libs/server/features/.babelrc",
    "content": "{\n    \"presets\": [[\"@nrwl/web/babel\", { \"useBuiltIns\": \"usage\" }]]\n}\n"
  },
  {
    "path": "libs/server/features/.eslintrc.json",
    "content": "{\n    \"extends\": [\"../../../.eslintrc.json\"],\n    \"ignorePatterns\": [\"!**/*\"],\n    \"overrides\": [\n        {\n            \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.ts\", \"*.tsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/server/features/README.md",
    "content": "# server-features\n\nThis library was generated with [Nx](https://nx.dev).\n\n## Running unit tests\n\nRun `nx test server-features` to execute the unit tests via [Jest](https://jestjs.io).\n"
  },
  {
    "path": "libs/server/features/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n    displayName: 'server-features',\n    preset: '../../../jest.preset.js',\n    globals: {\n        'ts-jest': {\n            tsconfig: '<rootDir>/tsconfig.spec.json',\n        },\n    },\n    testEnvironment: 'node',\n    transform: {\n        '^.+\\\\.[tj]sx?$': 'ts-jest',\n    },\n    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n    coverageDirectory: '../../../coverage/libs/server/features',\n}\n"
  },
  {
    "path": "libs/server/features/src/account/account-query.service.ts",
    "content": "import type {\n    User,\n    Valuation,\n    AccountCategory,\n    AccountConnection,\n    Account,\n    AccountClassification,\n} from '@prisma/client'\nimport { Prisma } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport type { DateTime } from 'luxon'\nimport _ from 'lodash'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type { PgService } from '@maybe-finance/server/shared'\nimport { raw, sql, DbUtil } from '@maybe-finance/server/shared'\n\ntype PaginationOptions = { page: number; pageSize: number }\n\ntype ValuationTrend = {\n    date: string\n    amount: Prisma.Decimal\n    valuation_id: Valuation['id'] | null\n    period_change: Prisma.Decimal | null\n    period_change_pct: Prisma.Decimal | null\n    total_change: Prisma.Decimal\n    total_change_pct: Prisma.Decimal\n}\n\ntype BalanceSeries = {\n    account_id: Account['id']\n    date: string\n    balance: Prisma.Decimal\n}\n\ntype ReturnSeries = {\n    account_id: Account['id']\n    date: string\n    rate_of_return: Prisma.Decimal\n    contributions: Prisma.Decimal\n    contributions_period: Prisma.Decimal\n}\n\ntype NetWorthSeries = {\n    date: string\n    netWorth: Prisma.Decimal\n    assets: Prisma.Decimal\n    liabilities: Prisma.Decimal\n    categories: Partial<Record<AccountCategory, Prisma.Decimal>>\n}\n\ntype AccountRollup = {\n    date: string\n    classification: Account['classification']\n    category: Account['category'] | null\n    id: Account['id'] | null\n    balance: Prisma.Decimal\n    rollup_pct: Prisma.Decimal\n    total_pct: Prisma.Decimal\n    grouping: 'classification' | 'category' | 'account'\n    account:\n        | (Pick<Account, 'id' | 'name' | 'mask' | 'syncStatus'> & {\n              connection: Pick<AccountConnection, 'name' | 'syncStatus'> | null\n          })\n        | null\n}\n\nexport interface IAccountQueryService {\n    getHoldingsEnriched(\n        accountId: Account['id'],\n        options: PaginationOptions\n    ): Promise<\n        Array<\n            SharedType.HoldingEnriched & {\n                cost_basis_user: Prisma.Decimal | null\n                cost_basis_provider: Prisma.Decimal | null\n            }\n        >\n    >\n    getValuationTrends(\n        accountId: Account['id'],\n        start?: DateTime,\n        end?: DateTime\n    ): Promise<ValuationTrend[]>\n    getReturnSeries(\n        accountId: Account['id'] | Account['id'][],\n        start: string,\n        end: string\n    ): Promise<ReturnSeries[]>\n    getBalanceSeries(\n        accountId: Account['id'] | Account['id'][],\n        start: string,\n        end: string,\n        interval: SharedType.TimeSeriesInterval\n    ): Promise<BalanceSeries[]>\n    getNetWorthSeries(\n        id: { userId: User['id'] } | { accountIds: Account['id'][] },\n        start: string,\n        end: string,\n        interval: SharedType.TimeSeriesInterval\n    ): Promise<NetWorthSeries[]>\n    getRollup(\n        id: { accountId: Account['id'] } | { userId: User['id'] },\n        start: string,\n        end: string,\n        interval: SharedType.TimeSeriesInterval\n    ): Promise<AccountRollup[]>\n}\n\nexport class AccountQueryService implements IAccountQueryService {\n    constructor(private readonly logger: Logger, private readonly pg: PgService) {}\n\n    async getHoldingsEnriched(accountId: Account['id'], { page, pageSize }: PaginationOptions) {\n        const { rows } = await this.pg.pool.query<\n            SharedType.HoldingEnriched & {\n                cost_basis_user: Prisma.Decimal | null\n                cost_basis_provider: Prisma.Decimal | null\n            }\n        >(\n            sql`\n              SELECT\n                h.id,\n                h.security_id,\n                s.name,\n                s.symbol,\n                s.shares_per_contract,\n                he.quantity,\n                he.value,\n                he.cost_basis,\n                h.cost_basis_user,\n                h.cost_basis_provider,\n                he.cost_basis_per_share,\n                he.price,\n                he.price_prev,\n                he.excluded\n              FROM\n                holdings_enriched he\n                INNER JOIN security s ON s.id = he.security_id\n                INNER JOIN holding h ON h.id = he.id\n              WHERE\n                he.account_id = ${accountId}\n              ORDER BY\n                he.excluded ASC,\n                he.value DESC\n              OFFSET ${page * pageSize}\n              LIMIT ${pageSize};\n            `\n        )\n\n        return rows\n    }\n\n    async getValuationTrends(accountId: Account['id'], start?: DateTime, end?: DateTime) {\n        // start/end date SQL query params\n        const pStart = start\n            ? raw(`'${start.toISODate()}'`)\n            : sql`account_value_start_date(${accountId}::int)`\n        const pEnd = end ? raw(`'${end.toISODate()}'`) : sql`now()`\n\n        const { rows } = await this.pg.pool.query<ValuationTrend>(\n            sql`\n              WITH valuation_trends AS (\n                SELECT\n                  date,\n                  COALESCE(interpolated::numeric, filled) AS amount\n                FROM (\n                  SELECT\n                    time_bucket_gapfill('1d', v.date) AS date,\n                    interpolate(avg(v.amount)) AS interpolated,\n                    locf(avg(v.amount)) AS filled\n                  FROM\n                    valuation v\n                  WHERE\n                    v.account_id = ${accountId}\n                    AND v.date BETWEEN ${pStart} AND ${pEnd}\n                  GROUP BY\n                    1\n                ) valuations_gapfilled\n                WHERE\n                  to_char(date, 'MM-DD') = '01-01'\n              ), valuations_combined AS (\n                SELECT\n                  COALESCE(v.date, vt.date) AS date,\n                  COALESCE(v.amount, vt.amount) AS amount,\n                  v.id AS valuation_id\n                FROM\n                  (SELECT * FROM valuation WHERE account_id = ${accountId}) v\n                  FULL OUTER JOIN valuation_trends vt ON vt.date = v.date\n              )\n              SELECT\n                v.date,\n                v.amount,\n                v.valuation_id,\n                v.amount - v.prev_amount AS period_change,\n                ROUND((v.amount - v.prev_amount)::numeric / NULLIF(v.prev_amount, 0), 4) AS period_change_pct,\n                v.amount - v.first_amount AS total_change,\n                ROUND((v.amount - v.first_amount)::numeric / NULLIF(v.first_amount, 0), 4) AS total_change_pct\n              FROM (\n                SELECT\n                  *,\n                  LAG(amount, 1) OVER (ORDER BY date ASC) AS prev_amount,\n                  (SELECT amount FROM valuations_combined ORDER BY date ASC LIMIT 1) AS first_amount\n                FROM\n                  valuations_combined\n              ) v\n              ORDER BY\n                v.date ASC\n            `\n        )\n\n        return rows\n    }\n\n    /**\n     * Return formula is the \"Basic Return with Cashflows at period end\" outlined here - https://www.kitces.com/blog/twr-dwr-irr-calculations-performance-reporting-software-methodology-gips-compliance/\n     */\n    async getReturnSeries(accountId: Account['id'], start: string, end: string) {\n        const pAccountIds = Array.isArray(accountId) ? accountId : [accountId]\n        const pStart = raw(`'${start}'`)\n        const pEnd = raw(`'${end}'`)\n\n        const { rows } = await this.pg.pool.query<ReturnSeries>(\n            sql`\n              WITH start_date AS (\n                SELECT\n                  a.id AS \"account_id\",\n                  GREATEST(account_value_start_date(a.id), a.start_date) AS \"start_date\"\n                FROM\n                  account a\n                WHERE\n                  a.id = ANY(${pAccountIds})\n                GROUP BY\n                  1\n              ), external_flows AS (\n                SELECT\n                  it.account_id,\n                  it.date,\n                  SUM(it.amount) AS \"amount\"\n                FROM\n                  investment_transaction it\n                  LEFT JOIN start_date sd ON sd.account_id = it.account_id\n                WHERE\n                  it.account_id = ANY(${pAccountIds})\n                  AND it.date BETWEEN sd.start_date AND ${pEnd}\n                  -- filter for investment_transactions that represent external flows\n                  AND it.category = 'transfer'\n                GROUP BY\n                  1, 2\n              ), external_flow_totals AS (\n                SELECT\n                  account_id,\n                  SUM(amount) as \"amount\"\n                FROM\n                  external_flows\n                GROUP BY\n                  1\n              ), balances AS (\n                SELECT\n                  abg.account_id,\n                  abg.date,\n                  abg.balance,\n                  0 - SUM(COALESCE(ef.amount, 0)) OVER (PARTITION BY abg.account_id ORDER BY abg.date ASC) AS \"contributions_period\",\n                  COALESCE(-1 * (eft.amount - coalesce(SUM(ef.amount) OVER (PARTITION BY abg.account_id ORDER BY abg.date DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 0)), 0) AS \"contributions\"\n                FROM\n                  account_balances_gapfilled(\n                    ${pStart},\n                    ${pEnd},\n                    '1d',\n                    ${pAccountIds}\n                  ) abg\n                  LEFT JOIN external_flows ef ON ef.account_id = abg.account_id AND ef.date = abg.date\n                  LEFT JOIN external_flow_totals eft ON eft.account_id = abg.account_id\n              )\n              SELECT\n                b.account_id,\n                b.date,\n                b.balance,\n                b.contributions,\n                b.contributions_period,\n                COALESCE(ROUND((b.balance - b0.balance - b.contributions_period) / COALESCE(NULLIF(b0.balance, 0), NULLIF(b.contributions_period, 0)), 4), 0) AS \"rate_of_return\"\n              FROM\n                balances b\n                LEFT JOIN (\n                  SELECT DISTINCT ON (account_id)\n                    account_id,\n                    balance\n                  FROM\n                    balances\n                  ORDER BY\n                    account_id, date ASC\n                ) b0 ON b0.account_id = b.account_id\n            `\n        )\n\n        return rows\n    }\n\n    async getBalanceSeries(\n        accountId: Account['id'] | Account['id'][],\n        start: string,\n        end: string,\n        interval: SharedType.TimeSeriesInterval\n    ) {\n        // by defining the query params upfront like this, we can easily copy-paste the query to debug in TablePlus w/ query param substitution\n        const pAccountIds = Array.isArray(accountId) ? accountId : [accountId]\n        const pStart = raw(`'${start}'`)\n        const pEnd = raw(`'${end}'`)\n        const pInterval = raw(`'${DbUtil.toPgInterval(interval)}'`)\n\n        const { rows } = await this.pg.pool.query<BalanceSeries>(\n            sql`\n              SELECT\n                abg.account_id,\n                abg.date,\n                abg.balance\n              FROM\n                account_balances_gapfilled(\n                  ${pStart},\n                  ${pEnd},\n                  ${pInterval},\n                  ${pAccountIds}\n                ) abg\n              `\n        )\n\n        return rows\n    }\n\n    /**\n     * returns net worth time series for an account\n     */\n    async getNetWorthSeries(\n        id: { userId: User['id'] } | { accountIds: Account['id'][] },\n        start: string,\n        end: string,\n        interval: SharedType.TimeSeriesInterval\n    ) {\n        // by defining the query params upfront like this, we can easily copy-paste the query to debug in TablePlus w/ query param substitution\n        const pAccountIds =\n            'accountIds' in id\n                ? id.accountIds\n                : sql`(\n                    SELECT\n                      array_agg(a.id)\n                    FROM\n                      account a\n                      LEFT JOIN account_connection ac ON ac.id = a.account_connection_id\n                    WHERE\n                      (a.user_id = ${id.userId} OR ac.user_id = ${id.userId})\n                      AND a.is_active\n                  )`\n\n        const pStart = raw(`'${start}'`)\n        const pEnd = raw(`'${end}'`)\n        const pInterval = raw(`'${DbUtil.toPgInterval(interval)}'`)\n\n        const { rows } = await this.pg.pool.query<\n            | {\n                  date: string\n                  classification: null\n                  category: null\n                  balance: Prisma.Decimal\n              }\n            | {\n                  date: string\n                  classification: AccountClassification\n                  category: null\n                  balance: Prisma.Decimal\n              }\n            | {\n                  date: string\n                  classification: AccountClassification\n                  category: AccountCategory\n                  balance: Prisma.Decimal\n              }\n        >(\n            sql`\n              SELECT\n                abg.date,\n                a.category,\n                a.classification,\n                SUM(CASE WHEN a.classification = 'asset' THEN abg.balance ELSE -abg.balance END) AS balance\n              FROM\n                account_balances_gapfilled(\n                  ${pStart},\n                  ${pEnd},\n                  ${pInterval},\n                  ${pAccountIds}\n                ) abg\n                INNER JOIN account a ON a.id = abg.account_id\n              GROUP BY\n                GROUPING SETS (\n                  (abg.date, a.classification, a.category),\n                  (abg.date, a.classification),\n                  (abg.date)\n                )\n              ORDER BY date ASC;\n            `\n        )\n\n        // Group independent rows into NetWorthSeries objects\n        return _(rows)\n            .groupBy((r) => r.date)\n            .mapValues((data, date) => ({\n                ...data.reduce(\n                    (acc, d) => {\n                        if (d.classification == null) {\n                            return {\n                                ...acc,\n                                netWorth: d.balance,\n                            }\n                        }\n\n                        if (d.category == null) {\n                            return d.classification === 'asset'\n                                ? { ...acc, assets: d.balance }\n                                : { ...acc, liabilities: d.balance }\n                        }\n\n                        return {\n                            ...acc,\n                            categories: {\n                                ...acc.categories,\n                                [d.category]: d.balance,\n                            },\n                        }\n                    },\n                    {\n                        date,\n                        netWorth: new Prisma.Decimal(0),\n                        assets: new Prisma.Decimal(0),\n                        liabilities: new Prisma.Decimal(0),\n                        categories: {},\n                    }\n                ),\n            }))\n            .values()\n            .value()\n    }\n\n    async getRollup(\n        id: { accountId: Account['id'] } | { userId: User['id'] },\n        start: string,\n        end: string,\n        interval: SharedType.TimeSeriesInterval\n    ) {\n        // by defining the query params upfront like this, we can easily copy-paste the query to debug in TablePlus w/ query param substitution\n        const pAccountIds =\n            'accountId' in id\n                ? [id.accountId]\n                : sql`(\n                    SELECT\n                      array_agg(a.id)\n                    FROM\n                      account a\n                      LEFT JOIN account_connection ac ON ac.id = a.account_connection_id\n                    WHERE\n                      (a.user_id = ${id.userId} OR ac.user_id = ${id.userId})\n                      AND a.is_active\n                  )`\n\n        const pStart = raw(`'${start}'`)\n        const pEnd = raw(`'${end}'`)\n        const pInterval = raw(`'${DbUtil.toPgInterval(interval)}'`)\n\n        const { rows } = await this.pg.pool.query<AccountRollup>(\n            sql`\n              WITH account_rollup AS (\n                SELECT\n                  abg.date,\n                  a.classification,\n                  a.category,\n                  a.id,\n                  SUM(abg.balance) AS balance,\n                  CASE GROUPING(abg.date, a.classification, a.category, a.id)\n                    WHEN 3 THEN 'classification'\n                    WHEN 1 THEN 'category'\n                    WHEN 0 THEN 'account'\n                    ELSE NULL\n                  END AS grouping\n                FROM\n                  account_balances_gapfilled(\n                    ${pStart},\n                    ${pEnd},\n                    ${pInterval},\n                    ${pAccountIds}\n                  ) abg\n                  INNER JOIN account a ON a.id = abg.account_id\n                GROUP BY\n                  GROUPING SETS (\n                    (abg.date, a.classification, a.category, a.id),\n                    (abg.date, a.classification, a.category),\n                    (abg.date, a.classification)\n                  )\n              )\n              SELECT\n                ar.date,\n                ar.classification,\n                ar.category,\n                ar.id,\n                ar.balance,\n                ar.grouping,\n                CASE\n                  WHEN a.id IS NULL THEN NULL\n                  ELSE json_build_object('id', a.id, 'name', a.name, 'mask', a.mask, 'syncStatus', a.sync_status, 'connection', CASE WHEN ac.id IS NULL THEN NULL ELSE json_build_object('name', ac.name, 'syncStatus', ac.sync_status) END)\n                END AS account,\n                ROUND(\n                  CASE ar.grouping\n                    WHEN 'account' THEN COALESCE(ar.balance / SUM(NULLIF(ar.balance, 0)) OVER (PARTITION BY ar.grouping, ar.date, ar.classification, ar.category), 0)\n                    WHEN 'category' THEN COALESCE(ar.balance / SUM(NULLIF(ar.balance, 0)) OVER (PARTITION BY ar.grouping, ar.date, ar.classification), 0)\n                    WHEN 'classification' THEN COALESCE(ar.balance / SUM(NULLIF(ar.balance, 0)) OVER (PARTITION BY ar.grouping, ar.date), 0)\n                  END, 4) AS rollup_pct,\n                ROUND(ar.balance / SUM(NULLIF(ar.balance, 0)) OVER (PARTITION BY ar.grouping, ar.date), 4) AS total_pct\n              FROM\n                account_rollup ar\n                LEFT JOIN account a ON a.id = ar.id\n                LEFT JOIN account_connection ac ON ac.id = a.account_connection_id\n              ORDER BY\n                ar.classification, ar.category, ar.id, ar.date;\n            `\n        )\n\n        return rows\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account/account.processor.ts",
    "content": "import type { SyncAccountQueueJobData } from '@maybe-finance/server/shared'\nimport type { Account } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport { ServerUtil } from '@maybe-finance/server/shared'\nimport type { IAccountProviderFactory } from './account.provider'\nimport type { IAccountService } from './account.service'\n\nexport interface IAccountProcessor {\n    sync(jobData: SyncAccountQueueJobData): Promise<void>\n}\n\nexport class AccountProcessor implements IAccountProcessor {\n    constructor(\n        private readonly logger: Logger,\n        private readonly accountService: IAccountService,\n        private readonly providers: IAccountProviderFactory\n    ) {}\n\n    async sync(jobData: SyncAccountQueueJobData) {\n        const account = await this.accountService.get(jobData.accountId)\n        const provider = this.providers.for(account)\n\n        await ServerUtil.useSync<Account>({\n            onStart: (account) => this.accountService.update(account.id, { syncStatus: 'SYNCING' }),\n            sync: (account) => provider.sync(account, jobData.options),\n            onSyncSuccess: (account) => this.accountService.syncBalances(account.id),\n            onSyncError: async (account, error) => {\n                this.logger.error(`error syncing account ${account.id}`, { error })\n            },\n            onEnd: (account) => this.accountService.update(account.id, { syncStatus: 'IDLE' }),\n        })(account)\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account/account.provider.ts",
    "content": "import type { SyncAccountOptions } from '@maybe-finance/server/shared'\nimport type { Account } from '@prisma/client'\n\nexport interface IAccountProvider {\n    sync(account: Account, options?: SyncAccountOptions): Promise<void>\n    delete(account: Account): Promise<void>\n}\n\nexport class NoOpAccountProvider implements IAccountProvider {\n    sync(_account: Account) {\n        return Promise.resolve()\n    }\n\n    delete(_account: Account) {\n        return Promise.resolve()\n    }\n}\n\nexport interface IAccountProviderFactory {\n    for(account: Account): IAccountProvider\n}\n\nexport class AccountProviderFactory implements IAccountProviderFactory {\n    constructor(\n        private readonly providers: Partial<Record<Account['provider'], IAccountProvider>>\n    ) {}\n\n    for(account: Account): IAccountProvider {\n        return this.providers[account.provider] ?? new NoOpAccountProvider()\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account/account.schema.ts",
    "content": "import { DateUtil } from '@maybe-finance/shared'\nimport { AccountCategory } from '@prisma/client'\nimport { z } from 'zod'\n\nconst CommonAccountFields = z.object({\n    name: z.string(),\n    currencyCode: z.string().default('USD'),\n    isActive: z.boolean().default(true),\n    startDate: z\n        .string()\n        .nullable()\n        .transform((d) => (d ? DateUtil.datetimeTransform(d).toJSDate() : null)),\n})\n\nconst ValuationAccountFields = z.object({\n    provider: z.literal('user').default('user'),\n    valuations: z.object({\n        originalBalance: z.number(),\n        currentBalance: z.number().nullish(),\n        currentDate: z.string().transform((d) => DateUtil.datetimeTransform(d)),\n    }),\n})\n\n// Property\n\nconst PropertyBaseSchema = z.object({\n    type: z.literal('PROPERTY'),\n    categoryUser: z.enum(['property']),\n    propertyMeta: z\n        .object({\n            track: z.boolean().default(false),\n            address: z.object({\n                line1: z.string(),\n                line2: z.string().optional(),\n                city: z.string(),\n                state: z.string(),\n                zip: z.string(),\n                country: z.string().default('United States'),\n            }),\n        })\n        .optional(),\n})\n\nconst PropertyCreateSchema =\n    PropertyBaseSchema.merge(CommonAccountFields).merge(ValuationAccountFields)\n\nconst PropertyUpdateSchema = PropertyBaseSchema.merge(CommonAccountFields.partial())\n\n// Vehicle\n\nconst VehicleBaseSchema = z.object({\n    type: z.literal('VEHICLE'),\n    categoryUser: z.enum(['vehicle']),\n    vehicleMeta: z\n        .object({\n            track: z.boolean().default(false),\n            make: z.string(),\n            model: z.string(),\n            year: z.number(),\n        })\n        .optional(),\n})\n\nconst VehicleCreateSchema =\n    VehicleBaseSchema.merge(CommonAccountFields).merge(ValuationAccountFields)\n\nconst VehicleUpdateSchema = VehicleBaseSchema.merge(CommonAccountFields.partial())\n\n// Investment\n\nconst InvestmentBaseSchema = z.object({\n    type: z.literal('INVESTMENT'),\n    categoryUser: z.enum(['investment']),\n})\n\nconst InvestmentCreateSchema = InvestmentBaseSchema.merge(CommonAccountFields)\n\n// Loan\nconst LoanBaseSchema = z.object({\n    type: z.literal('LOAN'),\n    categoryUser: z.enum(['loan']),\n    currentBalance: z.number(),\n    loanUser: z\n        .object({\n            originationDate: z.string(),\n            maturityDate: z.string(),\n            originationPrincipal: z.number(),\n            interestRate: z.discriminatedUnion('type', [\n                z.object({\n                    type: z.literal('fixed'),\n                    rate: z.number(),\n                }),\n                z.object({\n                    type: z.literal('arm'),\n                }),\n                z.object({\n                    type: z.literal('variable'),\n                }),\n            ]),\n            loanDetail: z.discriminatedUnion('type', [\n                z.object({\n                    type: z.literal('student'),\n                }),\n                z.object({\n                    type: z.literal('mortgage'),\n                }),\n                z.object({\n                    type: z.literal('other'),\n                }),\n            ]),\n        })\n        .optional(),\n})\n\nconst LoanCreateSchema = LoanBaseSchema.merge(CommonAccountFields).merge(\n    z.object({ provider: z.literal('user').default('user') })\n)\nconst LoanUpdateSchema = LoanBaseSchema.merge(CommonAccountFields.partial())\n\n// Credit\n\nconst CreditBaseSchema = z.object({\n    type: z.literal('CREDIT'),\n    categoryUser: z.enum(['credit']),\n    creditUser: z\n        .object({\n            isOverdue: z.boolean(),\n            lastPaymentAmount: z.number(),\n            lastPaymentDate: z.string(),\n            lastStatementAmount: z.number(),\n            lastStatementDate: z.string(),\n            minimumPayment: z.number(),\n        })\n        .optional(),\n})\n\nconst CreditCreateSchema = CreditBaseSchema.merge(CommonAccountFields).merge(ValuationAccountFields)\nconst CreditUpdateSchema = CreditBaseSchema.merge(CommonAccountFields.partial())\n\n// Other Asset\n\nconst OtherAssetBaseSchema = z.object({\n    type: z.literal('OTHER_ASSET'),\n    categoryUser: z.enum(['cash', 'investment', 'crypto', 'valuable', 'other']),\n})\n\nconst OtherAssetCreateSchema =\n    OtherAssetBaseSchema.merge(CommonAccountFields).merge(ValuationAccountFields)\nconst OtherAssetUpdateSchema = OtherAssetBaseSchema.merge(CommonAccountFields.partial())\n\n// Other Liability\n\nconst OtherLiabilityBaseSchema = z.object({\n    type: z.literal('OTHER_LIABILITY'),\n    categoryUser: z.enum(['other']),\n})\n\nconst OtherLiabilityCreateSchema =\n    OtherLiabilityBaseSchema.merge(CommonAccountFields).merge(ValuationAccountFields)\nconst OtherLiabilityUpdateSchema = OtherLiabilityBaseSchema.merge(CommonAccountFields.partial())\n\nexport const AccountCreateSchema = z.discriminatedUnion('type', [\n    PropertyCreateSchema,\n    VehicleCreateSchema,\n    LoanCreateSchema,\n    CreditCreateSchema,\n    OtherAssetCreateSchema,\n    OtherLiabilityCreateSchema,\n    InvestmentCreateSchema,\n])\n\nconst ProviderAccountUpdateSchema = z.discriminatedUnion('type', [\n    PropertyUpdateSchema,\n    VehicleUpdateSchema,\n    LoanUpdateSchema,\n    CreditUpdateSchema,\n    OtherAssetUpdateSchema,\n    OtherLiabilityUpdateSchema,\n    z\n        .object({\n            type: z.literal('DEPOSITORY'),\n            categoryUser: z.enum(['cash', 'other']),\n        })\n        .merge(CommonAccountFields),\n    z\n        .object({\n            type: z.literal('INVESTMENT'),\n            categoryUser: z.enum(['investment', 'cash', 'other']),\n        })\n        .merge(CommonAccountFields),\n])\n\nconst UserAccountUpdateSchema = z.discriminatedUnion('type', [\n    PropertyUpdateSchema,\n    VehicleUpdateSchema,\n    LoanUpdateSchema,\n    CreditUpdateSchema,\n    OtherAssetUpdateSchema,\n    OtherLiabilityUpdateSchema,\n])\n\nexport const AccountUpdateSchema = z.discriminatedUnion('provider', [\n    z.object({\n        provider: z.literal('user'),\n        data: UserAccountUpdateSchema,\n    }),\n    z.object({\n        provider: z.literal(undefined),\n        data: CommonAccountFields.partial().and(\n            z.object({ categoryUser: z.nativeEnum(AccountCategory).optional() })\n        ),\n    }),\n])\n"
  },
  {
    "path": "libs/server/features/src/account/account.service.ts",
    "content": "import type { Logger } from 'winston'\nimport type { SyncAccountQueue, SyncConnectionQueue } from '@maybe-finance/server/shared'\nimport type { IBalanceSyncStrategyFactory } from '../account-balance'\nimport type { IAccountQueryService } from './account-query.service'\nimport type {\n    Account,\n    AccountCategory,\n    AccountClassification,\n    User,\n    PrismaClient,\n    Prisma,\n    InvestmentTransactionCategory,\n} from '@prisma/client'\nimport _ from 'lodash'\nimport { DateTime } from 'luxon'\nimport { SharedType, AccountUtil, DateUtil } from '@maybe-finance/shared'\nimport { DbUtil } from '@maybe-finance/server/shared'\n\nexport interface IAccountService {\n    get(id: Account['id']): Promise<Account>\n    getAll(userId: User['id']): Promise<SharedType.AccountsResponse>\n    getAccountRollup(\n        userId: User['id'],\n        start?: string,\n        end?: string,\n        interval?: SharedType.TimeSeriesInterval\n    ): Promise<SharedType.AccountRollup>\n    sync(id: Account['id']): Promise<Account>\n    syncBalances(id: Account['id']): Promise<Account>\n    create(\n        data: Prisma.AccountUncheckedCreateInput,\n        initialValuations?: Prisma.ValuationCreateNestedManyWithoutAccountInput\n    ): Promise<Account>\n    update(id: Account['id'], data: Prisma.AccountUncheckedUpdateInput): Promise<Account>\n    delete(id: Account['id']): Promise<Account>\n}\n\nexport class AccountService implements IAccountService {\n    constructor(\n        private readonly logger: Logger,\n        private readonly prisma: PrismaClient,\n        private readonly queryService: IAccountQueryService,\n        private readonly syncAccountQueue: SyncAccountQueue,\n        private readonly syncConnectionQueue: SyncConnectionQueue,\n        private readonly balanceSyncStrategyFactory: IBalanceSyncStrategyFactory\n    ) {}\n\n    async get(id: Account['id']) {\n        return this.prisma.account.findUniqueOrThrow({\n            where: { id },\n            include: { accountConnection: true },\n        })\n    }\n\n    /**\n     * A user can have account associated with an `AccountConnection` or directly tied to their profile\n     *\n     * To retrieve all accounts, check both `Account` and `AccountConnection` tables for a non-null `userId`\n     */\n    async getAll(userId: User['id']): Promise<SharedType.AccountsResponse> {\n        const [accounts, connections] = await Promise.all([\n            this.prisma.account.findMany({\n                where: { userId },\n                orderBy: { id: 'asc' },\n            }),\n            this.prisma.accountConnection.findMany({\n                where: { userId },\n                include: {\n                    accounts: {\n                        orderBy: { id: 'asc' },\n                    },\n                },\n                orderBy: { id: 'asc' },\n            }),\n        ])\n\n        const activeConnectionSyncJobs = await this.syncConnectionQueue.getActiveJobs()\n\n        return {\n            accounts,\n            connections: connections.map((connection) => {\n                const job = activeConnectionSyncJobs.find(\n                    (job) => job.data.accountConnectionId === connection.id\n                )\n\n                const progress = job ? job.progress() : null\n\n                return {\n                    ...connection,\n                    syncProgress:\n                        progress != null &&\n                        typeof progress === 'object' &&\n                        typeof progress.description === 'string'\n                            ? progress\n                            : undefined,\n                }\n            }),\n        }\n    }\n\n    async getAccountDetails(id: Account['id']) {\n        return this.prisma.account.findUniqueOrThrow({\n            where: { id },\n            include: {\n                accountConnection: true,\n                transactions: {\n                    take: SharedType.PageSize.Transaction,\n                },\n                investmentTransactions: {\n                    take: SharedType.PageSize.InvestmentTransaction,\n                },\n                valuations: {\n                    take: SharedType.PageSize.Valuation,\n                },\n                holdings: {\n                    include: {\n                        security: true,\n                    },\n                    take: SharedType.PageSize.Holding,\n                },\n            },\n        })\n    }\n\n    async create(\n        data: Omit<Prisma.AccountUncheckedCreateInput, 'category'>,\n        initialValuations?: Prisma.ValuationCreateNestedManyWithoutAccountInput\n    ) {\n        return this.prisma.account.create({\n            data: {\n                ...data,\n                valuations: initialValuations,\n            },\n        })\n    }\n\n    async update(id: Account['id'], data: Prisma.AccountUncheckedUpdateInput) {\n        const account = await this.prisma.account.update({\n            where: { id },\n            data,\n        })\n        return account\n    }\n\n    async sync(id: Account['id']) {\n        const account = await this.get(id)\n        await this.syncAccountQueue.add('sync-account', { accountId: account.id })\n        return account\n    }\n\n    async syncBalances(id: Account['id']) {\n        const account = await this.get(id)\n        const strategy = this.balanceSyncStrategyFactory.for(account)\n\n        const profiler = this.logger.startTimer()\n        await strategy.syncAccountBalances(account)\n        profiler.done({ message: `synced account ${account.id} balances` })\n\n        return account\n    }\n\n    async delete(id: Account['id']) {\n        return this.prisma.account.delete({ where: { id } })\n    }\n\n    async getTransactions(accountId: Account['id'], page = 0, start?: DateTime, end?: DateTime) {\n        const [transactions, totalTransactions] = await this.prisma.$transaction([\n            this.prisma.$queryRaw<SharedType.TransactionEnriched[]>`\n                SELECT \n                    *\n                FROM transactions_enriched t\n                WHERE \n                    t.\"accountId\" = ${accountId}\n                    AND (${start?.toISODate()}::date IS NULL OR t.date >= ${start?.toISODate()}::date)\n                    AND (${end?.toISODate()}::date IS NULL OR t.date <= ${end?.toISODate()}::date)\n                ORDER BY t.date desc\n                LIMIT ${SharedType.PageSize.Transaction}\n                OFFSET ${page * SharedType.PageSize.Transaction}\n            `,\n            this.prisma.transaction.count({\n                where: {\n                    accountId,\n                    date: {\n                        gte: start?.toJSDate(),\n                        lte: end?.toJSDate(),\n                    },\n                },\n            }),\n        ])\n\n        return {\n            transactions,\n            totalTransactions,\n        }\n    }\n\n    async getHoldings(\n        accountId: Account['id'],\n        page = 0,\n        pageSize = SharedType.PageSize.Holding\n    ): Promise<SharedType.AccountHoldingResponse> {\n        const [holdings, totalHoldings] = await Promise.all([\n            this.queryService.getHoldingsEnriched(accountId, { page, pageSize }),\n            this.prisma.holding.count({ where: { accountId } }),\n        ])\n\n        return {\n            holdings: holdings.map((h) => {\n                return {\n                    id: h.id,\n                    securityId: h.security_id,\n                    name: h.name,\n                    symbol: h.symbol,\n                    quantity: h.quantity,\n                    sharesPerContract: h.shares_per_contract,\n                    costBasis: h.cost_basis_per_share,\n                    costBasisUser: h.cost_basis_user,\n                    costBasisProvider: h.cost_basis_provider,\n                    price: h.price,\n                    value: h.value,\n                    trend: {\n                        total: h.cost_basis ? DbUtil.calculateTrend(h.cost_basis, h.value) : null,\n                        today: h.price_prev\n                            ? DbUtil.calculateTrend(h.price_prev.times(h.quantity), h.value)\n                            : null,\n                    },\n                    excluded: h.excluded,\n                }\n            }),\n            totalHoldings,\n        }\n    }\n\n    async getInvestmentTransactions(\n        accountId: Account['id'],\n        page = 0,\n        start?: DateTime,\n        end?: DateTime,\n        category?: InvestmentTransactionCategory,\n        pageSize = SharedType.PageSize.InvestmentTransaction\n    ) {\n        const where = {\n            accountId,\n            date: {\n                gte: start?.toJSDate(),\n                lte: end?.toJSDate(),\n            },\n            category,\n        }\n\n        const [investmentTransactions, totalInvestmentTransactions] =\n            await this.prisma.$transaction([\n                this.prisma.investmentTransaction.findMany({\n                    where,\n                    include: {\n                        security: true,\n                    },\n                    orderBy: {\n                        date: 'desc',\n                    },\n                    skip: page * pageSize,\n                    take: pageSize,\n                }),\n                this.prisma.investmentTransaction.count({ where }),\n            ])\n\n        return {\n            investmentTransactions,\n            totalInvestmentTransactions,\n        }\n    }\n\n    async getBalance(\n        accountId: Account['id'],\n        date: string = DateTime.utc().plus({ days: 1 }).toISODate() // default to one day here to ensure we're grabbing the most recent date's balance\n    ): Promise<SharedType.AccountBalanceTimeSeriesData> {\n        const [balance] = await this.queryService.getBalanceSeries(accountId, date, date, 'days')\n\n        return {\n            date: balance.date,\n            balance: balance.balance,\n        }\n    }\n\n    async getBalances(\n        accountId: Account['id'],\n        start = DateTime.utc().minus({ years: 2 }).toISODate(),\n        end = DateTime.utc().toISODate(),\n        interval?: SharedType.TimeSeriesInterval\n    ): Promise<SharedType.AccountBalanceResponse> {\n        interval = interval ?? DateUtil.calculateTimeSeriesInterval(start, end)\n\n        const [balances, today, minDate] = await Promise.all([\n            this.queryService.getBalanceSeries(accountId, start, end, interval),\n            this.getBalance(accountId),\n            this.getOldestBalanceDate(accountId),\n        ])\n\n        return {\n            series: {\n                interval,\n                start,\n                end,\n                data: balances,\n            },\n            today,\n            minDate,\n            trend: DbUtil.calculateTrend(\n                balances[0].balance,\n                balances[balances.length - 1].balance\n            ),\n        }\n    }\n\n    async getReturns(\n        accountId: Account['id'],\n        start = DateTime.utc().minus({ years: 2 }).toISODate(),\n        end = DateTime.utc().toISODate()\n    ): Promise<SharedType.AccountReturnTimeSeriesData[]> {\n        const returnSeries = await this.queryService.getReturnSeries(accountId, start, end)\n\n        return returnSeries.map(\n            ({\n                date,\n                rate_of_return: rateOfReturn,\n                contributions,\n                contributions_period: contributionsPeriod,\n            }) => ({\n                date,\n                account: {\n                    contributions,\n                    contributionsPeriod,\n                    rateOfReturn,\n                },\n            })\n        )\n    }\n\n    async getAccountRollup(\n        userId: User['id'],\n        start = DateTime.utc().minus({ years: 2 }).toISODate(),\n        end = DateTime.utc().toISODate(),\n        interval?: SharedType.TimeSeriesInterval\n    ): Promise<SharedType.AccountRollup> {\n        interval = interval ?? DateUtil.calculateTimeSeriesInterval(start, end)\n\n        const rollup = await this.queryService.getRollup({ userId }, start, end, interval)\n\n        const toTimeSeries = (rows: typeof rollup): SharedType.AccountRollupTimeSeries => {\n            return {\n                interval: interval!,\n                start,\n                end,\n                data: rows.map(({ date, balance, rollup_pct, total_pct }) => ({\n                    date,\n                    balance,\n                    rollupPct: rollup_pct,\n                    totalPct: total_pct,\n                })),\n            }\n        }\n\n        // Arranges the flattened SQL query into a hierarchical tree for the UI\n        return _(rollup)\n            .groupBy((r) => r.classification)\n            .omit('null')\n            .mapValues((classificationRows, classification) => {\n                return {\n                    key: classification as AccountClassification,\n                    title: classification === 'asset' ? 'Assets' : 'Debts',\n                    balances: toTimeSeries(\n                        classificationRows.filter((r) => r.grouping === 'classification')\n                    ),\n                    items: _(classificationRows)\n                        .groupBy((r) => r.category)\n                        .omit('null')\n                        .mapValues((categoryRows, category) => {\n                            return {\n                                key: category as AccountCategory,\n                                title: AccountUtil.CATEGORIES[category as AccountCategory].plural,\n                                balances: toTimeSeries(\n                                    categoryRows.filter((r) => r.grouping === 'category')\n                                ),\n                                items: _(categoryRows)\n                                    .groupBy((r) => r.id)\n                                    .omit('null')\n                                    .mapValues((accountRows) => {\n                                        const [{ account }] = accountRows\n\n                                        if (!account) {\n                                            this.logger.warn('accountRow', accountRows[0])\n                                            throw new Error(\n                                                `accountRow is missing account data (rows: ${accountRows.length})`\n                                            )\n                                        }\n\n                                        return {\n                                            ...account,\n                                            syncing:\n                                                account.syncStatus !== 'IDLE' ||\n                                                (!!account.connection &&\n                                                    account.connection.syncStatus !== 'IDLE'),\n                                            balances: toTimeSeries(\n                                                accountRows.filter((r) => r.grouping === 'account')\n                                            ),\n                                        }\n                                    })\n                                    .values()\n                                    .value(),\n                            }\n                        })\n                        .values()\n                        .value(),\n                }\n            })\n            .values()\n            .value()\n    }\n\n    /**\n     * If the user has defined a `start_date`, use that. Otherwise, find the oldest balance record.\n     */\n    private async getOldestBalanceDate(accountId: Account['id']): Promise<string> {\n        const account = await this.prisma.account.findUnique({\n            where: { id: accountId },\n            select: { startDate: true },\n        })\n\n        if (account?.startDate) {\n            return DateTime.fromJSDate(account.startDate, { zone: 'utc' }).toISODate()\n        }\n\n        const {\n            _min: { date: minDate },\n        } = await this.prisma.accountBalance.aggregate({\n            where: { accountId },\n            _min: { date: true },\n        })\n\n        return minDate\n            ? DateTime.fromJSDate(minDate, { zone: 'utc' }).toISODate()\n            : DateTime.utc().minus({ years: 2 }).toISODate()\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account/index.ts",
    "content": "export * from './account.service'\nexport * from './account.processor'\nexport * from './account.provider'\nexport * from './account-query.service'\nexport * from './account.schema'\nexport * from './insight.service'\n"
  },
  {
    "path": "libs/server/features/src/account/insight.service.ts",
    "content": "import type { Logger } from 'winston'\nimport type {\n    Security,\n    Account,\n    Holding,\n    PrismaClient,\n    User,\n    TransactionType,\n} from '@prisma/client'\nimport { Prisma } from '@prisma/client'\nimport _ from 'lodash'\nimport { SharedUtil, type SharedType } from '@maybe-finance/shared'\nimport { DbUtil } from '@maybe-finance/server/shared'\nimport { DateTime } from 'luxon'\n\ntype UserInsightOptions = {\n    userId: User['id']\n    accountIds?: Account['id'] | Account['id'][]\n    now?: DateTime\n}\n\ntype AccountInsightOptions = {\n    accountId: Account['id']\n    now?: DateTime\n}\n\ntype HoldingInsightOptions = {\n    holding: Holding\n    now?: DateTime\n}\n\ntype PlanInsightOptions = {\n    userId: User['id']\n    now?: DateTime\n}\n\nexport interface IInsightService {\n    getUserInsights(options: UserInsightOptions): Promise<SharedType.UserInsights>\n    getAccountInsights(options: AccountInsightOptions): Promise<SharedType.AccountInsights>\n    getHoldingInsights(options: HoldingInsightOptions): Promise<SharedType.HoldingInsights>\n    getPlanInsights(options: PlanInsightOptions): Promise<SharedType.PlanInsights>\n}\n\nexport class InsightService implements IInsightService {\n    constructor(private readonly logger: Logger, private readonly prisma: PrismaClient) {}\n\n    async getUserInsights({\n        userId,\n        accountIds,\n        now = DateTime.utc(),\n    }: UserInsightOptions): Promise<SharedType.UserInsights> {\n        const pAccountIds =\n            accountIds != null\n                ? Array.isArray(accountIds)\n                    ? accountIds\n                    : [accountIds]\n                : Prisma.sql`(\n                    SELECT\n                      a.id\n                    FROM\n                      account a\n                      LEFT JOIN account_connection ac ON ac.id = a.account_connection_id\n                    WHERE\n                      (a.user_id = ${userId} OR ac.user_id = ${userId})\n                      AND a.is_active\n                  )`\n\n        const [user, accountSummary, holdingBreakdown, assetSummary, transactionSummary] =\n            await this.prisma.$transaction([\n                this.prisma.user.findUniqueOrThrow({ where: { id: userId } }),\n                this._accountSummary(pAccountIds, now),\n                this._holdingBreakdown(pAccountIds),\n                this._assetSummary(pAccountIds, now),\n                this._transactionSummary(pAccountIds, now),\n            ])\n\n        const assets = accountSummary.find(\n            (row) => row.grouping === 'classification' && row.classification === 'asset'\n        ) ?? {\n            balance_now: new Prisma.Decimal(0),\n            balance_yearly: new Prisma.Decimal(0),\n            balance_monthly: new Prisma.Decimal(0),\n            balance_weekly: new Prisma.Decimal(0),\n        }\n        const assetBuckets = assetSummary.filter(\n            (row): row is Extract<typeof row, { classification: 'asset' }> =>\n                row.classification === 'asset'\n        )\n\n        const liabilities = accountSummary.find(\n            (row) => row.grouping === 'classification' && row.classification === 'liability'\n        ) ?? {\n            balance_now: new Prisma.Decimal(0),\n            balance_yearly: new Prisma.Decimal(0),\n            balance_monthly: new Prisma.Decimal(0),\n            balance_weekly: new Prisma.Decimal(0),\n        }\n        const liabilityBuckets = assetSummary.filter(\n            (row): row is Extract<typeof row, { classification: 'liability' }> =>\n                row.classification === 'liability'\n        )\n\n        const netWorthNow = assets.balance_now.minus(liabilities.balance_now)\n        const netWorthYearly = assets.balance_yearly.minus(liabilities.balance_yearly)\n        const netWorthMonthly = assets.balance_monthly.minus(liabilities.balance_monthly)\n        const netWorthWeekly = assets.balance_weekly.minus(liabilities.balance_weekly)\n\n        const liquidAssetsNow = new Prisma.Decimal(\n            assetBuckets.find((row) => row.bucket === 'assets-liquid')?.balance_now ?? 0\n        )\n\n        const incomeMonthlyCalculated = new Prisma.Decimal(\n            transactionSummary.find((row) => row.type === 'INCOME')?.avg_6mo ?? 0\n        )\n        const incomeMonthly = user.monthlyIncomeUser ?? incomeMonthlyCalculated\n\n        const expensesMonthlyCalculated = new Prisma.Decimal(\n            transactionSummary.find((row) => row.type === 'EXPENSE')?.avg_6mo ?? 0\n        )\n        const expensesMonthly = user.monthlyExpensesUser ?? expensesMonthlyCalculated\n\n        const debtMonthlyCalculated = new Prisma.Decimal(\n            transactionSummary.find((row) => row.type === 'PAYMENT')?.avg_6mo ?? 0\n        )\n        const debtMonthly = user.monthlyDebtUser ?? debtMonthlyCalculated\n\n        const toAmountAndPct = (\n            value: Prisma.Decimal | null | undefined,\n            total: Prisma.Decimal\n        ) => {\n            const amount = value ?? new Prisma.Decimal(0)\n            return { amount, percentage: amount.dividedBy(total) }\n        }\n\n        return {\n            netWorthToday: netWorthNow,\n            netWorth: {\n                yearly: DbUtil.calculateTrend(netWorthYearly, netWorthNow),\n                monthly: DbUtil.calculateTrend(netWorthMonthly, netWorthNow),\n                weekly: DbUtil.calculateTrend(netWorthWeekly, netWorthNow),\n            },\n            safetyNet: {\n                months: liquidAssetsNow.dividedBy(expensesMonthly).clampedTo(0, Infinity),\n                spending: expensesMonthly,\n            },\n            debtIncome: {\n                ratio: debtMonthly.dividedBy(incomeMonthly),\n                debt: debtMonthly,\n                income: incomeMonthly,\n                user: {\n                    debt: user.monthlyDebtUser,\n                    income: user.monthlyIncomeUser,\n                },\n                calculated: {\n                    debt: debtMonthlyCalculated,\n                    income: incomeMonthlyCalculated,\n                },\n            },\n            debtAsset: {\n                ratio: liabilities.balance_now.dividedBy(assets.balance_now),\n                debt: liabilities.balance_now,\n                asset: assets.balance_now,\n            },\n            assetSummary: {\n                liquid: toAmountAndPct(liquidAssetsNow, assets.balance_now),\n                illiquid: toAmountAndPct(\n                    assetBuckets.find((row) => row.bucket === 'assets-illiquid')?.balance_now,\n                    assets.balance_now\n                ),\n                yielding: toAmountAndPct(\n                    assetBuckets.find((row) => row.bucket === 'assets-yielding')?.balance_now,\n                    assets.balance_now\n                ),\n            },\n            debtSummary: {\n                good: toAmountAndPct(\n                    liabilityBuckets.find((row) => row.bucket === 'debt-good')?.balance_now,\n                    liabilities.balance_now\n                ),\n                bad: toAmountAndPct(\n                    liabilityBuckets.find((row) => row.bucket === 'debt-bad')?.balance_now,\n                    liabilities.balance_now\n                ),\n                total: toAmountAndPct(\n                    liabilities.balance_now,\n                    assets.balance_now.plus(liabilities.balance_now)\n                ),\n            },\n            transactionSummary: {\n                income: incomeMonthly,\n                expenses: expensesMonthly,\n                payments: debtMonthly,\n            },\n            transactionBreakdown: transactionSummary.map((row) => ({\n                category: row.type,\n                amount: row.amount,\n                avg_6mo: row.avg_6mo,\n            })),\n            accountSummary: accountSummary\n                .filter(\n                    (row): row is Extract<typeof row, { grouping: 'category' }> =>\n                        row.grouping === 'category'\n                )\n                .map((row) => ({\n                    classification: row.classification,\n                    category: row.category,\n                    balance: row.balance_now,\n                    allocation: row.allocation_now,\n                })),\n            holdingBreakdown: _(holdingBreakdown)\n                .filter((h) => h.grouping === 'category')\n                .map((c) => ({\n                    ...c,\n                    holdings: _(holdingBreakdown)\n                        .filter(\n                            (h): h is Extract<typeof h, { grouping: 'security' }> =>\n                                h.grouping === 'security' && h.category === c.category\n                        )\n                        .orderBy((h) => +h.value, 'desc')\n                        .value(),\n                }))\n                .orderBy((h) => +h.value, 'desc')\n                .value(),\n        }\n    }\n\n    async getAccountInsights({\n        accountId,\n        now = DateTime.utc(),\n    }: AccountInsightOptions): Promise<SharedType.AccountInsights> {\n        const [\n            holdingSummary,\n            [returns],\n            [contributions],\n            {\n                _sum: { fees },\n            },\n        ] = await Promise.all([\n            this._holdingSummary(accountId),\n            this._portfolioReturn(accountId, now),\n            this._investmentTransactionSummary(accountId, now),\n            this.prisma.investmentTransaction.aggregate({\n                where: { accountId },\n                _sum: {\n                    fees: true,\n                },\n            }),\n        ])\n\n        const { value, cost_basis, pnl_amt, pnl_pct } = holdingSummary.find(\n            (s): s is Extract<typeof s, { asset_class: null }> => SharedUtil.isNull(s.asset_class)\n        )!\n\n        return {\n            portfolio:\n                value != null\n                    ? {\n                          return: {\n                              '1m': DbUtil.toTrend(returns.amt_1m, returns.pct_1m),\n                              '1y': DbUtil.toTrend(returns.amt_1y, returns.pct_1y),\n                              ytd: DbUtil.toTrend(returns.amt_ytd, returns.pct_ytd),\n                          },\n                          pnl: DbUtil.toTrend(pnl_amt, pnl_pct),\n                          costBasis: DbUtil.toDecimal(cost_basis),\n                          contributions: {\n                              ytd: {\n                                  amount: DbUtil.toDecimal(contributions.ytd_total).negated(),\n                                  monthlyAvg: DbUtil.toDecimal(contributions.ytd_total)\n                                      .dividedBy(DateTime.utc().month)\n                                      .negated(),\n                              },\n                              lastYear: {\n                                  amount: DbUtil.toDecimal(contributions.last_year_total).negated(),\n                                  monthlyAvg: DbUtil.toDecimal(contributions.last_year_total)\n                                      .dividedBy(12)\n                                      .negated(),\n                              },\n                          },\n                          fees: fees ?? new Prisma.Decimal(0),\n                          holdingBreakdown: holdingSummary\n                              .filter(\n                                  (s): s is Extract<typeof s, { asset_class: string }> =>\n                                      !SharedUtil.isNull(s.asset_class)\n                              )\n                              .map((s) => ({\n                                  asset_class: s.asset_class,\n                                  amount: DbUtil.toDecimal(s.value),\n                                  percentage: DbUtil.toDecimal(s.percentage),\n                              })),\n                      }\n                    : undefined,\n        }\n    }\n\n    async getHoldingInsights({\n        holding,\n    }: HoldingInsightOptions): Promise<SharedType.HoldingInsights> {\n        const [\n            {\n                _sum: { amount: dividends },\n            },\n            [{ allocation }],\n        ] = await Promise.all([\n            this.prisma.investmentTransaction.aggregate({\n                _sum: {\n                    amount: true,\n                },\n                where: {\n                    security: { id: holding.securityId },\n                    accountId: holding.accountId,\n                    category: 'dividend',\n                },\n            }),\n            this.prisma.$queryRaw<[{ allocation: Prisma.Decimal | null }]>`\n              WITH security_allocations as (\n                SELECT\n                  security_id,\n                  value / SUM(value) OVER () as \"allocation\"\n                FROM\n                  holdings_enriched\n                WHERE\n                  account_id = ${holding.accountId}\n              )\n              SELECT allocation\n              FROM security_allocations\n              WHERE security_id = ${holding.securityId}\n            `,\n        ])\n\n        return {\n            holding,\n            dividends: DbUtil.toDecimal(dividends),\n            allocation: DbUtil.toDecimal(allocation),\n        }\n    }\n\n    async getPlanInsights({ userId, now = DateTime.utc() }: PlanInsightOptions) {\n        const accountIds = Prisma.sql`(\n          SELECT\n            a.id\n          FROM\n            account a\n            LEFT JOIN account_connection ac ON ac.id = a.account_connection_id\n          WHERE\n            (a.user_id = ${userId} OR ac.user_id = ${userId})\n            AND a.is_active\n        )`\n\n        const [user, projectionAssetBreakdown, projectionLiabilityBreakdown, transactionSummary] =\n            await Promise.all([\n                this.prisma.user.findUniqueOrThrow({ where: { id: userId } }),\n                this._projectionAssetBreakdown(accountIds, now),\n                this._projectionLiabilityBreakdown(accountIds, now),\n                this._transactionSummary(accountIds, now),\n            ])\n\n        const incomeMonthly =\n            user.monthlyIncomeUser ??\n            new Prisma.Decimal(\n                transactionSummary.find((row) => row.type === 'INCOME')?.avg_6mo ?? 0\n            )\n\n        const expensesMonthly =\n            user.monthlyExpensesUser ??\n            new Prisma.Decimal(\n                transactionSummary.find((row) => row.type === 'EXPENSE')?.amount ?? 0\n            )\n\n        return {\n            projectionAssetBreakdown,\n            projectionLiabilityBreakdown,\n            income: incomeMonthly.times(12),\n            expenses: expensesMonthly.times(12),\n        }\n    }\n\n    private _accountSummary(accountIds: Prisma.Sql | number[], now: DateTime) {\n        const timepoints = {\n            now,\n            yearly: now.minus({ years: 1 }),\n            monthly: now.minus({ months: 1 }),\n            weekly: now.minus({ weeks: 1 }),\n        }\n\n        // discriminated union represents valid columns for each grouping\n        type AccountGrouping =\n            | {\n                  grouping: 'classification'\n                  classification: Account['classification']\n              }\n            | {\n                  grouping: 'category'\n                  classification: Account['classification']\n                  category: Account['category']\n              }\n\n        return this.prisma.$queryRaw<\n            (AccountGrouping & {\n                [key in keyof typeof timepoints as `balance_${key}`]: Prisma.Decimal\n            } & {\n                [key in keyof typeof timepoints as `allocation_${key}`]: Prisma.Decimal\n            })[]\n        >`\n          WITH category_rollup AS (\n            SELECT\n              CASE GROUPING(a.classification, a.category)\n                WHEN 0 THEN 'category'\n                WHEN 1 THEN 'classification'\n              END AS grouping,\n              a.classification,\n              a.category,\n              ${Prisma.join(\n                  Object.entries(timepoints).map(([key, date]) => {\n                      const pCol = Prisma.raw(`\"balance_${key}\"`)\n                      const pDate = Prisma.raw(`'${date.toISODate()}'`)\n                      return Prisma.sql`\n                        SUM(\n                          CASE\n                            WHEN a.start_date IS NOT NULL AND ${pDate} < a.start_date THEN 0\n                            ELSE COALESCE(\n                              (SELECT balance FROM account_balance WHERE account_id = a.id AND date <= ${pDate} ORDER BY date DESC LIMIT 1),\n                              (SELECT balance FROM account_balance WHERE account_id = a.id AND date > ${pDate} ORDER BY date ASC LIMIT 1),\n                              a.current_balance,\n                              0\n                            )\n                          END\n                        ) AS ${pCol}\n                      `\n                  })\n              )}\n            FROM\n              account a\n            WHERE\n              a.id IN ${accountIds}\n            GROUP BY\n              GROUPING SETS (\n                (a.classification, a.category),\n                (a.classification)\n              )\n          )\n          SELECT\n            *,\n            ${Prisma.join(\n                Object.entries(timepoints).map(([key]) => {\n                    const pBalanceCol = Prisma.raw(`\"balance_${key}\"`)\n                    const pAllocationCol = Prisma.raw(`\"allocation_${key}\"`)\n                    return Prisma.sql`\n                      CASE grouping\n                        WHEN 'category' THEN COALESCE(${pBalanceCol} / SUM(NULLIF(${pBalanceCol}, 0)) OVER (PARTITION BY grouping, classification), 0)\n                        WHEN 'classification' THEN COALESCE(${pBalanceCol} / SUM(NULLIF(${pBalanceCol}, 0)) OVER (PARTITION BY grouping), 0)\n                      END AS ${pAllocationCol}\n                    `\n                })\n            )}\n          FROM\n            category_rollup\n        `\n    }\n\n    private _assetSummary(accountIds: Prisma.Sql | number[], now: DateTime) {\n        const timepoints = {\n            now,\n            yearly: now.minus({ years: 1 }),\n            monthly: now.minus({ months: 1 }),\n            weekly: now.minus({ weeks: 1 }),\n        }\n\n        // discriminated union represents valid columns for each grouping\n        type AssetGrouping =\n            | {\n                  classification: 'asset'\n                  bucket: 'assets-liquid' | 'assets-illiquid' | 'assets-yielding' | 'assets-other'\n              }\n            | {\n                  classification: 'liability'\n                  bucket: 'debt-good' | 'debt-bad' | 'debt-other'\n              }\n\n        return this.prisma.$queryRaw<\n            (AssetGrouping & {\n                [key in keyof typeof timepoints as `balance_${key}`]: Prisma.Decimal\n            })[]\n        >`\n        SELECT\n          a.classification,\n          x.bucket,\n          ${Prisma.join(\n              Object.entries(timepoints).map(([key, date]) => {\n                  const pCol = Prisma.raw(`\"balance_${key}\"`)\n                  const pDate = Prisma.raw(`'${date.toISODate()}'`)\n                  return Prisma.sql`\n                    SUM(\n                      CASE\n                        WHEN a.start_date IS NOT NULL AND ${pDate} < a.start_date THEN 0\n                        ELSE COALESCE(\n                          (SELECT balance FROM account_balance WHERE account_id = a.id AND date <= ${pDate} ORDER BY date DESC LIMIT 1),\n                          (SELECT balance FROM account_balance WHERE account_id = a.id AND date > ${pDate} ORDER BY date ASC LIMIT 1),\n                          a.current_balance,\n                          0\n                        )\n                      END\n                    ) AS ${pCol}\n                  `\n              })\n          )}\n        FROM\n          account a\n          LEFT JOIN LATERAL (\n            SELECT\n              UNNEST(\n                CASE a.classification\n                  WHEN 'asset' THEN (\n                    CASE\n                      WHEN a.category = 'cash' THEN ARRAY['assets-liquid']\n                      WHEN a.category = 'property' THEN ARRAY['assets-illiquid', 'assets-yielding']\n                      WHEN a.category = 'investment' THEN ARRAY['assets-illiquid', 'assets-yielding']\n                      ELSE ARRAY['assets-other']\n                    END\n                  )\n                  WHEN 'liability' THEN (\n                    CASE\n                      WHEN a.category = 'loan' AND a.subcategory\n                        IN ('business', 'commercial', 'construction', 'mortgage')\n                        THEN ARRAY['debt-good']\n                      WHEN a.category = 'loan' AND a.subcategory\n                        IN ('consumer', 'home equity', 'overdraft', 'line of credit')\n                        THEN ARRAY['debt-bad']\n                      ELSE ARRAY['debt-other']\n                    END\n                  )\n                END\n              ) AS bucket\n          ) x ON true\n        WHERE\n          a.id IN ${accountIds}\n        GROUP BY\n          a.classification, x.bucket\n      `\n    }\n\n    private _transactionSummary(accountIds: Prisma.Sql | number[], now: DateTime) {\n        const pNow = now.toISODate()\n\n        return this.prisma.$queryRaw<\n            {\n                type: TransactionType\n                amount: Prisma.Decimal\n                avg_6mo: Prisma.Decimal\n            }[]\n        >`\n          WITH txns AS (\n            SELECT\n              t.id,\n              t.date,\n              t.amount,\n              t.flow,\n              t.type,\n              t.\"accountType\"\n            FROM\n              transactions_enriched t\n            WHERE\n              t.\"accountId\" IN ${accountIds}\n              AND NOT t.excluded\n              AND t.date >= date_trunc('month', ${pNow}::date - interval '6 months')\n              AND t.date < date_trunc('month', ${pNow}::date)\n          ), txn_type_months AS (\n            SELECT DISTINCT\n              t.type,\n              date_trunc('month', d.date) AS \"month\"\n            FROM\n              unnest(enum_range(NULL::\"TransactionType\")) t(type)\n              CROSS JOIN generate_series(date_trunc('month', (SELECT MIN(date) FROM txns)), now(), '1 month') d(date)\n          ), txn_monthly_agg AS (\n            SELECT\n              t.type,\n              date_trunc('month', t.date) AS \"month\",\n              ABS(SUM(t.amount)) AS amount\n            FROM\n              txns t\n            WHERE\n              -- filter out credit account payments until we have a way to distinguish interest vs balance payments\n              NOT (t.type = 'PAYMENT' AND t.\"accountType\" = 'CREDIT')\n            GROUP BY\n              1, 2\n          ), monthly_summary AS (\n            SELECT\n              ttm.type,\n              ttm.month,\n              COALESCE(tma.amount, 0) AS amount,\n              AVG(COALESCE(tma.amount, 0)) OVER (PARTITION BY ttm.type ORDER BY ttm.month ASC ROWS BETWEEN 5 PRECEDING AND CURRENT ROW) AS \"avg_6mo\"\n            FROM\n              txn_type_months ttm\n              LEFT JOIN txn_monthly_agg tma ON tma.type IS NOT DISTINCT FROM ttm.type AND tma.month = ttm.month\n          )\n          SELECT\n            type,\n            amount,\n            avg_6mo\n          FROM\n            monthly_summary\n          WHERE\n            month = date_trunc('month', ${pNow}::date - interval '1 month');\n        `\n    }\n\n    private _holdingSummary(accountId: Account['id']) {\n        return this.prisma.$queryRaw<\n            (\n                | {\n                      asset_class: string\n                      value: Prisma.Decimal\n                      percentage: Prisma.Decimal\n                      cost_basis: Prisma.Decimal | null\n                      pnl_amt: Prisma.Decimal | null\n                      pnl_pct: Prisma.Decimal | null\n                  }\n                | {\n                      asset_class: null\n                      value: Prisma.Decimal | null\n                      percentage: Prisma.Decimal | null\n                      cost_basis: Prisma.Decimal | null\n                      pnl_amt: Prisma.Decimal | null\n                      pnl_pct: Prisma.Decimal | null\n                  }\n            )[]\n        >`\n          SELECT\n            s.asset_class,\n            SUM(h.value) AS \"value\",\n            ROUND(SUM(h.value) / SUM(SUM(h.value)) OVER (PARTITION BY GROUPING(s.asset_class)), 4) AS \"percentage\",\n            SUM(h.cost_basis) AS \"cost_basis\",\n            SUM(h.value - h.cost_basis) AS \"pnl_amt\",\n            ROUND(SUM(h.value - h.cost_basis) / NULLIF(SUM(h.cost_basis), 0), 4) AS \"pnl_pct\"\n          FROM\n            holdings_enriched h\n            INNER JOIN (\n              SELECT\n                id,\n                asset_class\n              FROM\n                \"security\"\n            ) s ON s.id = h.security_id\n          WHERE\n            h.account_id = ${accountId}\n          GROUP BY\n            ROLLUP (s.asset_class)\n        `\n    }\n\n    private _holdingBreakdown(accountIds: Prisma.Sql | number[]) {\n        type HoldingCategory = 'stocks' | 'fixed_income' | 'cash' | 'crypto' | 'other'\n\n        type HoldingBreakdown =\n            | {\n                  grouping: 'category'\n                  category: HoldingCategory\n                  security: null\n                  value: Holding['value']\n                  allocation: Prisma.Decimal\n              }\n            | {\n                  grouping: 'security'\n                  category: HoldingCategory\n                  security: Pick<Security, 'id' | 'symbol' | 'name'>\n                  value: Holding['value']\n                  allocation: Prisma.Decimal\n              }\n\n        return this.prisma.$queryRaw<HoldingBreakdown[]>`\n          WITH holding_rollup AS (\n            SELECT\n              CASE GROUPING(x.category, h.security_id)\n                WHEN 0 THEN 'security'\n                WHEN 1 THEN 'category'\n              END AS \"grouping\",\n              x.category,\n              h.security_id,\n              SUM(h.value) AS \"value\",\n              SUM(h.value) / NULLIF(SUM(SUM(h.value)) OVER (PARTITION BY GROUPING(x.category, h.security_id)), 0) AS \"allocation\"\n            FROM\n              holdings_enriched h\n              INNER JOIN security s ON s.id = h.security_id\n              LEFT JOIN LATERAL (\n                SELECT\n                  s.asset_class AS \"category\"\n              ) x ON TRUE\n            WHERE\n              h.account_id IN ${accountIds}\n            GROUP BY\n              x.category, ROLLUP (h.security_id)\n          )\n          SELECT\n            r.grouping,\n            r.category,\n            CASE r.grouping WHEN 'security' THEN json_build_object('id', s.id, 'symbol', s.symbol, 'name', s.name) ELSE NULL END AS \"security\",\n            r.value,\n            r.allocation\n          FROM\n            holding_rollup r\n            LEFT JOIN security s ON s.id = r.security_id\n        `\n    }\n\n    private _investmentTransactionSummary(accountId: Account['id'], now: DateTime) {\n        return this.prisma.$queryRaw<\n            [\n                {\n                    last_year_total: Prisma.Decimal\n                    ytd_total: Prisma.Decimal\n                }\n            ]\n        >`\n          WITH cashflow_txns AS (\n            SELECT\n              (date_trunc('month', it.date) + interval '1 month' - interval '1 day')::date AS \"month\",\n              SUM(it.amount) as \"amount\"\n            FROM\n              investment_transaction it\n              LEFT JOIN account a ON a.id = it.account_id\n            WHERE\n              it.account_id = ${accountId}\n              AND it.category = 'transfer'\n              -- Exclude any contributions made prior to the start date since balances will be 0\n              AND (a.start_date is NULL OR it.date >= a.start_date)\n            GROUP BY 1\n          )\n          SELECT\n            COALESCE(sum(amount) FILTER (WHERE month >= date_trunc('year', now())), 0) AS ytd_total,\n            COALESCE(sum(amount) FILTER (WHERE month BETWEEN (date_trunc('year', now()) - interval '1 year') AND date_trunc('year', now())), 0) AS last_year_total\n          FROM\n            cashflow_txns\n        `\n    }\n\n    private _portfolioReturn(accountId: Account['id'], now: DateTime) {\n        const timepoints = {\n            '1m': [now.minus({ months: 1 }), now],\n            '1y': [now.minus({ years: 1 }), now],\n            ytd: [now.startOf('year'), now],\n        }\n\n        return this.prisma.$queryRaw<\n            [\n                {\n                    [key in keyof typeof timepoints as `pct_${key}`]: Prisma.Decimal\n                } & {\n                    [key in keyof typeof timepoints as `amt_${key}`]: Prisma.Decimal\n                }\n            ]\n        >`\n          SELECT\n            *\n          FROM\n            ${Prisma.join(\n                Object.entries(timepoints).map(([key, [start, end]]) => {\n                    const pAccountId = Prisma.raw(accountId.toString())\n                    const pStart = Prisma.raw(`'${start.toISODate()}'`)\n                    const pEnd = Prisma.raw(`'${end.toISODate()}'`)\n                    const pKey = Prisma.raw(key)\n                    return Prisma.sql`\n                      calculate_return_dietz(${pAccountId}, ${pStart}, ${pEnd}) ror_${pKey}(pct_${pKey}, amt_${pKey})\n                    `\n                })\n            )}\n        `\n    }\n\n    private _projectionAssetBreakdown(accountIds: Prisma.Sql, now: DateTime) {\n        const pAccountIds = accountIds\n        const pNow = now.toISODate()\n\n        return this.prisma.$queryRaw<\n            { type: SharedType.ProjectionAssetType; amount: Prisma.Decimal }[]\n        >`\n          SELECT\n            asset_type AS \"type\",\n            SUM(amount) AS \"amount\"\n          FROM (\n            -- non-investment accounts / investment accounts with 0 holdings\n            SELECT\n              x.asset_type,\n              SUM(\n                CASE\n                  WHEN a.start_date IS NOT NULL AND ${pNow}::date < a.start_date THEN 0\n                  ELSE COALESCE(\n                    (SELECT balance FROM account_balance WHERE account_id = a.id AND date <= ${pNow}::date ORDER BY date DESC LIMIT 1),\n                    (SELECT balance FROM account_balance WHERE account_id = a.id AND date > ${pNow}::date ORDER BY date ASC LIMIT 1),\n                    a.current_balance,\n                    0\n                  )\n                END\n              ) AS \"amount\"\n            FROM\n              account a\n              LEFT JOIN LATERAL (\n                SELECT\n                  CASE\n                    WHEN a.type IN ('DEPOSITORY', 'INVESTMENT') THEN 'cash'\n                    WHEN a.type IN ('PROPERTY') THEN 'property'\n                    ELSE 'other'\n                  END AS \"asset_type\"\n              ) x ON true\n            WHERE\n              a.id IN ${pAccountIds}\n              AND a.classification = 'asset'\n              AND (a.type <> 'INVESTMENT' OR NOT EXISTS (SELECT 1 FROM holding h WHERE h.account_id = a.id))\n            GROUP BY\n              x.asset_type\n            UNION ALL\n            -- investment accounts\n            SELECT\n              s.asset_class::text AS \"asset_type\",\n              SUM(h.value) AS \"amount\"\n            FROM\n              holdings_enriched h\n              INNER JOIN (\n                SELECT\n                  id,\n                  asset_class\n                FROM\n                  \"security\"\n              ) s ON s.id = h.security_id\n            WHERE\n              h.account_id IN ${pAccountIds}\n            GROUP BY\n              s.asset_class\n          ) x\n          GROUP BY\n            1\n        `\n    }\n\n    private _projectionLiabilityBreakdown(accountIds: Prisma.Sql, now: DateTime) {\n        const pAccountIds = accountIds\n        const pNow = now.toISODate()\n\n        return this.prisma.$queryRaw<\n            { type: SharedType.ProjectionLiabilityType; amount: Prisma.Decimal }[]\n        >`\n          SELECT\n            x.asset_type AS \"type\",\n            SUM(\n              CASE\n                WHEN a.start_date IS NOT NULL AND ${pNow}::date < a.start_date THEN 0\n                ELSE COALESCE(\n                  (SELECT balance FROM account_balance WHERE account_id = a.id AND date <= ${pNow}::date ORDER BY date DESC LIMIT 1),\n                  (SELECT balance FROM account_balance WHERE account_id = a.id AND date > ${pNow}::date ORDER BY date ASC LIMIT 1),\n                  a.current_balance,\n                  0\n                )\n              END\n            ) AS \"amount\"\n          FROM\n            account a\n            LEFT JOIN LATERAL (\n              SELECT\n                CASE\n                  WHEN a.type IN ('CREDIT') THEN 'credit'\n                  WHEN a.type IN ('LOAN') THEN 'loan'\n                  ELSE 'other'\n                END AS \"asset_type\"\n            ) x ON true\n          WHERE\n            a.id IN ${pAccountIds}\n            AND a.classification = 'liability'\n          GROUP BY\n            x.asset_type\n        `\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account-balance/balance-sync.strategy.ts",
    "content": "import { DateUtil } from '@maybe-finance/shared'\nimport type { Account, PrismaClient } from '@prisma/client'\nimport { DateTime } from 'luxon'\n\nexport interface IBalanceSyncStrategy {\n    syncAccountBalances(account: Account): Promise<void>\n}\n\nexport abstract class BalanceSyncStrategyBase implements IBalanceSyncStrategy {\n    constructor(protected readonly prisma: PrismaClient) {}\n\n    async syncAccountBalances(account: Account) {\n        const [{ date }] = await this.prisma.$queryRaw<\n            [{ date: Date }]\n        >`SELECT account_value_start_date(${account.id}::int) AS date`\n\n        const startDate = DateTime.max(\n            DateUtil.MIN_SUPPORTED_DATE,\n            DateTime.fromJSDate(date, { zone: 'utc' })\n        )\n\n        await this.syncBalances(account, startDate)\n    }\n\n    protected abstract syncBalances(account: Account, startDate: DateTime): Promise<void>\n}\n\nexport interface IBalanceSyncStrategyFactory {\n    for(account: Account): IBalanceSyncStrategy\n}\n\nexport class BalanceSyncStrategyFactory implements IBalanceSyncStrategyFactory {\n    constructor(private readonly strategies: Record<Account['type'], IBalanceSyncStrategy>) {}\n\n    for(account: Account): IBalanceSyncStrategy {\n        const strategy = this.strategies[account.type]\n        if (!strategy) throw new Error(`cannot find strategy for account: ${account.id}`)\n        return strategy\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account-balance/index.ts",
    "content": "export * from './balance-sync.strategy'\nexport * from './investment-transaction-balance-sync.strategy'\nexport * from './transaction-balance-sync.strategy'\nexport * from './valuation-balance-sync.strategy'\nexport * from './loan-balance-sync.strategy'\n"
  },
  {
    "path": "libs/server/features/src/account-balance/investment-transaction-balance-sync.strategy.ts",
    "content": "import type { Account, PrismaClient } from '@prisma/client'\nimport { Prisma } from '@prisma/client'\nimport type { DateTime } from 'luxon'\nimport type { Logger } from 'winston'\nimport { BalanceSyncStrategyBase } from './balance-sync.strategy'\n\nexport class InvestmentTransactionBalanceSyncStrategy extends BalanceSyncStrategyBase {\n    constructor(private readonly logger: Logger, prisma: PrismaClient) {\n        super(prisma)\n    }\n\n    async syncBalances(account: Account, startDate: DateTime) {\n        const pAccountId = Prisma.raw(account.id.toString())\n        const pStart = Prisma.raw(`'${startDate.toISODate()}'`)\n\n        await this.prisma.$executeRaw`\n          WITH holdings AS (\n            -- historical (artificial) holdings (eg. user bought 100 shares, then sold 100 shares, and now no longer has a holding record)\n            (\n              SELECT DISTINCT ON (it.account_id, it.security_id)\n                CONCAT(it.account_id, '|', it.security_id) AS id,\n                it.security_id,\n                0 AS quantity,\n                0 AS value\n              FROM\n                investment_transaction it\n                LEFT JOIN holding h ON h.account_id = it.account_id AND h.security_id = it.security_id\n              WHERE\n                it.account_id = ${pAccountId}\n                AND it.security_id IS NOT NULL\n                AND h.id IS NULL\n            )\n            UNION\n            -- current holdings\n            (\n              SELECT\n                h.id::text,\n                h.security_id,\n                h.quantity,\n                h.value\n              FROM\n                holding h\n                INNER JOIN security s ON s.id = h.security_id\n              WHERE\n                h.account_id = ${pAccountId}\n                AND NOT h.excluded\n                AND NOT s.is_brokerage_cash\n            )\n          ), holdings_daily AS (\n            SELECT\n              d.date,\n              sp.price,\n              s.shares_per_contract,\n              h.quantity - SUM(COALESCE(it.quantity, 0)) OVER (PARTITION BY h.id ORDER BY it.date DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) as quantity,\n              h.value as current_value\n            FROM\n              holdings h\n              CROSS JOIN (\n                SELECT generate_series(${pStart}, now(), '1d')::date\n              ) d(date)\n              INNER JOIN security s ON s.id = h.security_id\n              LEFT JOIN (\n                SELECT\n                  time_bucket_gapfill('1d', it.date) AS date,\n                  it.security_id,\n                  COALESCE(SUM(CASE it.flow WHEN 'INFLOW' THEN -ABS(it.quantity) ELSE ABS(it.quantity) END), 0) AS quantity\n                FROM\n                  investment_transaction it\n                WHERE\n                  it.account_id = ${pAccountId}\n                  AND it.date BETWEEN ${pStart} AND now()\n                  -- filter for transactions that modify a position\n                  AND it.category IN ('buy', 'sell', 'transfer')\n                GROUP BY\n                  1, 2\n              ) it ON it.security_id = s.id AND it.date = d.date\n              LEFT JOIN (\n                SELECT\n                  time_bucket_gapfill('1d', sp.date) AS date,\n                  sp.security_id,\n                  locf(avg(sp.price_close)) AS price\n                FROM\n                  security_pricing sp\n                WHERE\n                  sp.date BETWEEN ${pStart} AND now()\n                  AND sp.security_id IN (SELECT DISTINCT security_id FROM holdings)\n                GROUP BY\n                  1, 2\n              ) sp ON sp.security_id = s.id AND sp.date = d.date\n          ), holding_balances AS (\n            SELECT\n              hd.date,\n              SUM(COALESCE(hd.price * hd.quantity * COALESCE(hd.shares_per_contract, 1), hd.current_value)) AS balance\n            FROM\n              holdings_daily hd\n            GROUP BY\n              hd.date\n          ), cash_balances AS (\n            SELECT\n              it.date,\n              -- IF available_balance is null/0 AND account has 0 holdings THEN we use current_balance as a constant historical balance\n              COALESCE(NULLIF(a.available_balance, 0), (SELECT CASE WHEN EXISTS (SELECT 1 FROM holdings) THEN 0 ELSE a.current_balance END))\n              + COALESCE(SUM(it.amount) OVER (ORDER BY it.date DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 0) AS balance,\n              it.inflows,\n              it.outflows\n            FROM\n              account a\n              LEFT JOIN LATERAL (\n                SELECT\n                  time_bucket_gapfill('1d', it.date) AS date,\n                  COALESCE(SUM(it.amount), 0) AS amount,\n                  COALESCE(SUM(ABS(it.amount)) FILTER (WHERE it.flow = 'INFLOW'), 0) AS inflows,\n                  COALESCE(SUM(ABS(it.amount)) FILTER (WHERE it.flow = 'OUTFLOW'), 0) AS outflows\n                FROM\n                  investment_transaction it\n                WHERE\n                  it.account_id = a.id\n                  AND it.date BETWEEN ${pStart} AND now()\n                GROUP BY\n                  1\n              ) it ON TRUE\n            WHERE\n              a.id = ${pAccountId}\n          )\n          INSERT INTO account_balance (account_id, date, balance, inflows, outflows)\n          SELECT\n            ${pAccountId},\n            COALESCE(hb.date, cb.date) AS date,\n            COALESCE(hb.balance + cb.balance, cb.balance, 0) AS balance,\n            cb.inflows,\n            cb.outflows\n          FROM\n            holding_balances hb\n            FULL OUTER JOIN cash_balances cb ON cb.date = hb.date\n          ON CONFLICT (account_id, date) DO UPDATE\n          SET\n            balance = EXCLUDED.balance,\n            inflows = EXCLUDED.inflows,\n            outflows = EXCLUDED.outflows;\n        `\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account-balance/loan-balance-sync.strategy.ts",
    "content": "import { DateTime } from 'luxon'\nimport type { Account, PrismaClient } from '@prisma/client'\nimport { Prisma } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport { BalanceSyncStrategyBase } from './balance-sync.strategy'\n\nexport class LoanBalanceSyncStrategy extends BalanceSyncStrategyBase {\n    constructor(private readonly logger: Logger, prisma: PrismaClient) {\n        super(prisma)\n    }\n\n    async syncBalances(account: Account, startDate: DateTime) {\n        if (!account.loan) {\n            this.logger.warn(`account ${account.id} is missing loan data, skipping balance sync`)\n            return\n        }\n\n        const pAccountId = account.id\n        const pStart = Prisma.raw(`'${startDate.toISODate()}'`)\n\n        const {\n            _min: { date: minDate },\n        } = await this.prisma.transaction.aggregate({\n            where: { accountId: account.id },\n            _min: { date: true },\n        })\n\n        // the cutoff date is one day prior to the first date we have transaction data for\n        // this serves as our stopping point for interpolation from the origination date\n        const pCutoffDate = minDate\n            ? Prisma.raw(\n                  `'${DateTime.fromJSDate(minDate, { zone: 'utc' })\n                      .minus({ days: 1 })\n                      .toISODate()}'`\n              )\n            : Prisma.raw('now()')\n\n        await this.prisma.$executeRaw`\n          WITH interpolated_balances AS (\n            -- interpolate balances from origination -> earliest transaction\n            SELECT\n              time_bucket_gapfill('1d', b.date) AS \"date\",\n              interpolate(avg(b.balance)) AS \"balance\"\n            FROM\n              (\n                SELECT\n                  (a.loan->>'originationDate')::date AS \"date\",\n                  (a.loan->'originationPrincipal')::numeric AS \"balance\"\n                FROM\n                  account a\n                WHERE\n                  a.id = ${pAccountId} AND a.loan IS NOT NULL\n                UNION\n                SELECT\n                  ${pCutoffDate}::date AS \"date\",\n                  COALESCE(a.current_balance - COALESCE((SELECT SUM(amount) FROM \"transaction\" WHERE account_id = a.id AND date > ${pCutoffDate}), 0), 0) AS \"balance\"\n                FROM\n                  account a\n                WHERE\n                  a.id = ${pAccountId}\n              ) b\n            WHERE\n              b.date >= ${pStart}\n              AND b.date <= ${pCutoffDate}\n            GROUP BY\n              1\n          ), txn_balances AS (\n            -- compute balances from now -> earliest transaction (using standard transaction calculation approach)\n            SELECT\n              t.date,\n              (\n                COALESCE(a.current_balance, a.available_balance) -\n                COALESCE(SUM(t.net_flows) OVER (ORDER BY t.date DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 0)\n              ) as balance,\n              SUM(t.inflows) OVER (PARTITION BY t.date) as inflows,\n              SUM(t.outflows) OVER (PARTITION BY t.date) as outflows\n            FROM\n              account a,\n              (\n                SELECT\n                  time_bucket_gapfill('1d', t.date) AS date,\n                  COALESCE(SUM(t.amount), 0) as net_flows,\n                  COALESCE(SUM(ABS(amount)) FILTER (WHERE flow = 'INFLOW'), 0) as inflows,\n                  COALESCE(SUM(amount) FILTER (WHERE flow = 'OUTFLOW'), 0) as outflows\n                FROM\n                  transaction t\n                WHERE\n                  t.account_id = ${pAccountId}\n                  AND t.date > ${pCutoffDate}\n                  AND t.date <= now()\n                GROUP BY\n                  1\n              ) t\n            WHERE\n              a.id = ${pAccountId}\n          ), combined_balances AS (\n            -- combine results\n            SELECT\n              date,\n              balance,\n              NULL AS \"inflows\",\n              NULL AS \"outflows\"\n            FROM\n              interpolated_balances ib\n            UNION\n            SELECT\n              date,\n              balance,\n              inflows,\n              outflows\n            FROM\n              txn_balances tb\n          )\n          INSERT INTO account_balance (account_id, date, balance, inflows, outflows)\n          SELECT\n            ${pAccountId},\n            date,\n            balance,\n            inflows,\n            outflows\n          FROM\n            combined_balances\n          WHERE\n            balance IS NOT NULL -- balance can be NULL for accounts w/o valid loan info\n          ON CONFLICT (account_id, date) DO UPDATE\n          SET\n            balance = EXCLUDED.balance,\n            inflows = EXCLUDED.inflows,\n            outflows = EXCLUDED.outflows\n        `\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account-balance/transaction-balance-sync.strategy.ts",
    "content": "import type { DateTime } from 'luxon'\nimport type { Account, PrismaClient } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport { BalanceSyncStrategyBase } from './balance-sync.strategy'\n\nexport class TransactionBalanceSyncStrategy extends BalanceSyncStrategyBase {\n    constructor(private readonly logger: Logger, prisma: PrismaClient) {\n        super(prisma)\n    }\n\n    async syncBalances(account: Account, startDate: DateTime) {\n        const pAccountId = account.id\n        const pStart = startDate.toJSDate()\n\n        await this.prisma.$executeRaw`\n          INSERT INTO account_balance (account_id, date, balance, inflows, outflows)\n          SELECT\n            t.account_id,\n            t.date,\n            (\n              COALESCE(a.current_balance, a.available_balance) +\n              (CASE WHEN a.classification = 'liability' THEN -1 ELSE 1 END) *\n              COALESCE(SUM(t.net_flows) OVER (PARTITION BY t.account_id ORDER BY t.date DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 0)\n            ) as balance,\n            SUM(t.inflows) OVER (PARTITION BY t.account_id, t.date) as inflows,\n            SUM(t.outflows) OVER (PARTITION BY t.account_id, t.date) as outflows\n          FROM\n            (\n              SELECT\n                time_bucket_gapfill('1d', t.date) AS date,\n                t.account_id,\n                COALESCE(SUM(t.amount), 0) as net_flows,\n\t\t            COALESCE(SUM(ABS(amount)) FILTER (WHERE flow = 'INFLOW'), 0) as inflows,\n\t\t            COALESCE(SUM(amount) FILTER (WHERE flow = 'OUTFLOW'), 0) as outflows\n              FROM\n                transaction t\n              WHERE\n                t.account_id = ${pAccountId}\n                AND t.date BETWEEN ${pStart} AND now()\n              GROUP BY\n                1, 2\n            ) t\n            INNER JOIN account a ON a.id = t.account_id\n          WHERE\n            COALESCE(a.current_balance, a.available_balance) IS NOT NULL\n          ON CONFLICT (account_id, date) DO UPDATE\n          SET\n            inflows = EXCLUDED.inflows,\n            outflows = EXCLUDED.outflows,\n            balance = EXCLUDED.balance\n        `\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account-balance/valuation-balance-sync.strategy.ts",
    "content": "import type { DateTime } from 'luxon'\nimport type { Account, PrismaClient } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport { BalanceSyncStrategyBase } from './balance-sync.strategy'\n\nexport class ValuationBalanceSyncStrategy extends BalanceSyncStrategyBase {\n    constructor(private readonly logger: Logger, prisma: PrismaClient) {\n        super(prisma)\n    }\n\n    async syncBalances(account: Account, startDate: DateTime) {\n        const pAccountId = account.id\n        const pStart = startDate.toJSDate()\n\n        await this.prisma.$executeRaw`\n          INSERT INTO account_balance (account_id, date, balance)\n          SELECT\n            v.account_id,\n            v.date,\n            COALESCE(v.interpolate, v.locf) AS balance\n          FROM\n            (\n              SELECT\n                time_bucket_gapfill('1d', v.date) AS date,\n                v.account_id,\n                interpolate(avg(v.amount)),\n                locf(avg(v.amount))\n              FROM\n                valuation v\n              WHERE\n                v.account_id = ${pAccountId}\n                AND v.date BETWEEN ${pStart} AND now()\n              GROUP BY\n                1, 2\n            ) v\n          ON CONFLICT (account_id, date) DO UPDATE\n          SET inflows = EXCLUDED.inflows, outflows = EXCLUDED.outflows, balance = EXCLUDED.balance\n        `\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account-connection/account-connection.processor.ts",
    "content": "import type { AccountConnection } from '@prisma/client'\nimport type { SyncConnectionQueueJobData } from '@maybe-finance/server/shared'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type { Logger } from 'winston'\nimport type { IAccountConnectionProviderFactory } from './account-connection.provider'\nimport type { IAccountConnectionService } from './account-connection.service'\nimport { ErrorUtil, ServerUtil } from '@maybe-finance/server/shared'\nimport * as Sentry from '@sentry/node'\nimport type { ITransactionService } from '../transaction'\n\nexport interface IAccountConnectionProcessor {\n    sync(\n        jobData: SyncConnectionQueueJobData,\n        setProgress: (progress: SharedType.AccountSyncProgress) => Promise<void>\n    ): Promise<void>\n}\n\nexport class AccountConnectionProcessor implements IAccountConnectionProcessor {\n    constructor(\n        private readonly logger: Logger,\n        private readonly connectionService: IAccountConnectionService,\n        private readonly transactionService: ITransactionService,\n        private readonly providers: IAccountConnectionProviderFactory\n    ) {}\n\n    async sync(\n        jobData: SyncConnectionQueueJobData,\n        setProgress: (progress: SharedType.AccountSyncProgress) => Promise<void>\n    ) {\n        const connection = await this.connectionService.get(jobData.accountConnectionId)\n        const provider = this.providers.for(connection)\n\n        await ServerUtil.useSync<AccountConnection>({\n            onStart: async (connection) => {\n                this.logger.info(`[sync.onStart] connection=${connection.id}`, {\n                    connection: connection.id,\n                })\n                await this.connectionService.update(connection.id, {\n                    syncStatus: 'SYNCING',\n                })\n            },\n            sync: async (connection) => {\n                await Promise.all([\n                    setProgress({ progress: 0.2, description: 'Syncing data' }),\n                    provider.sync(connection, jobData.options),\n                ])\n            },\n            onSyncError: async (connection, error) => {\n                this.logger.error(`[sync.onSyncError] connection=${connection.id}`, {\n                    connection: connection.id,\n                    error: ErrorUtil.parseError(error),\n                })\n\n                const err = ErrorUtil.parseError(error)\n                Sentry.captureException(err, {\n                    level: 'error',\n                    tags: err.sentryTags,\n                    contexts: err.sentryContexts,\n                })\n\n                await Promise.all([\n                    setProgress({ progress: 0.75, description: 'Syncing data' }),\n                    provider.onSyncEvent(connection, { type: 'error', error }),\n                ])\n            },\n            onSyncSuccess: async (connection) => {\n                this.logger.info(`[sync.onSyncSuccess] connection=${connection.id}`, {\n                    connection: connection.id,\n                })\n\n                await Promise.all([\n                    setProgress({ progress: 0.4, description: 'Syncing data' }),\n                    provider.onSyncEvent(connection, { type: 'success' }),\n                ])\n\n                await Promise.all([\n                    setProgress({ progress: 0.6, description: 'Cleaning data' }),\n                    this.transactionService.markTransfers(connection.userId),\n                ])\n\n                // Temporarily disable\n                // await Promise.all([\n                //     setProgress({ progress: 0.5, description: 'Syncing data' }),\n                //     this.connectionService.syncSecurities(connection.id),\n                // ])\n\n                await Promise.all([\n                    setProgress({ progress: 0.75, description: 'Updating balances' }),\n                    this.connectionService.syncBalances(connection.id),\n                ])\n            },\n            onEnd: async (connection) => {\n                this.logger.info(`[sync.onEnd] connection=${connection.id}`, {\n                    connection: connection.id,\n                })\n\n                await Promise.all([\n                    setProgress({ progress: 0.9, description: 'Finishing up' }),\n                    this.connectionService.update(connection.id, {\n                        syncStatus: 'IDLE',\n                    }),\n                ])\n            },\n        })(connection)\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account-connection/account-connection.provider.ts",
    "content": "import type { SyncConnectionOptions } from '@maybe-finance/server/shared'\nimport type { AccountConnection } from '@prisma/client'\n\nexport type AccountConnectionSyncEvent = { type: 'error'; error: unknown } | { type: 'success' }\n\nexport interface IAccountConnectionProvider {\n    sync(connection: AccountConnection, options?: SyncConnectionOptions): Promise<void>\n    onSyncEvent(connection: AccountConnection, event: AccountConnectionSyncEvent): Promise<void>\n    delete(connection: AccountConnection): Promise<void>\n}\n\nexport interface IAccountConnectionProviderFactory {\n    for(connection: AccountConnection): IAccountConnectionProvider\n}\n\nexport class AccountConnectionProviderFactory implements IAccountConnectionProviderFactory {\n    constructor(\n        private readonly providers: Record<AccountConnection['type'], IAccountConnectionProvider>\n    ) {}\n\n    for(connection: AccountConnection): IAccountConnectionProvider {\n        const provider = this.providers[connection.type]\n        if (!provider) throw new Error(`Unsupported connection type: ${connection.type}`)\n        return provider\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account-connection/account-connection.service.ts",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport type { SyncConnectionOptions, SyncConnectionQueue } from '@maybe-finance/server/shared'\nimport type { AccountConnection, User, PrismaClient, Prisma } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport type { IAccountConnectionProviderFactory } from './account-connection.provider'\nimport type { IBalanceSyncStrategyFactory } from '../account-balance'\nimport type { ISecurityPricingService } from '../security-pricing'\nimport { DateTime } from 'luxon'\n\nexport interface IAccountConnectionService {\n    get(id: AccountConnection['id']): Promise<SharedType.ConnectionWithAccounts>\n    getAll(userId: User['id']): Promise<SharedType.ConnectionWithAccounts[]>\n    sync(id: AccountConnection['id'], options?: SyncConnectionOptions): Promise<AccountConnection>\n    syncBalances(id: AccountConnection['id']): Promise<AccountConnection>\n    syncSecurities(id: AccountConnection['id']): Promise<void>\n    disconnect(id: AccountConnection['id']): Promise<AccountConnection>\n    reconnect(id: AccountConnection['id']): Promise<AccountConnection>\n    update(\n        id: AccountConnection['id'],\n        data: Prisma.AccountConnectionUncheckedUpdateInput\n    ): Promise<AccountConnection>\n    delete(id: AccountConnection['id']): Promise<AccountConnection>\n}\n\nexport class AccountConnectionService implements IAccountConnectionService {\n    constructor(\n        private readonly logger: Logger,\n        private readonly prisma: PrismaClient,\n        private readonly providers: IAccountConnectionProviderFactory,\n        private readonly balanceSyncStrategyFactory: IBalanceSyncStrategyFactory,\n        private readonly securityPricingService: ISecurityPricingService,\n        private readonly queue: SyncConnectionQueue\n    ) {}\n\n    async get(id: AccountConnection['id']) {\n        return this.prisma.accountConnection.findUniqueOrThrow({\n            where: { id },\n            include: { accounts: { orderBy: { id: 'asc' } } },\n        })\n    }\n\n    async getAll(userId: User['id']) {\n        return this.prisma.accountConnection.findMany({\n            where: { userId },\n            include: { accounts: { orderBy: { id: 'asc' } } },\n            orderBy: { id: 'asc' },\n        })\n    }\n\n    async sync(id: AccountConnection['id'], options?: SyncConnectionOptions) {\n        const connection = await this.prisma.accountConnection.findUniqueOrThrow({\n            where: { id },\n            include: { user: true },\n        })\n\n        await this.queue.add('sync-connection', {\n            accountConnectionId: connection.id,\n            options,\n        })\n\n        return this.prisma.accountConnection.update({\n            where: { id },\n            data: { syncStatus: 'PENDING' },\n        })\n    }\n\n    async syncBalances(id: AccountConnection['id']) {\n        const connection = await this.get(id)\n\n        const profiler = this.logger.startTimer()\n\n        await Promise.all(\n            connection.accounts.map((account) =>\n                this.balanceSyncStrategyFactory.for(account).syncAccountBalances(account)\n            )\n        )\n\n        profiler.done({ message: `synced connection ${id} balances` })\n\n        return connection\n    }\n\n    async syncSecurities(id: AccountConnection['id']) {\n        const securities = await this.prisma.security.findMany({\n            where: {\n                AND: [\n                    {\n                        OR: [\n                            {\n                                holdings: {\n                                    some: {\n                                        account: {\n                                            accountConnectionId: id,\n                                            isActive: true,\n                                        },\n                                    },\n                                },\n                            },\n                            {\n                                investmentTransactions: {\n                                    some: {\n                                        account: {\n                                            accountConnectionId: id,\n                                            isActive: true,\n                                        },\n                                    },\n                                },\n                            },\n                        ],\n                    },\n                    {\n                        OR: [\n                            { pricingLastSyncedAt: null },\n                            {\n                                pricingLastSyncedAt: {\n                                    lt: DateTime.now().minus({ days: 1 }).toJSDate(),\n                                },\n                            },\n                        ],\n                    },\n                ],\n            },\n            select: {\n                assetClass: true,\n                currencyCode: true,\n                id: true,\n                symbol: true,\n            },\n        })\n\n        const profiler = this.logger.startTimer()\n\n        await Promise.allSettled(\n            securities.map((security) => this.securityPricingService.syncSecurity(security))\n        )\n\n        profiler.done({ message: `synced connection ${id} securities (${securities.length})` })\n    }\n\n    async disconnect(id: AccountConnection['id']) {\n        const [connection] = await this.prisma.$transaction([\n            this.prisma.accountConnection.update({\n                where: { id },\n                data: {\n                    status: 'DISCONNECTED',\n                },\n            }),\n            this.prisma.account.updateMany({\n                where: { accountConnectionId: id },\n                data: {\n                    isActive: false,\n                },\n            }),\n        ])\n\n        this.logger.info(\n            `Disconnected connection id=${connection.id} type=${connection.type} provider_connection_id=${connection.tellerEnrollmentId}`\n        )\n\n        return connection\n    }\n\n    async reconnect(id: AccountConnection['id']) {\n        const [connection] = await this.prisma.$transaction([\n            this.prisma.accountConnection.update({\n                where: { id },\n                data: {\n                    status: 'OK',\n                },\n            }),\n            this.prisma.account.updateMany({\n                where: { accountConnectionId: id },\n                data: {\n                    isActive: true,\n                },\n            }),\n        ])\n\n        this.logger.info(\n            `Reconnected connection id=${connection.id} type=${connection.type} provider_connection_id=${connection.tellerEnrollmentId}`\n        )\n\n        return connection\n    }\n\n    async update(id: AccountConnection['id'], data: Prisma.AccountConnectionUncheckedUpdateInput) {\n        return this.prisma.accountConnection.update({\n            where: { id },\n            data,\n        })\n    }\n\n    async delete(id: AccountConnection['id']) {\n        const connection = await this.prisma.accountConnection.findUniqueOrThrow({\n            where: { id },\n        })\n\n        await this.providers.for(connection).delete(connection)\n\n        const deletedConnection = await this.prisma.accountConnection.delete({\n            where: { id: connection.id },\n        })\n\n        this.logger.info(\n            `Deleted connection id=${deletedConnection.id} type=${connection.type} provider_connection_id=${connection.tellerEnrollmentId}`\n        )\n\n        return deletedConnection\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/account-connection/index.ts",
    "content": "export * from './account-connection.service'\nexport * from './account-connection.processor'\nexport * from './account-connection.provider'\n"
  },
  {
    "path": "libs/server/features/src/auth-user/auth-user.service.ts",
    "content": "import type { AuthUser, PrismaClient, Prisma } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport bcrypt from 'bcrypt'\n\nexport interface IAuthUserService {\n    get(id: AuthUser['id']): Promise<AuthUser>\n    delete(id: AuthUser['id']): Promise<AuthUser>\n}\n\nexport class AuthUserService implements IAuthUserService {\n    constructor(private readonly logger: Logger, private readonly prisma: PrismaClient) {}\n\n    async get(id: AuthUser['id']) {\n        return await this.prisma.authUser.findUniqueOrThrow({\n            where: { id },\n        })\n    }\n\n    async getByEmail(email: AuthUser['email']) {\n        if (!email) throw new Error('No email provided')\n        return await this.prisma.authUser.findUnique({\n            where: { email },\n        })\n    }\n\n    async updatePassword(id: AuthUser['id'], oldPassword: string, newPassword: string) {\n        const authUser = await this.get(id)\n        const isMatch = await bcrypt.compare(oldPassword, authUser.password!)\n        if (!isMatch) {\n            throw new Error('Could not reset password')\n        } else {\n            const hashedPassword = await bcrypt.hash(newPassword, 10)\n            return await this.prisma.authUser.update({\n                where: { id },\n                data: { password: hashedPassword },\n            })\n        }\n    }\n\n    async create(data: Prisma.AuthUserCreateInput & { firstName: string; lastName: string }) {\n        const authUser = await this.prisma.authUser.create({ data: { ...data } })\n        return authUser\n    }\n\n    async delete(id: AuthUser['id']) {\n        const authUser = await this.get(id)\n\n        this.logger.info(`Removing user ${authUser.id} from Prisma`)\n        const user = await this.prisma.authUser.delete({ where: { id } })\n        return user\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/auth-user/index.ts",
    "content": "export * from './auth-user.service'\n"
  },
  {
    "path": "libs/server/features/src/email/email.processor.ts",
    "content": "import type { Logger } from 'winston'\nimport type { PrismaClient } from '@prisma/client'\nimport type { SendEmailQueueJobData } from '@maybe-finance/server/shared'\nimport type { EmailService } from './email.service'\nimport { DateTime } from 'luxon'\n\nexport interface IEmailProcessor {\n    send(jobData: SendEmailQueueJobData): Promise<void>\n    sendTrialEndReminders(): Promise<void>\n}\n\nexport class EmailProcessor implements IEmailProcessor {\n    constructor(\n        private readonly logger: Logger,\n        private readonly prisma: PrismaClient,\n        private readonly emailService: EmailService\n    ) {}\n\n    async send(jobData: SendEmailQueueJobData) {\n        if ('type' in jobData) {\n            switch (jobData.type) {\n                case 'plain':\n                    if (Array.isArray(jobData.messages)) {\n                        this.emailService.send(jobData.messages)\n                    } else {\n                        this.emailService.send([jobData.messages])\n                    }\n                    break\n                case 'template':\n                    if (this.emailService.sendTemplate === undefined) {\n                        this.logger.error(\n                            'Attempted to send template email but email service does not support templates'\n                        )\n                        return\n                    }\n                    if (Array.isArray(jobData.messages)) {\n                        this.emailService.sendTemplate(jobData.messages)\n                    } else {\n                        this.emailService.sendTemplate([jobData.messages])\n                    }\n                    break\n                case 'trial-reminders':\n                    this.sendTrialEndReminders()\n                    break\n            }\n        } else {\n            this.logger.warn('Failed to process email send job', jobData)\n        }\n    }\n\n    async sendTrialEndReminders() {\n        // Find all users with trials expiring in under 3 days which haven't been notified in at least 7 days\n        const users = await this.prisma.user.findMany({\n            where: {\n                trialEnd: {\n                    gt: DateTime.now().toJSDate(),\n                    lt: DateTime.now().plus({ days: 3 }).toJSDate(),\n                },\n                stripeCancelAt: null,\n                OR: [\n                    { trialReminderSent: null },\n                    {\n                        trialReminderSent: {\n                            lt: DateTime.now().minus({ days: 7 }).toJSDate(),\n                        },\n                    },\n                ],\n            },\n        })\n\n        if (!users.length || !this.emailService.sendTemplate) return\n\n        const results = await this.emailService.sendTemplate(\n            users.map((user) => ({\n                to: user.email,\n                template: {\n                    alias: 'trial-ending',\n                    model: {\n                        endDate: DateTime.fromJSDate(user.trialEnd!).toFormat('MMM dd, yyyy'),\n                    },\n                },\n            }))\n        )\n\n        const successful = results\n            .map((response, idx) => ({\n                userId: users[idx]?.id,\n                response,\n            }))\n            .filter(({ userId, response }) => {\n                if (response.ErrorCode !== 0)\n                    this.logger.error(`Failed to send trial end notification to user ${userId}`)\n\n                return response.ErrorCode === 0\n            })\n\n        if (successful.length) {\n            await this.prisma.user.updateMany({\n                where: {\n                    id: {\n                        in: successful.map(({ userId }) => userId),\n                    },\n                },\n                data: {\n                    trialReminderSent: DateTime.now().toJSDate(),\n                },\n            })\n        }\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/email/email.schema.ts",
    "content": "import { z } from 'zod'\n\n// This schema should be kept in sync with templates maintained in the Postmark dashboard\nexport const EmailTemplateSchema = z.object({\n    alias: z.literal('trial-ending'),\n    model: z.object({\n        endDate: z.string(),\n    }),\n})\n"
  },
  {
    "path": "libs/server/features/src/email/email.service.ts",
    "content": "import type { Logger } from 'winston'\nimport type { ServerClient as PostmarkServerClient } from 'postmark'\nimport type { Transporter } from 'nodemailer'\nimport { PostmarkEmailProvider, SmtpProvider } from './providers'\nimport type { SharedType } from '@maybe-finance/shared'\n\nexport interface IEmailProvider {\n    send(messages: SharedType.PlainEmailMessage): Promise<SharedType.EmailSendingResponse>\n    send(messages: SharedType.PlainEmailMessage[]): Promise<SharedType.EmailSendingResponse[]>\n    send(\n        messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[]\n    ): Promise<any | any[]>\n    sendTemplate?(\n        messages: SharedType.TemplateEmailMessage\n    ): Promise<SharedType.EmailSendingResponse>\n    sendTemplate?(\n        messages: SharedType.TemplateEmailMessage[]\n    ): Promise<SharedType.EmailSendingResponse[]>\n    sendTemplate?(\n        messages: SharedType.TemplateEmailMessage | SharedType.TemplateEmailMessage[]\n    ): Promise<SharedType.EmailSendingResponse | SharedType.EmailSendingResponse[]>\n}\n\nexport class EmailService implements IEmailProvider {\n    private emailProvider: IEmailProvider | undefined\n    constructor(\n        private readonly logger: Logger,\n        private readonly client: PostmarkServerClient | Transporter | undefined,\n        private readonly defaultAddresses: { from: string; replyTo?: string }\n    ) {\n        const provider = process.env.NX_EMAIL_PROVIDER\n\n        switch (provider) {\n            case 'postmark':\n                this.emailProvider = new PostmarkEmailProvider(\n                    this.logger.child({ service: 'PostmarkEmailProvider' }),\n                    this.client as PostmarkServerClient,\n                    this.defaultAddresses\n                )\n                break\n            case 'smtp':\n                this.emailProvider = new SmtpProvider(\n                    this.logger.child({ service: 'SmtpProvider' }),\n                    this.client as Transporter,\n                    this.defaultAddresses\n                )\n                break\n            default:\n                undefined\n        }\n    }\n\n    /**\n     * Sends plain email(s)\n     *\n     * @returns success boolean(s)\n     */\n    async send(messages: SharedType.PlainEmailMessage): Promise<SharedType.EmailSendingResponse>\n    async send(messages: SharedType.PlainEmailMessage[]): Promise<SharedType.EmailSendingResponse[]>\n    async send(\n        messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[]\n    ): Promise<SharedType.EmailSendingResponse | SharedType.EmailSendingResponse[]> {\n        if (!this.emailProvider || !this.client) {\n            //no-op\n            return undefined as unknown as SharedType.EmailSendingResponse\n        }\n        return await this.emailProvider.send(messages)\n    }\n\n    /**\n     * Sends template email(s)\n     */\n    async sendTemplate(\n        messages: SharedType.TemplateEmailMessage\n    ): Promise<SharedType.EmailSendingResponse>\n    async sendTemplate(\n        messages: SharedType.TemplateEmailMessage[]\n    ): Promise<SharedType.EmailSendingResponse[]>\n    async sendTemplate(\n        messages: SharedType.TemplateEmailMessage | SharedType.TemplateEmailMessage[]\n    ): Promise<SharedType.EmailSendingResponse | SharedType.EmailSendingResponse[]> {\n        if (!this.emailProvider || !this.client || !this.emailProvider.sendTemplate) {\n            //no-op\n            return undefined as unknown as SharedType.EmailSendingResponse\n        }\n        return this.emailProvider.sendTemplate(messages)\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/email/index.ts",
    "content": "export * from './email.processor'\nexport * from './email.service'\n"
  },
  {
    "path": "libs/server/features/src/email/providers/index.ts",
    "content": "export * from './postmark.provider'\nexport * from './smtp.provider'\n"
  },
  {
    "path": "libs/server/features/src/email/providers/postmark.provider.ts",
    "content": "import type { IEmailProvider } from '../email.service'\nimport type { Logger } from 'winston'\nimport type { Message, ServerClient as PostmarkServerClient, TemplatedMessage } from 'postmark'\nimport type { MessageSendingResponse } from 'postmark/dist/client/models'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { chunk, uniq } from 'lodash'\nimport { EmailTemplateSchema } from '../email.schema'\n\nexport class PostmarkEmailProvider implements IEmailProvider {\n    constructor(\n        private readonly logger: Logger,\n        private readonly client: PostmarkServerClient | undefined,\n        private readonly defaultAddresses: { from: string; replyTo?: string }\n    ) {}\n\n    /**\n     * Sends plain email(s)\n     *\n     * @returns success boolean(s)\n     */\n    async send(messages: SharedType.PlainEmailMessage): Promise<MessageSendingResponse>\n    async send(messages: SharedType.PlainEmailMessage[]): Promise<MessageSendingResponse[]>\n    async send(\n        messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[]\n    ): Promise<MessageSendingResponse | MessageSendingResponse[]> {\n        const mapToPostmark = (message: SharedType.PlainEmailMessage): Message => ({\n            From: message.from ?? this.defaultAddresses.from,\n            ReplyTo: message.replyTo ?? this.defaultAddresses.replyTo,\n            To: message.to,\n            Subject: message.subject,\n            TextBody: message.textBody,\n            HtmlBody: message.htmlBody,\n        })\n\n        return Array.isArray(messages)\n            ? this.sendEmailBatch(messages.map(mapToPostmark))\n            : this.sendEmail(mapToPostmark(messages))\n    }\n\n    /**\n     * Sends template email(s)\n     */\n    async sendTemplate(messages: SharedType.TemplateEmailMessage): Promise<MessageSendingResponse>\n    async sendTemplate(\n        messages: SharedType.TemplateEmailMessage[]\n    ): Promise<MessageSendingResponse[]>\n    async sendTemplate(\n        messages: SharedType.TemplateEmailMessage | SharedType.TemplateEmailMessage[]\n    ): Promise<MessageSendingResponse | MessageSendingResponse[]> {\n        const mapToPostmark = (message: SharedType.TemplateEmailMessage): TemplatedMessage => {\n            const { alias, model } = EmailTemplateSchema.parse(message.template)\n\n            return {\n                From: message.from ?? this.defaultAddresses.from,\n                ReplyTo: message.replyTo ?? this.defaultAddresses.replyTo,\n                To: message.to,\n                TemplateAlias: alias,\n                TemplateModel: model,\n            }\n        }\n\n        return Array.isArray(messages)\n            ? this.sendEmailBatchWithTemplate(messages.map(mapToPostmark))\n            : this.sendEmailWithTemplate(mapToPostmark(messages))\n    }\n\n    private async sendEmailWithTemplate(\n        message: TemplatedMessage\n    ): Promise<MessageSendingResponse> {\n        this.logger.info(\n            `Sending templated email template=${message.TemplateAlias} from=${message.From} to=${message.To}`,\n            message.TemplateModel\n        )\n\n        if (!this.client) {\n            this.logger.info('Postmark API key not provided, skipping email send')\n            return undefined as unknown as MessageSendingResponse\n        }\n\n        return await this.client.sendEmailWithTemplate(message)\n    }\n\n    private async sendEmail(message: Message): Promise<MessageSendingResponse> {\n        this.logger.info(\n            `Sending plain email subject=${message.Subject} from=${message.From} to=${message.To}`,\n            { text: message.TextBody, html: message.HtmlBody }\n        )\n\n        if (!this.client) {\n            this.logger.info('Postmark API key not provided, skipping email send')\n            return undefined as unknown as MessageSendingResponse\n        }\n\n        return await this.client.sendEmail(message)\n    }\n\n    private async sendEmailBatchWithTemplate(\n        messages: TemplatedMessage[]\n    ): Promise<MessageSendingResponse[]> {\n        this.logger.info(\n            `Sending templated email batch templates=[${uniq(\n                messages.map(({ TemplateAlias }) => TemplateAlias)\n            ).join(',')}] count=${messages.length}`\n        )\n\n        return (\n            await Promise.all(\n                chunk(messages, 500).map((chunk) => {\n                    if (!this.client) {\n                        this.logger.info('Postmark API key not provided, skipping email send')\n                        return [] as MessageSendingResponse[]\n                    }\n                    return this.client.sendEmailBatchWithTemplates(chunk)\n                })\n            )\n        ).flat()\n    }\n\n    private async sendEmailBatch(messages: Message[]): Promise<MessageSendingResponse[]> {\n        this.logger.info(\n            `Sending templated email batch subjects=[${uniq(\n                messages.map(({ Subject }) => Subject)\n            ).join(',')}] count=${messages.length}`\n        )\n\n        return (\n            await Promise.all(\n                chunk(messages, 500).map((chunk) => {\n                    if (!this.client) {\n                        this.logger.info('Postmark API key not provided, skipping email send')\n                        return [] as MessageSendingResponse[]\n                    }\n                    return this.client.sendEmailBatch(chunk)\n                })\n            )\n        ).flat()\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/email/providers/smtp.provider.ts",
    "content": "import type { IEmailProvider } from '../email.service'\nimport type { Logger } from 'winston'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { chunk, uniq } from 'lodash'\nimport type { Transporter, SentMessageInfo } from 'nodemailer'\n\ntype NodemailerMessage = {\n    to: string\n    from: string\n    replyTo?: string\n    sender?: string\n    cc?: string\n    bcc?: string\n    subject: string\n    text?: string\n    html?: string\n}\n\nexport class SmtpProvider implements IEmailProvider {\n    constructor(\n        private readonly logger: Logger,\n        private readonly client: Transporter | undefined,\n        private readonly defaultAddresses: { from: string; replyTo?: string }\n    ) {}\n\n    /**\n     * Sends plain email(s)\n     *\n     * @returns success boolean(s)\n     */\n\n    async send(messages: SharedType.PlainEmailMessage): Promise<SentMessageInfo>\n    async send(messages: SharedType.PlainEmailMessage[]): Promise<SentMessageInfo>\n    async send(\n        messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[]\n    ): Promise<SentMessageInfo> {\n        const mapToNodemailer = (message: SharedType.PlainEmailMessage): NodemailerMessage => ({\n            from: message.from ?? this.defaultAddresses.from,\n            replyTo: message.replyTo ?? this.defaultAddresses.replyTo,\n            to: message.to,\n            subject: message.subject,\n            text: message.textBody,\n            html: message.htmlBody,\n        })\n\n        return Array.isArray(messages)\n            ? this.sendEmailBatch(messages.map(mapToNodemailer))\n            : this.sendEmail(mapToNodemailer(messages))\n    }\n\n    private async sendEmail(message: NodemailerMessage): Promise<void> {\n        this.logger.info(\n            `Sending plain email subject=${message.subject} from=${message.from} to=${message.to}`,\n            { text: message.text, html: message.html }\n        )\n\n        if (!this.client) {\n            this.logger.info('SMTP config not set up, skipping email send')\n            return undefined as void\n        }\n\n        return await this.client.sendMail(message)\n    }\n\n    private async sendEmailBatch(messages: NodemailerMessage[]): Promise<SentMessageInfo[]> {\n        this.logger.info(\n            `Sending email batch subjects=[${uniq(messages.map(({ subject }) => subject)).join(\n                ','\n            )}] count=${messages.length}`\n        )\n\n        if (!this.client) {\n            this.logger.info('SMTP config not set up, skipping email send')\n            return [] as SentMessageInfo[]\n        }\n\n        return (\n            await Promise.all(\n                chunk(messages, 500).map((chunk) => {\n                    if (!this.client) {\n                        this.logger.info('Postmark API key not provided, skipping email send')\n                        return [] as SentMessageInfo[]\n                    } else {\n                        chunk.forEach((message) => {\n                            return this.client?.sendMail(message)\n                        })\n                    }\n                })\n            )\n        ).flat()\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/holding/holding.schema.ts",
    "content": "import { z } from 'zod'\n\nexport const HoldingUpdateInputSchema = z\n    .object({\n        excluded: z.boolean(),\n        costBasisUser: z.number().nullable(),\n    })\n    .partial()\n"
  },
  {
    "path": "libs/server/features/src/holding/holding.service.ts",
    "content": "import type { Logger } from 'winston'\nimport type { PrismaClient, Holding } from '@prisma/client'\nimport type { Prisma } from '@prisma/client'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { DbUtil } from '@maybe-finance/server/shared'\n\nexport class HoldingService {\n    constructor(private readonly logger: Logger, private readonly prisma: PrismaClient) {}\n\n    async get(id: Holding['id']) {\n        return this.prisma.holding.findUniqueOrThrow({\n            where: { id },\n            include: { account: { include: { accountConnection: true } } },\n        })\n    }\n\n    async getHoldingDetails(id: Holding['id']): Promise<SharedType.AccountHolding> {\n        const [he] = await this.prisma.$queryRaw<\n            Array<\n                SharedType.HoldingEnriched & {\n                    cost_basis_user: Prisma.Decimal | null\n                    cost_basis_provider: Prisma.Decimal | null\n                }\n            >\n        >`\n            SELECT\n              he.*,\n              h.security_id,\n              h.cost_basis_user,\n              h.cost_basis_provider,\n              s.name,\n              s.symbol\n            FROM\n              holdings_enriched he\n              INNER JOIN security s ON s.id = he.security_id\n              INNER JOIN holding h ON h.id = he.id\n            WHERE\n              h.id = ${id};\n        `\n\n        return {\n            id,\n            securityId: he.security_id,\n            name: he.name,\n            symbol: he.symbol,\n            quantity: DbUtil.toDecimal(he.quantity),\n            sharesPerContract: DbUtil.toDecimal(he.shares_per_contract),\n            costBasis: DbUtil.toDecimal(he.cost_basis),\n            costBasisUser: DbUtil.toDecimal(he.cost_basis_user),\n            costBasisProvider: DbUtil.toDecimal(he.cost_basis_provider),\n            price: DbUtil.toDecimal(he.price),\n            value: DbUtil.toDecimal(he.value),\n            trend: {\n                total: he.cost_basis ? DbUtil.calculateTrend(he.cost_basis, he.value) : null,\n                today: he.price_prev\n                    ? DbUtil.calculateTrend(he.price_prev.times(he.quantity), he.value)\n                    : null,\n            },\n            excluded: he.excluded,\n        }\n    }\n\n    async update(id: Holding['id'], data: Prisma.HoldingUncheckedUpdateInput) {\n        const holding = await this.prisma.holding.update({\n            where: { id },\n            data,\n        })\n\n        this.logger.info(`Updated holding id=${id} account=${holding.accountId}`)\n\n        return holding\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/holding/index.ts",
    "content": "export * from './holding.service'\nexport * from './holding.schema'\n"
  },
  {
    "path": "libs/server/features/src/index.ts",
    "content": "export * from './account'\nexport * from './account-connection'\nexport * from './account-balance'\nexport * from './email'\nexport * from './institution'\nexport * from './security-pricing'\nexport * from './auth-user'\nexport * from './user'\nexport * from './valuation'\nexport * from './providers'\nexport * from './transaction'\nexport * from './holding'\nexport * from './investment-transaction'\nexport * from './plan'\nexport * from './stripe'\n"
  },
  {
    "path": "libs/server/features/src/institution/index.ts",
    "content": "export * from './institution.service'\nexport * from './institution.provider'\n"
  },
  {
    "path": "libs/server/features/src/institution/institution.provider.ts",
    "content": "import type { Prisma, Provider } from '@prisma/client'\n\nexport interface IInstitutionProvider {\n    getInstitutions(): Promise<Omit<Prisma.ProviderInstitutionUncheckedCreateInput, 'provider'>[]>\n}\n\nexport interface IInstitutionProviderFactory {\n    for(provider: Provider): IInstitutionProvider\n}\n\nexport class InstitutionProviderFactory implements IInstitutionProviderFactory {\n    constructor(private readonly providers: Record<Provider, IInstitutionProvider>) {}\n\n    for(p: Provider): IInstitutionProvider {\n        const provider = this.providers[p]\n        if (!provider) throw new Error(`Unsupported provider: ${p}`)\n        return provider\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/institution/institution.service.ts",
    "content": "import type { Institution, PrismaClient, Provider, ProviderInstitution } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport type { PgService } from '@maybe-finance/server/shared'\nimport type { IInstitutionProviderFactory } from './institution.provider'\nimport { Prisma } from '@prisma/client'\nimport _ from 'lodash'\nimport { SharedType } from '@maybe-finance/shared'\nimport { join, sql } from '@maybe-finance/server/shared'\n\nexport interface IInstitutionService {\n    getAll(options: { query?: string; page?: number }): Promise<SharedType.InstitutionsResponse>\n    sync(provider: Provider): Promise<void>\n    deduplicateInstitutions(): Promise<void>\n}\n\nexport class InstitutionService implements IInstitutionService {\n    constructor(\n        private readonly logger: Logger,\n        private readonly prisma: PrismaClient,\n        private readonly pg: PgService,\n        private readonly providers: IInstitutionProviderFactory\n    ) {}\n\n    async getAll({\n        query,\n        page = 0,\n    }: {\n        query?: string\n        page?: number\n    } = {}): Promise<SharedType.InstitutionsResponse> {\n        if (!query) {\n            // filter for institutions with at least one provider\n            const institutionWhere: Prisma.InstitutionWhereInput = {\n                providers: { some: {} },\n            }\n\n            // filter for provider institutions not attached to an institution\n            const providerInstitutionWhere: Prisma.ProviderInstitutionWhereInput = {\n                institution: null,\n            }\n\n            const [institutions, institutionCount, providerInstitutions, providerInstitutionCount] =\n                await this.prisma.$transaction([\n                    this.prisma.institution.findMany({\n                        where: institutionWhere,\n                        include: {\n                            providers: {\n                                select: {\n                                    id: true,\n                                    provider: true,\n                                    providerId: true,\n                                    rank: true,\n                                },\n                                orderBy: { rank: 'desc', oauth: 'desc' },\n                            },\n                        },\n                        orderBy: { name: 'asc' },\n                        skip: page * SharedType.PageSize.Institution,\n                        take: SharedType.PageSize.Institution,\n                    }),\n                    this.prisma.institution.count({ where: institutionWhere }),\n                    this.prisma.providerInstitution.findMany({\n                        where: providerInstitutionWhere,\n                        orderBy: { name: 'asc' },\n                        skip: page * SharedType.PageSize.Institution,\n                        take: SharedType.PageSize.Institution,\n                    }),\n                    this.prisma.providerInstitution.count({ where: providerInstitutionWhere }),\n                ])\n\n            return {\n                institutions: [\n                    ...institutions,\n                    ...providerInstitutions.map((pi) => ({\n                        id: `provider[${pi.id}]`,\n                        name: pi.name,\n                        url: pi.url,\n                        logo: pi.logo,\n                        logoUrl: pi.logoUrl,\n                        primaryColor: pi.primaryColor,\n                        providers: [\n                            {\n                                id: pi.id,\n                                provider: pi.provider,\n                                providerId: pi.providerId,\n                                rank: pi.rank,\n                            },\n                        ],\n                    })),\n                ],\n                totalInstitutions: institutionCount + providerInstitutionCount,\n            }\n        }\n\n        // autocomplete/search using postgres full-text search\n        const tokens = query\n            .trim()\n            .split(/\\s+/gim) // remove whitespace\n            .map((x) => x.replace(/\\W/gim, '')) // remove non-word characters\n            .filter((x) => x.length > 0)\n            .map((x) => `${x}:*`) // convert to prefix search query\n        this.logger.debug(`converted query: \"${query}\" to tokens: ${JSON.stringify(tokens)}`)\n\n        const q = Prisma.sql`(${Prisma.join(\n            tokens.map((token) => Prisma.sql`to_tsquery('simple', ${token})`),\n            ' && '\n        )})`\n\n        type InstitutionQueryResult = (Pick<\n            Institution,\n            'id' | 'name' | 'url' | 'logo' | 'logoUrl' | 'primaryColor'\n        > & {\n            providers: Pick<ProviderInstitution, 'id' | 'provider' | 'providerId' | 'rank'>[]\n        })[]\n\n        const institutionSearchQuery = Prisma.sql`\n          SELECT\n            i.id,\n            i.name,\n            i.url,\n            i.logo,\n            i.logo_url AS \"logoUrl\",\n            i.primary_color AS \"primaryColor\",\n            jsonb_agg(jsonb_build_object('id', pi.id, 'provider', pi.provider, 'providerId', pi.provider_id, 'rank', pi.rank) ORDER BY pi.rank, pi.oauth DESC) AS providers\n          FROM\n            institution i\n            INNER JOIN provider_institution pi ON pi.institution_id = i.id\n          WHERE\n            edge_ngram_tsvector(i.name) @@ ${q}\n            AND pi.rank >= 0\n          GROUP BY\n            i.id\n          ORDER BY\n            ts_rank(edge_ngram_tsvector(i.name), ${q}, 1|16) DESC, length(i.name) ASC\n        `\n\n        const providerInstitutionSearchQuery = Prisma.sql`\n          SELECT\n            'provider.' || pi.id AS id,\n            pi.name,\n            pi.url,\n            pi.logo,\n            pi.logo_url AS \"logoUrl\",\n            pi.primary_color AS \"primaryColor\",\n            jsonb_build_array(jsonb_build_object('id', pi.id, 'provider', pi.provider, 'providerId', pi.provider_id, 'rank', pi.rank)) AS providers\n          FROM\n            provider_institution pi\n          WHERE\n            pi.institution_id IS NULL\n            AND edge_ngram_tsvector(pi.name) @@ ${q}\n            AND pi.rank >= 0\n          ORDER BY\n            ts_rank(edge_ngram_tsvector(pi.name), ${q}, 1|16) DESC, length(pi.name) ASC\n        `\n\n        const [institutions, [{ count: institutionCount }], [{ count: providerInstitutionCount }]] =\n            await this.prisma.$transaction([\n                this.prisma.$queryRaw<InstitutionQueryResult>`\n                  ${institutionSearchQuery}\n                  LIMIT ${SharedType.PageSize.Institution}\n                  OFFSET ${page * SharedType.PageSize.Institution};\n                `,\n                this.prisma.$queryRaw<\n                    [{ count: bigint }]\n                >`SELECT COUNT(*) FROM (${institutionSearchQuery}) q`,\n                this.prisma.$queryRaw<\n                    [{ count: bigint }]\n                >`SELECT COUNT(*) FROM (${providerInstitutionSearchQuery}) q`,\n            ])\n\n        const providerInstitutions =\n            institutions.length < SharedType.PageSize.Institution\n                ? await this.prisma.$queryRaw<InstitutionQueryResult>`\n                    ${providerInstitutionSearchQuery}\n                    LIMIT ${SharedType.PageSize.Institution - institutions.length}\n                    OFFSET ${Math.max(\n                        0,\n                        page * SharedType.PageSize.Institution - Number(institutionCount)\n                    )};\n                  `\n                : []\n\n        return {\n            institutions: [...institutions, ...providerInstitutions],\n            totalInstitutions: Number(institutionCount + providerInstitutionCount),\n        }\n    }\n\n    async sync(provider: Provider) {\n        const institutions = await this.providers.for(provider).getInstitutions()\n        this.logger.info(`fetched ${institutions.length} institutions for provider ${provider}`)\n\n        for (const chunk of _.chunk(institutions, 2000)) {\n            await this.pg.pool.query(\n                sql`\n                    INSERT INTO provider_institution (provider, provider_id, name, url, logo, logo_url, primary_color, oauth, data)\n                    VALUES\n                      ${join(\n                          chunk.map(\n                              (institution) => sql`(\n                                  ${provider},\n                                  ${institution.providerId},\n                                  ${institution.name},\n                                  ${institution.url},\n                                  ${institution.logo},\n                                  ${institution.logoUrl},\n                                  ${institution.primaryColor},\n                                  ${institution.oauth},\n                                  ${institution.data as any}\n                              )`\n                          )\n                      )}\n                    ON CONFLICT (provider, provider_id) DO UPDATE\n                    SET\n                      name = EXCLUDED.name,\n                      url = EXCLUDED.url,\n                      logo = EXCLUDED.logo,\n                      logo_url = EXCLUDED.logo_url,\n                      primary_color = EXCLUDED.primary_color,\n                      oauth = EXCLUDED.oauth,\n                      data = EXCLUDED.data;\n                `\n            )\n        }\n\n        await this.pg.pool.query(sql`\n          DELETE FROM provider_institution\n          WHERE\n            provider = ${provider}\n            AND provider_id NOT IN (${join(institutions.map((i) => i.providerId))})\n        `)\n    }\n\n    async deduplicateInstitutions() {\n        await this.prisma.$executeRaw`\n          WITH duplicates AS (\n            SELECT\n              MAX(TRIM(pi.name)) AS name,\n              x.url,\n              array_agg(pi.id) AS provider_ids\n            FROM\n              provider_institution pi\n              LEFT JOIN LATERAL (\n                SELECT 'https://' || LOWER(TRIM(SPLIT_PART(pi.url, '://', 2))) AS url\n              ) x ON true\n            GROUP BY\n              UPPER(TRIM(pi.name)),\n              x.url\n            HAVING\n              COUNT(pi.id) > 1 AND COUNT(pi.institution_id) < COUNT(pi.id)\n          ), institutions AS (\n            INSERT INTO institution (name, url, logo, logo_url, primary_color)\n            SELECT\n              name,\n              url,\n              (SELECT logo FROM provider_institution pi WHERE pi.id = ANY(provider_ids) AND logo IS NOT NULL LIMIT 1),\n              (SELECT logo_url FROM provider_institution pi WHERE pi.id = ANY(provider_ids) AND logo_url IS NOT NULL LIMIT 1),\n              (SELECT primary_color FROM provider_institution pi WHERE pi.id = ANY(provider_ids) AND primary_color IS NOT NULL LIMIT 1)\n            FROM\n              duplicates\n            ON CONFLICT (name, url) DO UPDATE\n            SET\n              name = EXCLUDED.name,\n              url = EXCLUDED.url,\n              logo = EXCLUDED.logo,\n              logo_url = EXCLUDED.logo_url,\n              primary_color = EXCLUDED.primary_color\n            RETURNING id, name, url\n          )\n          UPDATE\n            provider_institution pi\n          SET\n            institution_id = i.id,\n            rank = (CASE WHEN pi.provider = 'TELLER' THEN 1 ELSE 0 END)\n          FROM\n            duplicates d\n            INNER JOIN institutions i ON i.name = d.name AND i.url = d.url\n          WHERE\n            pi.id = ANY(d.provider_ids)\n            AND pi.institution_id IS NULL;\n        `\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/investment-transaction/index.ts",
    "content": "export * from './investment-transaction.schema'\n"
  },
  {
    "path": "libs/server/features/src/investment-transaction/investment-transaction.schema.ts",
    "content": "import { z } from 'zod'\n\nexport const InvestmentTransactionCategorySchema = z.enum([\n    'buy',\n    'sell',\n    'dividend',\n    'transfer',\n    'tax',\n    'fee',\n    'cancel',\n    'other',\n])\n"
  },
  {
    "path": "libs/server/features/src/plan/index.ts",
    "content": "export * from './plan.service'\nexport * from './plan.schema'\nexport * from './projection'\n"
  },
  {
    "path": "libs/server/features/src/plan/plan.schema.ts",
    "content": "import Decimal from 'decimal.js'\nimport { z } from 'zod'\n\nconst DecimalSchema = z.union([z.number(), z.string(), z.instanceof(Decimal)])\nconst IdSchema = z.number().int()\nconst YearSchema = z.number().int().positive()\n\nconst RetirementTemplateSchema = z.object({\n    retirementYear: z.number(),\n    annualIncome: DecimalSchema.nullish(),\n    annualRetirementIncome: DecimalSchema.nullish(),\n    annualExpenses: DecimalSchema.nullish(),\n    annualRetirementExpenses: DecimalSchema.nullish(),\n})\n\nexport const PlanTemplateSchema = z.discriminatedUnion('type', [\n    z.object({\n        type: z.literal('retirement'),\n        data: RetirementTemplateSchema,\n    }),\n    z.object({\n        type: z.literal('placeholder'),\n        data: z.object({}).default({}),\n    }),\n])\n\nexport type RetirementTemplate = z.infer<typeof RetirementTemplateSchema>\nexport type PlanTemplate = z.infer<typeof PlanTemplateSchema>\n\nconst PlanEventCreateSchema = z.object({\n    name: z.string(),\n    category: z.string().nullish(),\n    frequency: z.enum(['monthly', 'yearly']).optional(),\n    initialValue: DecimalSchema.nullish(),\n    initialValueRef: z.enum(['income', 'expenses']).nullish(),\n    rate: DecimalSchema.optional(),\n    startYear: YearSchema.nullish(),\n    startMilestoneId: IdSchema.nullish(),\n    endYear: YearSchema.nullish(),\n    endMilestoneId: IdSchema.nullish(),\n})\n\nconst PlanEventUpdateSchema = PlanEventCreateSchema.partial()\n\nconst PlanMilestoneBaseCreateSchema = z.object({\n    name: z.string(),\n    category: z.string().optional(),\n})\n\nconst PlanMilestoneTypeCreateSchema = z.discriminatedUnion('type', [\n    z.object({\n        type: z.literal('year'),\n        year: YearSchema,\n    }),\n    z.object({\n        type: z.literal('net_worth'),\n        expenseMultiple: z.number().nonnegative(),\n        expenseYears: z.number().int().nonnegative(),\n    }),\n])\n\nconst PlanMilestoneCreateSchema = PlanMilestoneBaseCreateSchema.and(PlanMilestoneTypeCreateSchema)\nconst PlanMilestoneUpdateSchema = PlanMilestoneBaseCreateSchema.partial().and(\n    PlanMilestoneTypeCreateSchema\n)\n\nexport const PlanCreateSchema = z.object({\n    name: z.string(),\n    lifeExpectancy: z.number(),\n    events: z.array(PlanEventCreateSchema).default([]),\n    milestones: z.array(PlanMilestoneCreateSchema).default([]),\n})\n\nexport const PlanUpdateSchema = PlanCreateSchema.omit({ events: true, milestones: true })\n    .extend({\n        events: z\n            .object({\n                create: z.array(PlanEventCreateSchema),\n                update: z.array(z.object({ id: IdSchema, data: PlanEventUpdateSchema })),\n                delete: z.array(IdSchema),\n            })\n            .partial(),\n        milestones: z\n            .object({\n                create: z.array(PlanMilestoneCreateSchema),\n                update: z.array(z.object({ id: IdSchema, data: PlanMilestoneUpdateSchema })),\n                delete: z.array(IdSchema),\n            })\n            .partial(),\n    })\n    .partial()\n"
  },
  {
    "path": "libs/server/features/src/plan/plan.service.ts",
    "content": "import type { Prisma, Plan, User, PrismaClient, PlanEvent, PlanMilestone } from '@prisma/client'\nimport { PlanEventFrequency } from '@prisma/client'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type { IInsightService } from '../account'\nimport type { IProjectionCalculator, ProjectionInput, ProjectionSeriesData } from './projection'\nimport type { PlanTemplate, RetirementTemplate } from './plan.schema'\nimport Decimal from 'decimal.js'\nimport { DateTime } from 'luxon'\nimport _ from 'lodash'\nimport { DateUtil, PlanUtil, SharedUtil, StatsUtil } from '@maybe-finance/shared'\nimport { AssetValue, monteCarlo } from './projection'\n\nconst PERCENTILES: Decimal[] = ['0.1', '0.9'].map((p) => new Decimal(p))\nconst MONTE_CARLO_N = 1_000\n\ntype PlanWithEventsMilestones = Plan & { events: PlanEvent[]; milestones: PlanMilestone[] }\ntype PlanWithEventsMilestonesUser = PlanWithEventsMilestones & { user: User }\n\ntype ValueRefMap = Record<string, Prisma.Decimal>\n\nfunction resolveValueRef(valueRef: string, valueRefMap: ValueRefMap): Prisma.Decimal {\n    return valueRefMap[valueRef] ?? 0\n}\n\nfunction yearToDate(year: number) {\n    return DateTime.fromObject({ year }, { zone: 'utc' }).toISODate()\n}\n\n/**\n * Mapping of asset types -> [avg annual return, annual return standard deviation]\n */\nconst PROJECTION_ASSET_PARAMS: {\n    [type in SharedType.ProjectionAssetType]: [mean: Decimal.Value, stddev: Decimal.Value]\n} = {\n    stocks: ['0.05', '0.186'],\n    fixed_income: ['0.02', '0.052'],\n    cash: ['-0.02', '0.05'],\n    crypto: ['1.0', '1.0'],\n    property: ['0.1', '0.2'],\n    other: ['-0.02', '0'],\n}\n\nexport interface IPlanService {\n    get(id: Plan['id']): Promise<SharedType.Plan>\n    getAll(userId: User['id']): Promise<SharedType.PlansResponse>\n    create(data: Prisma.PlanUncheckedCreateInput): Promise<Plan>\n    createWithTemplate(user: User, template: PlanTemplate): Promise<SharedType.Plan>\n    update(id: Plan['id'], data: Prisma.PlanUncheckedUpdateInput): Promise<SharedType.Plan>\n    updateWithTemplate(\n        planId: Plan['id'],\n        template: PlanTemplate,\n        shouldReset?: boolean\n    ): Promise<SharedType.Plan>\n    delete(id: Plan['id']): Promise<Plan>\n    projections(id: Plan['id']): Promise<SharedType.PlanProjectionResponse>\n}\n\nexport class PlanService implements IPlanService {\n    constructor(\n        private readonly prisma: PrismaClient,\n        private readonly projectionCalculator: IProjectionCalculator,\n        private readonly insightService: IInsightService\n    ) {}\n\n    async get(id: Plan['id']): Promise<SharedType.Plan> {\n        const plan = await this.prisma.plan.findUniqueOrThrow({\n            where: { id },\n            include: {\n                user: true,\n                events: true,\n                milestones: true,\n            },\n        })\n\n        return this._mapToSharedPlan(plan, await this._getValueRefMap(plan.user.id))\n    }\n\n    async getAll(userId: User['id']): Promise<SharedType.PlansResponse> {\n        const [user, plans] = await Promise.all([\n            this.prisma.user.findUniqueOrThrow({ where: { id: userId } }),\n            this.prisma.plan.findMany({\n                where: { userId },\n                include: {\n                    events: true,\n                    milestones: true,\n                },\n                orderBy: { createdAt: 'desc' },\n            }),\n        ])\n\n        const insights = await this.insightService.getPlanInsights({ userId: user.id })\n        const valueRefMap = this._toValueRefMap(insights)\n\n        return {\n            plans: plans.map((plan) => this._mapToSharedPlan(plan, valueRefMap)),\n        }\n    }\n\n    async create(data: Prisma.PlanUncheckedCreateInput) {\n        return this.prisma.plan.create({\n            data,\n        })\n    }\n\n    async createWithTemplate(user: User, template: PlanTemplate) {\n        const plan = await this._connectTemplate(template, async (tx, data) => {\n            const _plan = await tx.plan.create({ data: { ...data, userId: user.id } })\n            return _plan.id\n        })\n\n        const insights = await this.insightService.getPlanInsights({ userId: user.id })\n        const valueRefMap = this._toValueRefMap(insights)\n\n        return this._mapToSharedPlan(plan, valueRefMap)\n    }\n\n    async update(id: Plan['id'], data: Prisma.PlanUncheckedUpdateInput) {\n        const plan = await this.prisma.plan.update({\n            where: { id },\n            include: {\n                user: true,\n                events: true,\n                milestones: true,\n            },\n            data,\n        })\n\n        return this._mapToSharedPlan(plan, await this._getValueRefMap(plan.user.id))\n    }\n\n    async updateWithTemplate(planId: Plan['id'], template: PlanTemplate, reset = false) {\n        // Clear out previous templates's milestones/events\n        if (reset) {\n            await this.prisma.plan.update({\n                where: { id: planId },\n                data: { events: { deleteMany: {} }, milestones: { deleteMany: {} } },\n            })\n        }\n\n        const plan = await this._connectTemplate(template, planId)\n        const insights = await this.insightService.getPlanInsights({ userId: plan.user.id })\n        const valueRefMap = this._toValueRefMap(insights)\n\n        return this._mapToSharedPlan(plan, valueRefMap)\n    }\n\n    async delete(id: Plan['id']) {\n        return this.prisma.plan.delete({ where: { id } })\n    }\n\n    async projections(id: Plan['id']) {\n        const plan = await this.prisma.plan.findUniqueOrThrow({\n            where: { id },\n            include: {\n                user: true,\n                events: true,\n                milestones: true,\n            },\n        })\n\n        const insights = await this.insightService.getPlanInsights({ userId: plan.user.id })\n\n        const age = DateUtil.dobToAge(plan.user.dob) ?? PlanUtil.DEFAULT_AGE\n\n        const inputTheo = this._toProjectionInput(plan, age, insights, false)\n        const theo = this.projectionCalculator.calculate(inputTheo)\n\n        const inputRandomized = this._toProjectionInput(plan, age, insights, true)\n        const simulations = monteCarlo(() => this.projectionCalculator.calculate(inputRandomized), {\n            n: MONTE_CARLO_N,\n        })\n\n        const simulationStats = _.zipWith(...simulations, (...series) => {\n            const year = series[0].year\n            const netWorths = series.map((d) => d.netWorth)\n            const successRate = StatsUtil.rateOf(netWorths, (netWorth) => netWorth.gt(0))\n\n            return {\n                year,\n                percentiles: StatsUtil.quantiles(netWorths, PERCENTILES),\n                successRate,\n            }\n        })\n\n        const simulationsByPercentile = PERCENTILES.map((percentile, idx) => ({\n            percentile,\n            simulation: simulationStats.map(({ year, percentiles }) => ({\n                year,\n                netWorth: percentiles[idx],\n            })),\n        }))\n\n        const planMapped = this._mapToSharedPlan(plan, this._toValueRefMap(insights))\n\n        return {\n            input: inputRandomized,\n            projection: this._mapToProjectionTimeSeries(theo, simulationStats, planMapped, age),\n            simulations: simulationsByPercentile.map(({ percentile, simulation }) => ({\n                percentile,\n                simulation: this._mapToSimulationTimeSeries(simulation, age),\n            })),\n        }\n    }\n\n    private async _connectTemplate(\n        template: PlanTemplate,\n        planIdAccessor:\n            | ((\n                  tx: Prisma.TransactionClient,\n                  data: Omit<Prisma.PlanUncheckedCreateInput, 'userId'>\n              ) => Promise<Plan['id']>)\n            | Plan['id']\n    ) {\n        return this.prisma.$transaction(async (tx) => {\n            let updatedPlan: PlanWithEventsMilestonesUser\n\n            switch (template.type) {\n                case 'retirement': {\n                    const _planId =\n                        typeof planIdAccessor === 'function'\n                            ? await planIdAccessor(tx, { name: 'Retirement Plan' })\n                            : planIdAccessor\n\n                    updatedPlan = await this._connectRetirementTemplate(tx, _planId, template.data)\n\n                    break\n                }\n                default: {\n                    throw new Error('Template not implemented')\n                }\n            }\n\n            return updatedPlan\n        })\n    }\n\n    private async _connectRetirementTemplate(\n        tx: Prisma.TransactionClient,\n        planId: Plan['id'],\n        data: RetirementTemplate & { userAge?: number }\n    ) {\n        const milestone = await tx.planMilestone.create({\n            data: {\n                planId,\n                name: 'Retirement',\n                category: PlanUtil.PlanMilestoneCategory.Retirement,\n                type: 'year',\n                year: data.retirementYear,\n            },\n        })\n\n        return await tx.plan.update({\n            where: { id: planId },\n            data: {\n                events: {\n                    createMany: {\n                        data: [\n                            // User's current income, stops at retirement\n                            {\n                                name: 'Income (current)',\n                                endMilestoneId: milestone.id,\n                                frequency: PlanEventFrequency.yearly,\n                                initialValue: data.annualIncome ? data.annualIncome : undefined,\n                                initialValueRef: data.annualIncome ? undefined : 'income',\n                            },\n\n                            // User's retirement income - if not specified, we assume no income\n                            ...(data.annualRetirementIncome\n                                ? [\n                                      {\n                                          name: 'Income (retirement)',\n                                          startMilestoneId: milestone.id,\n                                          frequency: PlanEventFrequency.yearly,\n                                          initialValue: data.annualRetirementIncome,\n                                      },\n                                  ]\n                                : []),\n\n                            // User's current expenses, stops at retirement\n                            {\n                                name: 'Expenses (current)',\n                                endMilestoneId: milestone.id,\n                                frequency: PlanEventFrequency.yearly,\n                                initialValue: data.annualExpenses ? data.annualExpenses : undefined,\n                                initialValueRef: data.annualExpenses ? undefined : 'expenses',\n                            },\n\n                            // User's post-retirement expenses - if not specified, defaults to current expenses\n                            {\n                                name: 'Expenses (retirement)',\n                                startMilestoneId: milestone.id,\n                                frequency: PlanEventFrequency.yearly,\n                                initialValue: data.annualRetirementExpenses\n                                    ? data.annualRetirementExpenses\n                                    : undefined,\n                                initialValueRef: data.annualRetirementExpenses\n                                    ? undefined\n                                    : 'expenses',\n                            },\n                        ],\n                    },\n                },\n            },\n            include: {\n                user: true,\n                events: true,\n                milestones: true,\n            },\n        })\n    }\n\n    private _toProjectionInput(\n        plan: PlanWithEventsMilestones,\n        age: number,\n        insights: SharedType.PlanInsights,\n        randomized: boolean\n    ): ProjectionInput {\n        const valueRefMap = this._toValueRefMap(insights)\n\n        return {\n            years: plan.lifeExpectancy - age + 1,\n            assets: insights.projectionAssetBreakdown.map(({ type, amount }) => {\n                const [mean, std] = PROJECTION_ASSET_PARAMS[type]\n                return {\n                    id: type,\n                    value: new AssetValue(amount.toString(), mean, randomized ? std : 0),\n                }\n            }),\n            liabilities: insights.projectionLiabilityBreakdown.map(({ type, amount }) => {\n                return {\n                    id: type,\n                    value: new AssetValue(amount.toString()),\n                }\n            }),\n            events: plan.events.map(\n                ({\n                    id,\n                    startYear,\n                    startMilestoneId,\n                    endYear,\n                    endMilestoneId,\n                    frequency,\n                    initialValue,\n                    initialValueRef,\n                    rate,\n                }) => {\n                    const value = initialValue ?? resolveValueRef(initialValueRef!, valueRefMap)\n\n                    const valueYearly = Decimal.mul(\n                        value.toString(),\n                        frequency === 'monthly' ? 12 : 1\n                    )\n\n                    return {\n                        id: id.toString(),\n                        value: new AssetValue(valueYearly, rate.toString()),\n                        start: startYear ?? startMilestoneId?.toString(),\n                        end: endYear ?? endMilestoneId?.toString(),\n                    }\n                }\n            ),\n            milestones: plan.milestones.map((milestone) => {\n                switch (milestone.type) {\n                    case 'year':\n                        return {\n                            id: milestone.id.toString(),\n                            type: 'year',\n                            year: milestone.year!,\n                        }\n                    case 'net_worth':\n                        return {\n                            id: milestone.id.toString(),\n                            type: 'net-worth',\n                            expenseMultiple: milestone.expenseMultiple!,\n                            expenseYears: milestone.expenseYears!,\n                        }\n                }\n            }),\n        }\n    }\n\n    private _toValueRefMap(insights: SharedType.PlanInsights): ValueRefMap {\n        return {\n            income: insights.income.toDP(2),\n            expenses: insights.expenses.negated().toDP(2),\n        }\n    }\n\n    private async _getValueRefMap(userId: User['id']): Promise<ValueRefMap> {\n        const insights = await this.insightService.getPlanInsights({ userId })\n        return this._toValueRefMap(insights)\n    }\n\n    /**\n     * Converts a plan to its DTO representation.\n     */\n    private _mapToSharedPlan(\n        plan: Plan & {\n            events: PlanEvent[]\n            milestones: PlanMilestone[]\n        },\n        valueRefMap: ValueRefMap\n    ): SharedType.Plan {\n        return {\n            ...plan,\n            events: plan.events.map((event) => ({\n                ...event,\n                initialValue: event.initialValueRef\n                    ? resolveValueRef(event.initialValueRef, valueRefMap)\n                    : event.initialValue!,\n            })),\n        }\n    }\n\n    private _mapToProjectionTimeSeries(\n        theo: ProjectionSeriesData[],\n        simulationStats: { year: number; successRate: Decimal }[],\n        plan: SharedType.Plan,\n        currentAge: number\n    ): SharedType.TimeSeries<SharedType.PlanProjectionData> {\n        return {\n            interval: 'years',\n            start: yearToDate(theo[0].year),\n            end: yearToDate(theo[theo.length - 1].year),\n            data: theo.map((data, idx) => {\n                const { successRate } = simulationStats.find((x) => x.year === data.year)!\n                return {\n                    date: yearToDate(data.year),\n                    values: {\n                        age: currentAge + idx,\n                        year: data.year,\n                        netWorth: data.netWorth,\n                        events: data.events\n                            .map((e) => {\n                                const event = plan.events.find((event) => event.id === +e.id)\n                                return event ? { event, calculatedValue: e.balance } : undefined\n                            })\n                            .filter(SharedUtil.nonNull),\n                        milestones: data.milestones\n                            .map((m) => plan.milestones.find((milestone) => milestone.id === +m.id))\n                            .filter(SharedUtil.nonNull),\n                        successRate,\n                    },\n                }\n            }),\n        }\n    }\n\n    private _mapToSimulationTimeSeries(\n        simulation: { year: number; netWorth: Decimal }[],\n        currentAge: number\n    ): SharedType.TimeSeries<SharedType.PlanSimulationData> {\n        return {\n            interval: 'years',\n            start: yearToDate(simulation[0].year),\n            end: yearToDate(simulation[simulation.length - 1].year),\n            data: simulation.map((data, idx) => ({\n                date: yearToDate(data.year),\n                values: {\n                    age: currentAge + idx,\n                    year: data.year,\n                    netWorth: data.netWorth,\n                },\n            })),\n        }\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/plan/projection/index.ts",
    "content": "export * from './projection-calculator'\nexport * from './projection-value'\nexport * from './monte-carlo'\n"
  },
  {
    "path": "libs/server/features/src/plan/projection/monte-carlo.spec.ts",
    "content": "import Decimal from 'decimal.js'\nimport { monteCarlo } from './monte-carlo'\n\nconst d = (x: Decimal.Value) => new Decimal(x)\n\ndescribe('monte carlo', () => {\n    it('simulates', () => {\n        const results = monteCarlo(() => Math.random(), {\n            n: 1_000,\n        })\n\n        expect(results).toHaveLength(1_000)\n    })\n})\n"
  },
  {
    "path": "libs/server/features/src/plan/projection/monte-carlo.ts",
    "content": "import range from 'lodash/range'\n\ntype MonteCarloOptions = {\n    n: number\n}\n\nexport function monteCarlo<T>(\n    fn: (i: number) => T,\n    { n = 1_000 }: Partial<MonteCarloOptions> = {}\n) {\n    return range(n).map(fn)\n}\n"
  },
  {
    "path": "libs/server/features/src/plan/projection/projection-calculator.spec.ts",
    "content": "import Decimal from 'decimal.js'\nimport { DateTime } from 'luxon'\nimport { AssetValue } from './projection-value'\nimport { ProjectionCalculator } from './projection-calculator'\n\nexpect.extend({\n    toEqualDecimal(\n        received: Decimal.Value,\n        expected: Decimal.Value,\n        threshold: Decimal.Value = '0.01'\n    ) {\n        const pass = Decimal.sub(received, expected).abs().lt(threshold)\n        return {\n            pass,\n            message: () =>\n                `expected ${this.utils.printReceived(received)} ${\n                    pass ? `not to be` : 'to be'\n                } within ${threshold} of ${this.utils.printExpected(expected)}`,\n        }\n    },\n})\n\ninterface CustomMatchers<R = unknown> {\n    toEqualDecimal(expected: Decimal.Value, threshold?: Decimal.Value): R\n}\n\n/* eslint-disable */\ndeclare global {\n    namespace jest {\n        interface Expect extends CustomMatchers {}\n        interface Matchers<R> extends CustomMatchers<R> {}\n        interface InverseAsymmetricMatchers extends CustomMatchers {}\n    }\n}\n/* eslint-enable */\n\nconst calculator = new ProjectionCalculator()\n\ndescribe('projection service', () => {\n    it('simulates assets', () => {\n        const series = calculator.calculate(\n            {\n                years: 30,\n                assets: [\n                    { id: 'stock', value: new AssetValue(800, 0.05) },\n                    { id: 'bonds', value: new AssetValue(150, 0.02) },\n                    { id: 'cash', value: new AssetValue(50, -0.2) },\n                ],\n                liabilities: [],\n                events: [],\n                milestones: [],\n            },\n            DateTime.fromISO('2022-01-01')\n        )\n\n        expect(series).toHaveLength(30)\n\n        series.forEach((data) => {\n            expect(data.assets).toHaveLength(3)\n        })\n\n        expect(series[0]).toMatchObject({ year: 2022, netWorth: expect.toEqualDecimal(1000) })\n        expect(series[1]).toMatchObject({ year: 2023, netWorth: expect.toEqualDecimal(1033) })\n        expect(series[2]).toMatchObject({ year: 2024, netWorth: expect.toEqualDecimal(1067.09) })\n        expect(series[29]).toMatchObject({ year: 2051, netWorth: expect.toEqualDecimal(2563.95) })\n    })\n\n    it('simulates liabilities', () => {\n        const series = calculator.calculate(\n            {\n                years: 10,\n                assets: [{ id: 'asset', value: new AssetValue(100) }],\n                liabilities: [{ id: 'liability', value: new AssetValue(50) }],\n                events: [],\n                milestones: [],\n            },\n            DateTime.fromISO('2022-01-01')\n        )\n\n        expect(series).toHaveLength(10)\n\n        series.forEach((data) => {\n            expect(data.liabilities).toHaveLength(1)\n        })\n\n        expect(series[0]).toMatchObject({ year: 2022, netWorth: expect.toEqualDecimal(50) })\n        expect(series[1]).toMatchObject({ year: 2023, netWorth: expect.toEqualDecimal(50) })\n        expect(series[2]).toMatchObject({ year: 2024, netWorth: expect.toEqualDecimal(50) })\n        expect(series[9]).toMatchObject({ year: 2031, netWorth: expect.toEqualDecimal(50) })\n    })\n\n    it('simulates events', () => {\n        const series = calculator.calculate(\n            {\n                years: 30,\n                assets: [\n                    { id: 'stock', value: new AssetValue(800, 0.05) },\n                    { id: 'bonds', value: new AssetValue(150, 0.02) },\n                    { id: 'cash', value: new AssetValue(50, -0.03) },\n                ],\n                liabilities: [],\n                events: [\n                    { id: 'salary', value: new AssetValue(2000) },\n                    { id: 'rent', value: new AssetValue(-1000) },\n                    { id: 'windfall', value: new AssetValue(100), start: 2025, end: 2025 },\n                ],\n                milestones: [],\n            },\n            DateTime.fromISO('2022-01-01')\n        )\n\n        expect(series).toHaveLength(30)\n        expect(series[0]).toMatchObject({ year: 2022, netWorth: expect.toEqualDecimal(2000) })\n        expect(series[1]).toMatchObject({ year: 2023, netWorth: expect.toEqualDecimal(3083) })\n        expect(series[2]).toMatchObject({ year: 2024, netWorth: expect.toEqualDecimal(4210.94) })\n        expect(series[2].events).toHaveLength(2)\n        expect(series[3]).toMatchObject({ year: 2025, netWorth: expect.toEqualDecimal(5485.7) })\n        expect(series[3].events).toHaveLength(3)\n        expect(series[4]).toMatchObject({ year: 2026, netWorth: expect.toEqualDecimal(6713.36) })\n        expect(series[4].events).toHaveLength(2)\n    })\n\n    it('simulates milestones', () => {\n        const series = calculator.calculate(\n            {\n                years: 30,\n                assets: [\n                    { id: 'stock', value: new AssetValue(800, 0.05) },\n                    { id: 'bonds', value: new AssetValue(150, 0.02) },\n                    { id: 'cash', value: new AssetValue(50, -0.03) },\n                ],\n                liabilities: [],\n                events: [\n                    { id: 'salary', value: new AssetValue(2000), end: 'fi' },\n                    { id: 'rent', value: new AssetValue(-1000) },\n                    {\n                        id: 'fi-spend',\n                        value: new AssetValue(-100),\n                        start: 'fi',\n                        end: 'retirement',\n                    },\n                    { id: 'retirement-spend', value: new AssetValue(-100), start: 'retirement' },\n                ],\n                milestones: [\n                    { id: 'fi', type: 'net-worth', expenseMultiple: 25, expenseYears: 3 },\n                    { id: 'retirement', type: 'year', year: 2050 },\n                ],\n            },\n            DateTime.fromISO('2022-01-01')\n        )\n\n        expect(series).toHaveLength(30)\n        expect(series[0]).toMatchObject({ year: 2022, netWorth: expect.toEqualDecimal(2000) })\n        expect(series[1]).toMatchObject({ year: 2023, netWorth: expect.toEqualDecimal(3083) })\n        expect(series[2]).toMatchObject({ year: 2024, netWorth: expect.toEqualDecimal(4210.94) })\n\n        // year before `fi` milestone\n        expect(series[15].events.map((e) => e.id)).toEqual(['salary', 'rent'])\n        // year of `fi` milestone\n        expect(series[16].events.map((e) => e.id)).toEqual(['salary', 'rent'])\n        expect(series[16].milestones.map((m) => m.id)).toEqual(['fi'])\n        // year after `fi` milestone\n        expect(series[17].events.map((e) => e.id)).toEqual(['rent', 'fi-spend'])\n\n        // year before `retirement` milestone\n        expect(series[27].events.map((e) => e.id)).toEqual(['rent', 'fi-spend'])\n        // year of `retirement` milestone\n        expect(series[28].events.map((e) => e.id)).toEqual(['rent', 'fi-spend', 'retirement-spend'])\n        expect(series[28].milestones.map((m) => m.id)).toEqual(['retirement'])\n        // year after `retirement` milestone\n        expect(series[29].events.map((e) => e.id)).toEqual(['rent', 'retirement-spend'])\n    })\n\n    it('events end in same year as referenced milestone', () => {\n        const series = calculator.calculate(\n            {\n                years: 10,\n                assets: [],\n                liabilities: [],\n                events: [{ id: 'income', value: new AssetValue(1_000), end: 'retirement' }],\n                milestones: [{ id: 'retirement', type: 'year', year: 2025 }],\n            },\n            DateTime.fromISO('2022-01-01')\n        )\n\n        // 2024\n        expect(series[2].events).toHaveLength(1)\n        expect(series[2].milestones).toHaveLength(0)\n\n        // 2025\n        expect(series[3].events).toHaveLength(1)\n        expect(series[3].milestones).toHaveLength(1)\n\n        // 2026\n        expect(series[4].events).toHaveLength(0)\n        expect(series[4].milestones).toHaveLength(0)\n    })\n})\n"
  },
  {
    "path": "libs/server/features/src/plan/projection/projection-calculator.ts",
    "content": "import type { ProjectionValue } from './projection-value'\nimport type Decimal from 'decimal.js'\nimport { DateTime } from 'luxon'\nimport { NumberUtil } from '@maybe-finance/shared'\nimport range from 'lodash/range'\n\nexport type ProjectionAsset = {\n    id: string\n    value: ProjectionValue\n}\n\nexport type ProjectionLiability = {\n    id: string\n    value: ProjectionValue\n}\n\nexport type ProjectionMilestone = {\n    id: string\n} & (\n    | {\n          type: 'year'\n          year: number\n      }\n    | {\n          type: 'net-worth'\n          expenseMultiple: number\n          expenseYears: number\n      }\n)\n\nexport type ProjectionEvent = {\n    id: string\n    value: ProjectionValue\n    start?: number | ProjectionMilestone['id'] | null\n    end?: number | ProjectionMilestone['id'] | null\n}\n\nexport type ProjectionInput = {\n    years: number\n    assets: ProjectionAsset[]\n    liabilities: ProjectionLiability[]\n    events: ProjectionEvent[]\n    milestones: ProjectionMilestone[]\n}\n\nexport type ProjectionSeriesData = {\n    year: number\n    netWorth: Decimal\n    assets: { id: ProjectionAsset['id']; balance: Decimal }[]\n    liabilities: { id: ProjectionLiability['id']; balance: Decimal }[]\n    events: { id: ProjectionEvent['id']; balance: Decimal }[]\n    milestones: { id: ProjectionMilestone['id'] }[]\n}\n\nexport interface IProjectionCalculator {\n    calculate(input: ProjectionInput, now?: DateTime): ProjectionSeriesData[]\n}\n\nexport class ProjectionCalculator implements IProjectionCalculator {\n    calculate(input: ProjectionInput, now = DateTime.now()): ProjectionSeriesData[] {\n        const initialAssets = NumberUtil.sumBy(input.assets, (a) => a.value.initialValue)\n        const assetsWithAllocation = input.assets.map((asset) => ({\n            ...asset,\n            allocation: asset.value.initialValue.dividedBy(initialAssets),\n        }))\n\n        const milestones: { id: ProjectionMilestone['id']; year: number }[] = input.milestones\n            .filter((m): m is Extract<ProjectionMilestone, { type: 'year' }> => m.type === 'year')\n            .map((m) => ({ id: m.id, year: m.year }))\n\n        return range(input.years).reduce((acc, t) => {\n            const year = now.year + t\n\n            // events\n            const events = input.events\n                .filter((e) => this.isActive(e, year, milestones))\n                .map((event) => {\n                    if (t === 0) {\n                        return { id: event.id, balance: event.value.initialValue }\n                    }\n\n                    const balancePrev = acc[t - 1]!.events.find((e) => e.id === event.id)?.balance\n\n                    return {\n                        id: event.id,\n                        balance: event.value.next(balancePrev),\n                    }\n                })\n            const netEvents = NumberUtil.sumBy(events, (e) => e.balance)\n\n            // assets\n            const assets = assetsWithAllocation.map((asset) => {\n                if (t === 0) {\n                    return {\n                        id: asset.id,\n                        balance: asset.value.initialValue,\n                    }\n                }\n\n                // in order to determine this asset's last balance we assume a constant allocation\n                // of contributions (ie. netEventsPrev) based on the initial (t0) allocation\n                // that way we don't have to do any manual \"rebalancing\" of the asset portfolio\n                const netAssetsPrev = NumberUtil.sumBy(acc[t - 1]!.assets, (a) => a.balance)\n                const netEventsPrev = NumberUtil.sumBy(acc[t - 1]!.events, (e) => e.balance)\n                const balancePrev = netAssetsPrev.plus(netEventsPrev).times(asset.allocation)\n\n                return {\n                    id: asset.id,\n                    balance: asset.value.next(balancePrev),\n                }\n            })\n            const assetsTotal = NumberUtil.sumBy(assets, (a) => a.balance)\n\n            // liabilities\n            const liabilities = input.liabilities.map((liability, idx) => {\n                if (t === 0) {\n                    return {\n                        id: liability.id,\n                        balance: liability.value.initialValue,\n                    }\n                }\n\n                // ToDo: update this logic to apply \"payments\" made each year towards this liability (via `netEventsPrev`) so that the balance goes down over time.\n                // - we'll need to figure out priority for how excess money gets distributed between assets vs liabilities\n                // - ProjectionLab handles this by allowing user to allocate $X/yr to each liability, or specify a payment plan (eg. \"Pay over 10 years\"), or specify % of excess cash that gets put towards paying down debt\n                const balancePrev = acc[t - 1]!.liabilities[idx]!.balance\n\n                return {\n                    id: liability.id,\n                    balance: liability.value.next(balancePrev),\n                }\n            })\n            const liabilitiesTotal = NumberUtil.sumBy(liabilities, (l) => l.balance)\n\n            const netWorth = assetsTotal.minus(liabilitiesTotal).plus(netEvents)\n\n            // milestones\n            milestones.push(\n                ...input.milestones\n                    .filter(({ id }) => !milestones.some((m) => m.id === id))\n                    .map((milestone) => {\n                        switch (milestone.type) {\n                            case 'net-worth': {\n                                const data = acc.slice(-milestone.expenseYears)\n                                const target = NumberUtil.sumBy(data, ({ events }) =>\n                                    NumberUtil.sumBy(\n                                        events.filter((e) => e.balance.lt(0)),\n                                        (e) => e.balance.abs()\n                                    )\n                                )\n                                    .dividedBy(data.length)\n                                    .times(milestone.expenseMultiple)\n\n                                return {\n                                    id: milestone.id,\n                                    year: netWorth.gte(target) ? year : null,\n                                }\n                            }\n                            default:\n                                return { id: milestone.id, year: milestone.year }\n                        }\n                    })\n                    .filter((m): m is typeof milestones[0] => m.year != null)\n            )\n\n            acc.push({\n                year,\n                netWorth,\n                assets,\n                liabilities,\n                events,\n                milestones: milestones.filter((m) => m.year === year),\n            })\n\n            return acc\n        }, [] as ProjectionSeriesData[])\n    }\n\n    private isActive(\n        event: ProjectionEvent,\n        year: number,\n        milestones: { id: ProjectionMilestone['id']; year: number }[]\n    ): boolean {\n        const hasStarted =\n            event.start == null ||\n            (typeof event.start === 'number' && year >= event.start) ||\n            (typeof event.start === 'string' &&\n                milestones.some((m) => m.id === event.start && year >= m.year))\n\n        const hasEnded =\n            (typeof event.end === 'number' && year > event.end) ||\n            (typeof event.end === 'string' &&\n                milestones.some((m) => m.id === event.end && year > m.year))\n\n        return hasStarted && !hasEnded\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/plan/projection/projection-value.spec.ts",
    "content": "import { Decimal } from 'decimal.js'\nimport { AssetValue } from './projection-value'\n\nconst d = (n: Decimal.Value) => new Decimal(n)\n\ndescribe('asset value', () => {\n    it('never generates negative values', () => {\n        const value = new AssetValue(1, -2)\n        expect(value.initialValue).toEqual(d(1))\n\n        const next = value.next()\n        expect(next).toEqual(d(0))\n        expect(value.next(next)).toEqual(d(0))\n    })\n})\n"
  },
  {
    "path": "libs/server/features/src/plan/projection/projection-value.ts",
    "content": "import { StatsUtil } from '@maybe-finance/shared'\nimport Decimal from 'decimal.js'\n\nexport type ProjectionValue = {\n    initialValue: Decimal\n\n    /**\n     * @param previousValue the value to compute next from (defaults to `initialValue`)\n     */\n    next(previousValue?: Decimal.Value): Decimal\n}\n\n/**\n * Models an asset's value.\n */\nexport class AssetValue implements ProjectionValue {\n    readonly initialValue: Decimal\n    readonly rate: Decimal\n    readonly stddev: Decimal\n\n    /**\n     * @param initialValue initial asset value\n     * @param rate average rate of return\n     * @param stddev stddev used to simulate rate of return\n     */\n    constructor(initialValue: Decimal.Value, rate: Decimal.Value = 0, stddev: Decimal.Value = 0) {\n        this.initialValue = new Decimal(initialValue)\n        this.rate = new Decimal(rate)\n        this.stddev = new Decimal(stddev)\n    }\n\n    next(previousValue: Decimal.Value = this.initialValue): Decimal {\n        // avoid calculating `randomNormal` if stddev=0 for performance\n        const rate = this.stddev.isZero()\n            ? this.rate\n            : StatsUtil.randomNormal(this.rate, this.stddev)\n\n        // need to clamp the minimum rate at -100% since an asset can never lose more than 100% of its value.\n        return rate.clamp(-1, Infinity).times(previousValue).plus(previousValue)\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/providers/index.ts",
    "content": "export * from './teller'\nexport * from './vehicle'\nexport * from './property'\n"
  },
  {
    "path": "libs/server/features/src/providers/property/index.ts",
    "content": "export * from './property.service'\n"
  },
  {
    "path": "libs/server/features/src/providers/property/property.service.ts",
    "content": "import type { Account } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport type { IETL, SyncAccountOptions } from '@maybe-finance/server/shared'\nimport type { IAccountProvider } from '../../account'\nimport { etl } from '@maybe-finance/server/shared'\n\nexport type PropertyData = {\n    pricing: {}\n}\n\nexport class PropertyService implements IAccountProvider, IETL<Account, PropertyData> {\n    public constructor(private readonly logger: Logger) {}\n\n    async sync(account: Account, _options?: SyncAccountOptions) {\n        await etl(this, account)\n    }\n\n    async delete(_account: Account) {\n        // ToDo: implement if needed\n    }\n\n    async extract(_account: Account) {\n        // ToDo: fetch pricing from Zillow|Redfin\n        return {\n            pricing: {},\n        }\n    }\n\n    transform(_account: Account, data: PropertyData) {\n        return Promise.resolve(data)\n    }\n\n    async load(_account: Account, _data: PropertyData) {\n        // ToDo: save pricing valuation\n        throw new Error('Method not implemented.')\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/providers/teller/index.ts",
    "content": "export * from './teller.webhook'\nexport * from './teller.service'\nexport * from './teller.etl'\n"
  },
  {
    "path": "libs/server/features/src/providers/teller/teller.etl.ts",
    "content": "import type { AccountConnection, PrismaClient } from '@prisma/client'\nimport { AccountClassification } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport { AccountUtil, SharedUtil, type SharedType } from '@maybe-finance/shared'\nimport type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'\nimport { DbUtil, TellerUtil, type IETL, type ICryptoService } from '@maybe-finance/server/shared'\nimport { Prisma } from '@prisma/client'\nimport _ from 'lodash'\nimport { DateTime } from 'luxon'\n\nexport type TellerRawData = {\n    accounts: TellerTypes.Account[]\n    transactions: TellerTypes.Transaction[]\n    transactionsDateRange: SharedType.DateRange<DateTime>\n}\n\nexport type TellerData = {\n    accounts: TellerTypes.AccountWithBalances[]\n    transactions: TellerTypes.Transaction[]\n    transactionsDateRange: SharedType.DateRange<DateTime>\n}\n\ntype Connection = Pick<\n    AccountConnection,\n    'id' | 'userId' | 'tellerInstitutionId' | 'tellerAccessToken'\n>\n\nconst maybeCategoryByTellerCategory: Record<\n    Required<TellerTypes.Transaction['details']>['category'],\n    string\n> = {\n    accommodation: 'Travel',\n    advertising: 'Other',\n    bar: 'Food and Drink',\n    charity: 'Other',\n    clothing: 'Shopping',\n    dining: 'Food and Drink',\n    education: 'Other',\n    electronics: 'Shopping',\n    entertainment: 'Shopping',\n    fuel: 'Transportation',\n    general: 'Other',\n    groceries: 'Food and Drink',\n    health: 'Health',\n    home: 'Home Improvement',\n    income: 'Income',\n    insurance: 'Other',\n    investment: 'Other',\n    loan: 'Other',\n    office: 'Other',\n    phone: 'Utilities',\n    service: 'Other',\n    shopping: 'Shopping',\n    software: 'Shopping',\n    sport: 'Shopping',\n    tax: 'Other',\n    transport: 'Transportation',\n    transportation: 'Transportation',\n    utilities: 'Utilities',\n}\n\nexport class TellerETL implements IETL<Connection, TellerRawData, TellerData> {\n    public constructor(\n        private readonly logger: Logger,\n        private readonly prisma: PrismaClient,\n        private readonly teller: Pick<\n            TellerApi,\n            'getAccounts' | 'getTransactions' | 'getAccountBalances'\n        >,\n        private readonly crypto: ICryptoService\n    ) {}\n\n    async extract(connection: Connection): Promise<TellerRawData> {\n        if (!connection.tellerInstitutionId) {\n            throw new Error(`connection ${connection.id} is missing tellerInstitutionId`)\n        }\n        if (!connection.tellerAccessToken) {\n            throw new Error(`connection ${connection.id} is missing tellerAccessToken`)\n        }\n\n        const accessToken = this.crypto.decrypt(connection.tellerAccessToken)\n\n        const user = await this.prisma.user.findUniqueOrThrow({\n            where: { id: connection.userId },\n            select: {\n                id: true,\n                tellerUserId: true,\n            },\n        })\n\n        if (!user.tellerUserId) {\n            throw new Error(`user ${user.id} is missing tellerUserId`)\n        }\n\n        // TODO: Check if Teller supports date ranges for transactions\n        const transactionsDateRange = {\n            start: DateTime.now().minus(TellerUtil.TELLER_WINDOW_MAX),\n            end: DateTime.now(),\n        }\n\n        const accounts = await this._extractAccounts(accessToken)\n\n        const transactions = await this._extractTransactions(accessToken, accounts)\n\n        this.logger.info(\n            `Extracted Teller data for customer ${user.tellerUserId} accounts=${accounts.length} transactions=${transactions.length}`,\n            { connection: connection.id, transactionsDateRange }\n        )\n\n        return {\n            accounts,\n            transactions,\n            transactionsDateRange,\n        }\n    }\n\n    async transform(_connection: Connection, data: TellerData): Promise<TellerData> {\n        return {\n            ...data,\n        }\n    }\n\n    async load(connection: Connection, data: TellerData): Promise<void> {\n        await this.prisma.$transaction([\n            ...this._loadAccounts(connection, data),\n            ...this._loadTransactions(connection, data),\n        ])\n\n        this.logger.info(`Loaded Teller data for connection ${connection.id}`, {\n            connection: connection.id,\n        })\n    }\n\n    private async _extractAccounts(accessToken: string) {\n        const accounts = await this.teller.getAccounts({ accessToken })\n        return accounts\n    }\n\n    private _loadAccounts(connection: Connection, { accounts }: Pick<TellerData, 'accounts'>) {\n        return [\n            // upsert accounts\n            ...accounts.map((tellerAccount) => {\n                const type = TellerUtil.getType(tellerAccount.type)\n                const classification = AccountUtil.getClassification(type)\n\n                return this.prisma.account.upsert({\n                    where: {\n                        accountConnectionId_tellerAccountId: {\n                            accountConnectionId: connection.id,\n                            tellerAccountId: tellerAccount.id,\n                        },\n                    },\n                    create: {\n                        type: TellerUtil.getType(tellerAccount.type),\n                        provider: 'teller',\n                        categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),\n                        subcategoryProvider: tellerAccount.subtype ?? 'other',\n                        accountConnectionId: connection.id,\n                        userId: connection.userId,\n                        tellerAccountId: tellerAccount.id,\n                        name: tellerAccount.name,\n                        tellerType: tellerAccount.type,\n                        tellerSubtype: tellerAccount.subtype,\n                        mask: tellerAccount.last_four,\n                        ...TellerUtil.getAccountBalanceData(tellerAccount, classification),\n                    },\n                    update: {\n                        type: TellerUtil.getType(tellerAccount.type),\n                        categoryProvider: TellerUtil.tellerTypesToCategory(tellerAccount.type),\n                        subcategoryProvider: tellerAccount.subtype ?? 'other',\n                        tellerType: tellerAccount.type,\n                        tellerSubtype: tellerAccount.subtype,\n                        ..._.omit(TellerUtil.getAccountBalanceData(tellerAccount, classification), [\n                            'currentBalanceStrategy',\n                            'availableBalanceStrategy',\n                        ]),\n                    },\n                })\n            }),\n            // any accounts that are no longer in Teller should be marked inactive\n            this.prisma.account.updateMany({\n                where: {\n                    accountConnectionId: connection.id,\n                    AND: [\n                        { tellerAccountId: { not: null } },\n                        { tellerAccountId: { notIn: accounts.map((a) => a.id) } },\n                    ],\n                },\n                data: {\n                    isActive: false,\n                },\n            }),\n        ]\n    }\n\n    private async _extractTransactions(\n        accessToken: string,\n        tellerAccounts: TellerTypes.GetAccountsResponse\n    ) {\n        const accountTransactions = await Promise.all(\n            tellerAccounts.map(async (tellerAccount) => {\n                const type = TellerUtil.getType(tellerAccount.type)\n                const classification = AccountUtil.getClassification(type)\n\n                const transactions = await SharedUtil.withRetry(\n                    () =>\n                        this.teller.getTransactions({\n                            accountId: tellerAccount.id,\n                            accessToken,\n                        }),\n                    {\n                        maxRetries: 3,\n                    }\n                )\n                if (classification === AccountClassification.asset) {\n                    transactions.forEach((t) => {\n                        t.amount = String(Number(t.amount) * -1)\n                    })\n                }\n\n                return transactions\n            })\n        )\n        return accountTransactions.flat()\n    }\n\n    private _loadTransactions(\n        connection: Connection,\n        {\n            transactions,\n            transactionsDateRange,\n        }: Pick<TellerData, 'transactions' | 'transactionsDateRange'>\n    ) {\n        if (!transactions.length) return []\n\n        const txnUpsertQueries = _.chunk(transactions, 1_000).map((chunk) => {\n            return this.prisma.$executeRaw`\n                INSERT INTO transaction (account_id, teller_transaction_id, date, name, amount, pending, currency_code, merchant_name, teller_type, teller_category, category)\n                VALUES\n                    ${Prisma.join(\n                        chunk.map((tellerTransaction) => {\n                            const {\n                                id: transactionId,\n                                account_id,\n                                description,\n                                amount,\n                                status,\n                                type,\n                                details,\n                                date,\n                            } = tellerTransaction\n\n                            return Prisma.sql`(\n                                (SELECT id FROM account WHERE account_connection_id = ${\n                                    connection.id\n                                } AND teller_account_id = ${account_id.toString()}),\n                                ${transactionId},\n                                ${date}::date,\n                                ${description},\n                                ${DbUtil.toDecimal(Number(amount))},\n                                ${status === 'pending'},\n                                ${'USD'},\n                                ${details.counterparty?.name ?? ''},\n                                ${type},\n                                ${details.category ?? ''},\n                                ${maybeCategoryByTellerCategory[details.category ?? ''] ?? 'Other'}\n                            )`\n                        })\n                    )}\n                ON CONFLICT (teller_transaction_id) DO UPDATE\n                SET\n                    name = EXCLUDED.name,\n                    amount = EXCLUDED.amount,\n                    pending = EXCLUDED.pending,\n                    merchant_name = EXCLUDED.merchant_name,\n                    teller_type = EXCLUDED.teller_type,\n                    teller_category = EXCLUDED.teller_category,\n                    category = EXCLUDED.category;\n            `\n        })\n\n        return [\n            // upsert transactions\n            ...txnUpsertQueries,\n            // delete teller-specific transactions that are no longer in teller\n            this.prisma.transaction.deleteMany({\n                where: {\n                    account: {\n                        accountConnectionId: connection.id,\n                    },\n                    AND: [\n                        { tellerTransactionId: { not: null } },\n                        { tellerTransactionId: { notIn: transactions.map((t) => `${t.id}`) } },\n                    ],\n                    date: {\n                        gte: transactionsDateRange.start.startOf('day').toJSDate(),\n                        lte: transactionsDateRange.end.endOf('day').toJSDate(),\n                    },\n                },\n            }),\n        ]\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/providers/teller/teller.service.ts",
    "content": "import type { Logger } from 'winston'\nimport type { AccountConnection, PrismaClient, User } from '@prisma/client'\nimport type { IInstitutionProvider } from '../../institution'\nimport type {\n    AccountConnectionSyncEvent,\n    IAccountConnectionProvider,\n} from '../../account-connection'\nimport { SharedUtil } from '@maybe-finance/shared'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type { SyncConnectionOptions, CryptoService, IETL } from '@maybe-finance/server/shared'\nimport _ from 'lodash'\nimport { ErrorUtil, etl } from '@maybe-finance/server/shared'\nimport type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'\n\nexport class TellerService implements IAccountConnectionProvider, IInstitutionProvider {\n    constructor(\n        private readonly logger: Logger,\n        private readonly prisma: PrismaClient,\n        private readonly teller: TellerApi,\n        private readonly etl: IETL<AccountConnection>,\n        private readonly crypto: CryptoService,\n        private readonly webhookUrl: string | Promise<string>,\n        private readonly testMode: boolean\n    ) {}\n\n    async sync(connection: AccountConnection, options?: SyncConnectionOptions) {\n        if (options && options.type !== 'teller') throw new Error('invalid sync options')\n\n        await etl(this.etl, connection)\n    }\n\n    async onSyncEvent(connection: AccountConnection, event: AccountConnectionSyncEvent) {\n        switch (event.type) {\n            case 'success': {\n                await this.prisma.accountConnection.update({\n                    where: { id: connection.id },\n                    data: {\n                        status: 'OK',\n                        syncStatus: 'IDLE',\n                    },\n                })\n                break\n            }\n            case 'error': {\n                const { error } = event\n\n                await this.prisma.accountConnection.update({\n                    where: { id: connection.id },\n                    data: {\n                        status: 'ERROR',\n                        tellerError: ErrorUtil.isTellerError(error)\n                            ? (error.response.data as any)\n                            : undefined,\n                    },\n                })\n                break\n            }\n        }\n    }\n\n    async delete(connection: AccountConnection) {\n        // purge teller data\n        if (connection.tellerAccessToken && connection.tellerEnrollmentId) {\n            const accounts = await this.prisma.account.findMany({\n                where: { accountConnectionId: connection.id },\n            })\n\n            for (const account of accounts) {\n                if (!account.tellerAccountId) continue\n                await this.teller.deleteAccount({\n                    accessToken: this.crypto.decrypt(connection.tellerAccessToken),\n                    accountId: account.tellerAccountId,\n                })\n\n                this.logger.info(`Teller account ${account.id} removed`)\n            }\n\n            this.logger.info(`Teller enrollment ${connection.tellerEnrollmentId} removed`)\n        }\n    }\n\n    async getInstitutions() {\n        const tellerInstitutions = await SharedUtil.paginate({\n            pageSize: 10000,\n            delay:\n                process.env.NODE_ENV !== 'production'\n                    ? {\n                          onDelay: (message: string) => this.logger.debug(message),\n                          milliseconds: 7_000, // Sandbox rate limited at 10 calls / minute\n                      }\n                    : undefined,\n            fetchData: () =>\n                SharedUtil.withRetry(\n                    () =>\n                        this.teller.getInstitutions().then((data) => {\n                            this.logger.debug(\n                                `teller fetch inst=${data.length} (total=${data.length})`\n                            )\n                            return data\n                        }),\n                    {\n                        maxRetries: 3,\n                        onError: (error, attempt) => {\n                            this.logger.error(\n                                `Teller fetch institutions request failed attempt=${attempt}`,\n                                { error: ErrorUtil.parseError(error) }\n                            )\n\n                            return !ErrorUtil.isTellerError(error) || error.response.status >= 500\n                        },\n                    }\n                ),\n        })\n\n        return _.uniqBy(tellerInstitutions, (i) => i.id).map((tellerInstitution) => {\n            const { id, name } = tellerInstitution\n            return {\n                providerId: id,\n                name,\n                url: null,\n                logo: null,\n                logoUrl: `https://teller.io/images/banks/${id}.jpg`,\n                primaryColor: null,\n                oauth: false,\n                data: tellerInstitution,\n            }\n        })\n    }\n\n    async handleEnrollment(\n        userId: User['id'],\n        institution: Pick<TellerTypes.Institution, 'name' | 'id'>,\n        enrollment: TellerTypes.Enrollment\n    ) {\n        const connections = await this.prisma.accountConnection.findMany({\n            where: { userId },\n        })\n\n        if (connections.length > 40) {\n            throw new Error('MAX_ACCOUNT_CONNECTIONS')\n        }\n\n        const accounts = await this.teller.getAccounts({ accessToken: enrollment.accessToken })\n\n        this.logger.info(`Teller accounts retrieved for enrollment ${enrollment.enrollment.id}`)\n\n        // If all the accounts are Non-USD, throw an error\n        if (accounts.every((a) => a.currency !== 'USD')) {\n            throw new Error('USD_ONLY')\n        }\n\n        await this.prisma.user.update({\n            where: { id: userId },\n            data: {\n                tellerUserId: enrollment.user.id,\n            },\n        })\n\n        const accountConnection = await this.prisma.accountConnection.create({\n            data: {\n                name: enrollment.enrollment.institution.name,\n                type: 'teller' as SharedType.AccountConnectionType,\n                tellerEnrollmentId: enrollment.enrollment.id,\n                tellerInstitutionId: institution.id,\n                tellerAccessToken: this.crypto.encrypt(enrollment.accessToken),\n                userId,\n                syncStatus: 'PENDING',\n            },\n        })\n\n        return accountConnection\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/providers/teller/teller.webhook.ts",
    "content": "import type { Logger } from 'winston'\nimport type { PrismaClient } from '@prisma/client'\nimport type { TellerApi, TellerTypes } from '@maybe-finance/teller-api'\nimport type { IAccountConnectionService } from '../../account-connection'\n\nexport interface ITellerWebhookHandler {\n    handleWebhook(data: TellerTypes.WebhookData): Promise<void>\n}\n\nexport class TellerWebhookHandler implements ITellerWebhookHandler {\n    constructor(\n        private readonly logger: Logger,\n        private readonly prisma: PrismaClient,\n        private readonly teller: TellerApi,\n        private readonly accountConnectionService: IAccountConnectionService\n    ) {}\n\n    /**\n     * Process Teller webhooks. These handlers should execute as quick as possible and\n     * long-running operations should be performed in the background.\n     */\n    async handleWebhook(data: TellerTypes.WebhookData) {\n        switch (data.type) {\n            case 'webhook.test': {\n                this.logger.info('Received Teller webhook test')\n                break\n            }\n            default: {\n                this.logger.warn('Unhandled Teller webhook', { data })\n                break\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/providers/vehicle/index.ts",
    "content": "export * from './vehicle.service'\n"
  },
  {
    "path": "libs/server/features/src/providers/vehicle/vehicle.service.ts",
    "content": "import type { Account } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport type { IETL, SyncAccountOptions } from '@maybe-finance/server/shared'\nimport type { IAccountProvider } from '../../account'\nimport { etl } from '@maybe-finance/server/shared'\n\nexport type VehicleData = {\n    pricing: {}\n}\n\nexport class VehicleService implements IAccountProvider, IETL<Account, VehicleData> {\n    public constructor(private readonly logger: Logger) {}\n\n    async sync(account: Account, _options?: SyncAccountOptions) {\n        await etl(this, account)\n    }\n\n    async delete(_account: Account) {\n        // ToDo: implement if needed\n    }\n\n    /**\n     * @todo fetch pricing from KBB / Edmunds\n     */\n    async extract(_account: Account) {\n        return {\n            pricing: {},\n        }\n    }\n\n    transform(_account: Account, data: VehicleData) {\n        return Promise.resolve(data)\n    }\n\n    async load(_account: Account, _data: VehicleData) {\n        // ToDo: save pricing valuation\n        throw new Error('Method not implemented.')\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/security-pricing/index.ts",
    "content": "export * from './security-pricing.service'\nexport * from './security-pricing.processor'\n"
  },
  {
    "path": "libs/server/features/src/security-pricing/security-pricing.processor.ts",
    "content": "import type { Logger } from 'winston'\nimport type { SyncSecurityQueueJobData } from '@maybe-finance/server/shared'\nimport type { ISecurityPricingService } from './security-pricing.service'\n\nexport interface ISecurityPricingProcessor {\n    syncAll(jobData?: SyncSecurityQueueJobData): Promise<void>\n    syncUSStockTickers(jobData?: SyncSecurityQueueJobData): Promise<void>\n}\n\nexport class SecurityPricingProcessor implements ISecurityPricingProcessor {\n    constructor(\n        private readonly logger: Logger,\n        private readonly securityPricingService: ISecurityPricingService\n    ) {}\n\n    async syncAll(_jobData?: SyncSecurityQueueJobData) {\n        await this.securityPricingService.syncSecuritiesPricing()\n    }\n\n    async syncUSStockTickers(_jobData?: SyncSecurityQueueJobData) {\n        await this.securityPricingService.syncUSStockTickers()\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/security-pricing/security-pricing.service.ts",
    "content": "import type { PrismaClient, Security } from '@prisma/client'\nimport { SecurityProvider, AssetClass } from '@prisma/client'\nimport type {\n    IMarketDataService,\n    LivePricing,\n    EndOfDayPricing,\n    TSecurity,\n} from '@maybe-finance/server/shared'\nimport type { Logger } from 'winston'\nimport { Prisma } from '@prisma/client'\nimport { DateTime } from 'luxon'\nimport { SharedUtil } from '@maybe-finance/shared'\nimport _ from 'lodash'\n\nexport interface ISecurityPricingService {\n    syncSecurity(\n        security: Pick<Security, 'assetClass' | 'currencyCode' | 'id' | 'symbol'>,\n        syncStart?: string\n    ): Promise<void>\n    syncSecuritiesPricing(): Promise<void>\n    syncUSStockTickers(): Promise<void>\n}\n\nexport class SecurityPricingService implements ISecurityPricingService {\n    constructor(\n        private readonly logger: Logger,\n        private readonly prisma: PrismaClient,\n        private readonly marketDataService: IMarketDataService\n    ) {}\n\n    async syncSecurity(\n        security: Pick<Security, 'assetClass' | 'currencyCode' | 'id' | 'symbol'>,\n        syncStart?: string\n    ) {\n        const dailyPricing = await this.marketDataService.getDailyPricing(\n            security,\n            syncStart\n                ? DateTime.fromISO(syncStart, { zone: 'utc' })\n                : DateTime.utc().minus({ years: 2 }),\n            DateTime.now()\n        )\n\n        if (!dailyPricing.length) return\n\n        this.logger.debug(\n            `fetched ${dailyPricing.length} daily prices for Security{id=${security.id} symbol=${security.symbol})`\n        )\n\n        await this.prisma.$transaction([\n            this.prisma.$executeRaw`\n              INSERT INTO security_pricing (security_id, date, price_close, price_as_of, source)\n              VALUES\n                ${Prisma.join(\n                    dailyPricing.map(\n                        ({ date, priceClose }) =>\n                            Prisma.sql`(\n                              ${security.id},\n                              ${date.toISODate()}::date,\n                              ${priceClose},\n                              NOW(),\n                              ${this.marketDataService.source}\n                            )`\n                    )\n                )}\n              ON CONFLICT (security_id, date) DO UPDATE\n              SET\n                price_close = EXCLUDED.price_close,\n                price_as_of = EXCLUDED.price_as_of,\n                source = EXCLUDED.source;\n            `,\n            this.prisma.security.update({\n                where: { id: security.id },\n                data: {\n                    pricingLastSyncedAt: new Date(),\n                },\n            }),\n        ])\n    }\n\n    async syncSecuritiesPricing() {\n        if (!process.env.NX_POLYGON_API_KEY) {\n            this.logger.warn('No polygon API key found, skipping sync')\n            return\n        }\n\n        const profiler = this.logger.startTimer()\n\n        const dailyPrices = await this.marketDataService.getAllDailyPricing()\n\n        for await (const securities of SharedUtil.paginateIt({\n            pageSize: 1000,\n            fetchData: (offset, count) =>\n                this.prisma.security.findMany({\n                    select: {\n                        assetClass: true,\n                        currencyCode: true,\n                        id: true,\n                        symbol: true,\n                    },\n                    skip: offset,\n                    take: count,\n                }),\n        })) {\n            let pricingData: LivePricing<TSecurity>[] | EndOfDayPricing<TSecurity>[]\n            if (!process.env.NX_POLYGON_TIER || process.env.NX_POLYGON_TIER === 'basic') {\n                try {\n                    pricingData = await this.marketDataService.getEndOfDayPricing(\n                        securities,\n                        dailyPrices\n                    )\n                } catch (err) {\n                    this.logger.warn('Polygon fetch for EOD pricing failed', err)\n                    pricingData = []\n                }\n            } else {\n                try {\n                    pricingData = await this.marketDataService.getLivePricing(securities)\n                } catch (err) {\n                    this.logger.warn('Polygon fetch for live pricing failed', err)\n                    pricingData = []\n                }\n            }\n\n            const prices = pricingData.filter((p) => !!p.pricing)\n\n            this.logger.debug(\n                `Fetched pricing for ${prices.length} / ${securities.length} securities`\n            )\n\n            if (prices.length === 0) break\n\n            await this.prisma.$transaction([\n                this.prisma.$executeRaw`\n                  INSERT INTO security_pricing (security_id, date, price_close, price_as_of, source)\n                  VALUES\n                    ${Prisma.join(\n                        prices.map(\n                            ({ security, pricing }) =>\n                                Prisma.sql`(\n                                  ${security.id},\n                                  ${pricing!.updatedAt.toISODate()}::date,\n                                  ${pricing!.price},\n                                  ${pricing!.updatedAt.toJSDate()},\n                                  ${this.marketDataService.source}\n                                )`\n                        )\n                    )}\n                  ON CONFLICT (security_id, date) DO UPDATE\n                  SET\n                    price_close = EXCLUDED.price_close,\n                    price_as_of = EXCLUDED.price_as_of,\n                    source = EXCLUDED.source;\n                `,\n                // Update today's balance record for any accounts with holdings containing synced securities\n                this.prisma.$executeRaw`\n                  INSERT INTO account_balance (account_id, date, balance)\n                  SELECT\n                    h.account_id,\n                    NOW() AS date,\n                    SUM(COALESCE(h.quantity * sp.price_close * COALESCE(s.shares_per_contract, 1), h.value)) AS balance\n                  FROM\n                    holding h\n                    INNER JOIN security s ON s.id = h.security_id\n                    LEFT JOIN (\n                      SELECT DISTINCT ON (security_id)\n                        *\n                      FROM\n                        security_pricing\n                      ORDER BY\n                        security_id, date DESC\n                    ) sp ON sp.security_id = s.id\n                  WHERE\n                    h.account_id IN (\n                      SELECT\n                        a.id\n                      FROM\n                        account a\n                        INNER JOIN holding h ON h.account_id = a.id\n                      WHERE\n                        h.security_id IN (${Prisma.join(prices.map((p) => p.security.id))})\n                    )\n                  GROUP BY\n                    h.account_id\n                  ON CONFLICT (account_id, date) DO UPDATE\n                  SET\n                    balance = EXCLUDED.balance;\n                `,\n            ])\n        }\n\n        profiler.done({ message: 'Synced securities pricing' })\n    }\n\n    async syncUSStockTickers() {\n        if (!process.env.NX_POLYGON_API_KEY) {\n            this.logger.warn('No polygon API key found, skipping sync')\n            return\n        }\n\n        const profiler = this.logger.startTimer()\n\n        const usStockTickers = await this.marketDataService.getUSStockTickers()\n\n        if (!usStockTickers.length) return\n\n        this.logger.debug(`fetched ${usStockTickers.length} stock tickers`)\n\n        _.chunk(usStockTickers, 1_000).map((chunk) => {\n            return this.prisma.$transaction([\n                this.prisma.$executeRaw`\n                    INSERT INTO security (name, symbol, currency_code, exchange_acronym, exchange_mic, exchange_name, provider_name, asset_class)\n                    VALUES\n                      ${Prisma.join(\n                          chunk.map(\n                              ({\n                                  name,\n                                  ticker,\n                                  currency_name,\n                                  exchangeAcronym,\n                                  exchangeMic,\n                                  exchangeName,\n                              }) =>\n                                  Prisma.sql`(\n                                    ${name},\n                                    ${ticker},\n                                    ${currency_name?.toUpperCase()},\n                                    ${exchangeAcronym},\n                                    ${exchangeMic},\n                                    ${exchangeName},\n                                    ${SecurityProvider.polygon}::\"SecurityProvider\",\n                                    ${AssetClass.stocks}::\"AssetClass\"\n                                  )`\n                          )\n                      )}\n                    ON CONFLICT (symbol, exchange_mic) DO UPDATE\n                    SET\n                      name = EXCLUDED.name,\n                      currency_code = EXCLUDED.currency_code;\n                  `,\n            ])\n        })\n\n        profiler.done({ message: 'Synced US stock tickers' })\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/stripe/index.ts",
    "content": "export * from './stripe.webhook'\n"
  },
  {
    "path": "libs/server/features/src/stripe/stripe.webhook.ts",
    "content": "import type { Logger } from 'winston'\nimport type Stripe from 'stripe'\nimport type { PrismaClient } from '@prisma/client'\nimport { DateTime } from 'luxon'\n\nexport interface IStripeWebhookHandler {\n    handleWebhook(event: Stripe.Event): Promise<void>\n}\n\nexport class StripeWebhookHandler implements IStripeWebhookHandler {\n    constructor(\n        private readonly logger: Logger,\n        private readonly prisma: PrismaClient,\n        private readonly stripe: Stripe\n    ) {}\n\n    async handleWebhook(event: Stripe.Event) {\n        switch (event.type) {\n            case 'checkout.session.completed': {\n                const session = event.data.object as Stripe.Checkout.Session\n\n                if (!session.subscription || !session.client_reference_id) return\n\n                const subscription = await this.stripe.subscriptions.retrieve(\n                    session.subscription as string\n                )\n\n                await this.prisma.user.updateMany({\n                    where: {\n                        authId: session.client_reference_id,\n                    },\n                    data: {\n                        trialEnd: null,\n                        stripeCustomerId: subscription.customer as string,\n                        stripeSubscriptionId: subscription.id,\n                        stripePriceId: subscription.items.data[0]?.price.id,\n                        stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),\n                        stripeCancelAt: subscription.cancel_at\n                            ? new Date(subscription.cancel_at * 1000)\n                            : null,\n                    },\n                })\n\n                break\n            }\n            case 'customer.subscription.created':\n            case 'customer.subscription.updated': {\n                const subscription = event.data.object as Stripe.Subscription\n\n                await this.prisma.user.updateMany({\n                    where: {\n                        stripeCustomerId: subscription.customer as string,\n                    },\n                    data: {\n                        trialEnd: null,\n                        stripeSubscriptionId: subscription.id,\n                        stripePriceId: subscription.items.data[0]?.price.id,\n                        stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),\n                        stripeCancelAt: subscription.cancel_at\n                            ? new Date(subscription.cancel_at * 1000)\n                            : null,\n                    },\n                })\n\n                break\n            }\n            case 'customer.subscription.deleted': {\n                const subscription = event.data.object as Stripe.Subscription\n\n                await this.prisma.user.updateMany({\n                    where: {\n                        stripeSubscriptionId: subscription.id,\n                    },\n                    data: {\n                        stripeSubscriptionId: null,\n                        stripePriceId: null,\n                        stripeCurrentPeriodEnd: null,\n                        stripeCancelAt: DateTime.now().toJSDate(),\n                    },\n                })\n\n                break\n            }\n            case 'customer.deleted': {\n                const customer = event.data.object as Stripe.Customer\n\n                await this.prisma.user.updateMany({\n                    where: {\n                        stripeCustomerId: customer.id,\n                    },\n                    data: {\n                        stripeCustomerId: null,\n                        stripeSubscriptionId: null,\n                        stripePriceId: null,\n                        stripeCurrentPeriodEnd: null,\n                        stripeCancelAt: null,\n                    },\n                })\n                break\n            }\n            default: {\n                this.logger.warn('Unhandled Stripe event', { event })\n                break\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/transaction/index.ts",
    "content": "export * from './transaction.service'\nexport * from './transaction.schema'\n"
  },
  {
    "path": "libs/server/features/src/transaction/transaction.schema.ts",
    "content": "import { DateTime } from 'luxon'\nimport { z } from 'zod'\nimport { DateUtil } from '@maybe-finance/shared'\n\nexport const TransactionPaginateParams = z.object({\n    pageIndex: z.string().transform((val) => parseInt(val)),\n    pageSize: z\n        .string()\n        .default('50')\n        .transform((val) => parseInt(val)),\n})\n\nexport const ISODateSchema = z\n    .string()\n    .default(DateTime.utc().toISODate())\n    .transform((s) => DateUtil.datetimeTransform(s).toJSDate())\n\nexport const TransactionUpdateInputSchema = z\n    .object({\n        date: ISODateSchema,\n        name: z.string(),\n        amount: z.number(),\n        categoryUser: z.string(),\n        excluded: z.boolean(),\n        typeUser: z.enum(['INCOME', 'EXPENSE', 'PAYMENT', 'TRANSFER']),\n    })\n    .partial()\n"
  },
  {
    "path": "libs/server/features/src/transaction/transaction.service.ts",
    "content": "import type { Logger } from 'winston'\nimport type { AccountConnection, PrismaClient, Transaction, User } from '@prisma/client'\nimport type { Prisma } from '@prisma/client'\nimport { SharedType } from '@maybe-finance/shared'\nimport { DateTime } from 'luxon'\n\ntype TransactionWithConnection = Transaction & {\n    account: SharedType.Account & {\n        accountConnection: AccountConnection | null\n    }\n}\n\nexport interface ITransactionService {\n    get(\n        id: User['id'],\n        pageIndex?: number,\n        pageSize?: SharedType.PageSize\n    ): Promise<TransactionWithConnection>\n    getAll(userId: User['id']): Promise<{ transactions: Transaction[]; pageCount: number }>\n    update(\n        id: Transaction['id'],\n        data: Prisma.TransactionUncheckedUpdateInput\n    ): Promise<Transaction>\n    markTransfers(userId: User['id'], startDate?: string): Promise<void>\n}\n\nexport class TransactionService implements ITransactionService {\n    constructor(private readonly logger: Logger, private readonly prisma: PrismaClient) {}\n\n    async getAll(\n        userId: User['id'],\n        pageIndex = 0,\n        pageSize = SharedType.PageSize.Transaction\n    ): Promise<SharedType.TransactionsResponse> {\n        const where = {\n            OR: [{ account: { userId } }, { account: { accountConnection: { userId } } }],\n        }\n\n        const [transactions, count] = await this.prisma.$transaction([\n            this.prisma.transaction.findMany({\n                where,\n                include: { account: { include: { accountConnection: true } } },\n                skip: pageIndex * pageSize,\n                take: pageSize,\n                orderBy: [{ date: 'desc' }, { amount: 'desc' }, { name: 'desc' }],\n            }),\n            this.prisma.transaction.count({ where }),\n        ])\n\n        return {\n            transactions,\n            pageCount: Math.ceil(count / pageSize),\n        }\n    }\n\n    async get(id: Transaction['id']) {\n        return await this.prisma.transaction.findUniqueOrThrow({\n            where: { id },\n            include: { account: { include: { accountConnection: true } } },\n        })\n    }\n\n    async update(id: Transaction['id'], data: Prisma.TransactionUncheckedUpdateInput) {\n        const transaction = await this.prisma.transaction.update({\n            where: { id },\n            data,\n        })\n\n        this.logger.info(`Updated transaction id=${id} account=${transaction.accountId}`)\n\n        return transaction\n    }\n\n    async markTransfers(\n        userId: User['id'],\n        startDate = DateTime.utc().minus({ years: 2 }).toISODate()\n    ) {\n        this.logger.debug(`Analyzing and enhancing transactions for user=${userId}`)\n\n        const transferPairs = await this.prisma.$queryRaw<{ id: number; match_id: number }[]>`\n            WITH txn_set AS (\n                SELECT \n                    t.id, \n                    t.date,\n                    t.amount, \n                    t.flow,\n                    a.id AS account_id, \n                    a.type AS account_type,\n                    a.classification AS account_classification\n                FROM transaction t\n                    INNER JOIN account a ON a.id = t.account_id\n                    LEFT JOIN account_connection ac ON ac.id = a.account_connection_id\n                WHERE (a.user_id = ${userId} OR ac.user_id = ${userId})\n                    AND t.date >= ${startDate}::date \n                    AND t.date <= ${DateTime.utc().toISODate()}::date\n            ), txn_matches as (\n                SELECT DISTINCT \n                    ON (t.id)\n                    t.id,\n                    tc.id AS match_id\n                FROM txn_set t\n                LEFT JOIN txn_set tc ON tc.id <> t.id\n                    AND tc.account_id <> t.account_id\n                    AND ABS(tc.date - t.date) <= 1 \n                    AND tc.amount = - t.amount\n                    AND (\n                        (t.account_classification = 'asset' AND tc.account_classification = 'asset') -- asset transfer\n                        OR (t.account_classification = 'asset' AND t.flow = 'OUTFLOW' AND tc.account_classification = 'liability') -- transfer from asset to liability\n                        OR (t.account_classification = 'liability' AND t.flow = 'INFLOW' AND tc.account_classification = 'asset') -- payment received from asset to liability\n                    )\n                WHERE tc IS NOT NULL\n            )\n            UPDATE transaction t\n            SET match_id = tm.match_id\n            FROM txn_matches tm\n            WHERE t.id = tm.id\n            RETURNING t.id, tm.match_id \n        `\n\n        if (transferPairs.length) {\n            this.logger.info(\n                `Marked ${transferPairs.length} transactions as transfer matches for user=${userId}`,\n                transferPairs\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/user/index.ts",
    "content": "export * from './user.processor'\nexport * from './user.service'\nexport * from './onboarding.schema'\n\nexport type { OnboardingState, RegisteredStep } from './onboarding.service'\n"
  },
  {
    "path": "libs/server/features/src/user/onboarding.schema.ts",
    "content": "import { z } from 'zod'\n\nexport const UpdateOnboardingSchema = z.discriminatedUnion('flow', [\n    z.object({\n        flow: z.literal('main'),\n        updates: z\n            .object({\n                key: z.enum([\n                    'intro',\n                    'profile',\n                    'verifyEmail',\n                    'firstAccount',\n                    'accountSelection',\n                    'terms',\n                    'maybe',\n                    'welcome',\n                ]),\n                markedComplete: z.boolean(),\n            })\n            .array(),\n    }),\n    z.object({\n        flow: z.literal('sidebar'),\n        markedComplete: z.boolean().optional(),\n        updates: z\n            .object({\n                key: z.enum([\n                    'connect-depository',\n                    'connect-investment',\n                    'connect-liability',\n                    'add-crypto',\n                    'add-property',\n                    'add-vehicle',\n                    'add-other',\n                    'upgrade-account',\n                    'create-plan',\n                ]),\n                markedComplete: z.boolean(),\n            })\n            .array(),\n    }),\n])\n"
  },
  {
    "path": "libs/server/features/src/user/onboarding.service.ts",
    "content": "import type { SharedType } from '@maybe-finance/shared'\n\nexport type RegisteredStep = {\n    key: string\n    markedComplete: boolean\n}\n\n// DB Json field where each key represents a separate onboarding flow\nexport type OnboardingState = {\n    [key in SharedType.OnboardingFlow]: {\n        markedComplete: boolean // An override to indicate the flow is complete\n        steps: RegisteredStep[]\n    }\n}\n\ntype CallbackFn<TData = unknown, T = boolean> = (user: TData, step: Step<TData>) => T\n\nexport class Step<TData = unknown> {\n    group: string | undefined\n    ctaPath: string | undefined\n    title!: CallbackFn<TData, string>\n    isComplete!: CallbackFn<TData>\n    isMarkedComplete: CallbackFn<TData> = () => false\n    isExcluded: CallbackFn<TData> = () => false\n    isOptional = false\n\n    /**\n     * Constructs an onboarding step\n     * @param key a stable key that the UI relies to show relevant view components\n     * @param group a grouping that UI uses to visually group steps as \"substeps\"\n     */\n    constructor(readonly key: string) {}\n\n    addToGroup(group: string) {\n        this.group = group\n        return this\n    }\n\n    /** When clicked, the router path that app will redirect to */\n    setCTAPath(path: string) {\n        this.ctaPath = path\n        return this\n    }\n\n    setTitle(fn: CallbackFn<TData, string>) {\n        this.title = fn\n        return this\n    }\n\n    completeIf(fn: CallbackFn<TData>) {\n        this.isComplete = fn\n        return this\n    }\n\n    markedCompleteIf(fn: CallbackFn<TData>) {\n        this.isMarkedComplete = fn\n        return this\n    }\n\n    excludeIf(fn: CallbackFn<TData>) {\n        this.isExcluded = fn\n        return this\n    }\n\n    optional() {\n        this.isOptional = true\n        return this\n    }\n}\n\nexport class Onboarding<TData = unknown> {\n    private _steps: Step<TData>[] = []\n\n    constructor(\n        private readonly data: TData,\n        private readonly onboardingCompleteOverride: boolean\n    ) {\n        if (!this._steps.every((step) => step.completeIf != null && step.title != null)) {\n            throw new Error('Every step must define completeIf callback fn and title')\n        }\n    }\n\n    get isComplete() {\n        return this.steps.every(\n            (step) => step.isComplete || step.isOptional || step.isMarkedComplete\n        )\n    }\n\n    get isMarkedComplete() {\n        return this.steps.every((step) => step.isMarkedComplete) || this.onboardingCompleteOverride\n    }\n\n    /** Progress, expressed as percentage. */\n    get progress() {\n        const completed = this.steps.filter(\n            (step) => step.isComplete || step.isMarkedComplete\n        ).length\n\n        return {\n            completed,\n            total: this.steps.length,\n            percent: +(completed / this.steps.length).toFixed(2),\n        }\n    }\n\n    get steps() {\n        return this._steps\n            .filter((step) => !step.isExcluded(this.data, step))\n            .map((step) => ({\n                key: step.key,\n                title: step.title(this.data, step),\n                group: step.group,\n                ctaPath: step.ctaPath,\n                isOptional: step.isOptional,\n                isComplete: step.isComplete(this.data, step),\n                isMarkedComplete: step.isMarkedComplete(this.data, step),\n            }))\n    }\n\n    get currentStep() {\n        const incompleteSteps = this.steps.filter((step) => !step.isComplete)\n        return incompleteSteps[0]\n    }\n\n    addStep(key: string) {\n        const step = new Step<TData>(key)\n        this._steps.push(step)\n        return step\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/user/user.processor.ts",
    "content": "import type { Logger } from 'winston'\nimport type { PrismaClient, User } from '@prisma/client'\nimport type { SyncUserQueueJobData } from '@maybe-finance/server/shared'\nimport type { IAccountService } from '../account'\nimport type { IUserService } from './user.service'\nimport type {\n    IAccountConnectionProviderFactory,\n    IAccountConnectionService,\n} from '../account-connection'\nimport { ServerUtil } from '@maybe-finance/server/shared'\n\nexport interface IUserProcessor {\n    sync(jobData: SyncUserQueueJobData): Promise<void>\n    delete(jobData: SyncUserQueueJobData): Promise<void>\n}\n\nexport class UserProcessor implements IUserProcessor {\n    constructor(\n        private readonly logger: Logger,\n        private readonly prisma: PrismaClient,\n        private readonly userService: IUserService,\n        private readonly accountService: IAccountService,\n        private readonly connectionService: IAccountConnectionService,\n        private readonly connectionProviders: IAccountConnectionProviderFactory\n    ) {}\n\n    async sync(jobData: SyncUserQueueJobData) {\n        const user = await this.userService.get(jobData.userId)\n\n        await ServerUtil.useSync<User>({\n            sync: async (user) => {\n                const { connections, accounts } = await this.accountService.getAll(user.id)\n\n                await Promise.allSettled([\n                    ...connections.map((connection) => this.connectionService.sync(connection.id)),\n                    ...accounts.map((account) => this.accountService.sync(account.id)),\n                ])\n            },\n            onSyncSuccess: (user) => this.userService.syncBalances(user.id),\n            onSyncError: async (user, error) => {\n                this.logger.error(`error syncing user ${user.id}`, { error })\n            },\n        })(user)\n    }\n\n    async delete(jobData: SyncUserQueueJobData) {\n        const { userId } = jobData\n\n        this.logger.info(`deleting user ${userId}...`)\n\n        const connections = await this.prisma.accountConnection.findMany({\n            where: { userId },\n        })\n\n        // delete connection data\n        this.logger.info(`deleting user ${userId} data for ${connections.length} connections...`)\n        await Promise.allSettled(connections.map((c) => this.connectionProviders.for(c).delete(c)))\n\n        // delete from database (will cascade to relations)\n        this.logger.info(`deleting user ${userId} from database...`)\n        await this.prisma.user.delete({ where: { id: userId } })\n\n        this.logger.info(`user ${userId} deleted successfully`)\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/user/user.service.ts",
    "content": "import type { AccountCategory, AccountType, PrismaClient, User } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport type { PurgeUserQueue, SyncUserQueue } from '@maybe-finance/server/shared'\nimport type Stripe from 'stripe'\nimport type { IBalanceSyncStrategyFactory } from '../account-balance'\nimport type { IAccountQueryService } from '../account'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { DateTime } from 'luxon'\nimport { DbUtil } from '@maybe-finance/server/shared'\nimport { DateUtil } from '@maybe-finance/shared'\nimport { flatten } from 'lodash'\nimport { Onboarding, type OnboardingState, type Step } from './onboarding.service'\n\nexport type MainOnboardingUser = Pick<\n    User,\n    'dob' | 'household' | 'maybeGoals' | 'firstName' | 'lastName' | 'name'\n> & {\n    emailVerified: boolean\n    isAppleIdentity: boolean\n    onboarding: OnboardingState['main']\n    accountConnections: { accounts: { id: number }[] }[]\n    accounts: { id: number }[]\n}\n\nexport type SidebarOnboardingUser = {\n    onboarding: OnboardingState['sidebar']\n    accountConnections: {\n        accounts: {\n            type: AccountType\n            category: AccountCategory\n        }[]\n    }[]\n    accounts: {\n        type: AccountType\n        category: AccountCategory\n    }[]\n    _count: {\n        plans: number\n    }\n}\n\nexport interface IUserService {\n    get(id: User['id']): Promise<User>\n    sync(id: User['id']): Promise<User>\n    syncBalances(id: User['id']): Promise<User>\n    delete(id: User['id']): Promise<User>\n}\n\nexport class UserService implements IUserService {\n    constructor(\n        private readonly logger: Logger,\n        private readonly prisma: PrismaClient,\n        private readonly queryService: IAccountQueryService,\n        private readonly balanceSyncStrategyFactory: IBalanceSyncStrategyFactory,\n        private readonly syncQueue: SyncUserQueue,\n        private readonly purgeQueue: PurgeUserQueue,\n        private readonly stripe: Stripe\n    ) {}\n\n    async get(id: User['id']) {\n        return this.prisma.user.findUniqueOrThrow({\n            where: { id },\n        })\n    }\n\n    async getAuthProfile(id: User['id']): Promise<SharedType.AuthUser> {\n        const user = await this.get(id)\n        return this.prisma.authUser.findUniqueOrThrow({\n            where: { id: user.authId },\n        })\n    }\n\n    async sync(id: User['id']) {\n        const user = await this.get(id)\n        await this.syncQueue.add('sync-user', { userId: user.id })\n        return user\n    }\n\n    async syncBalances(id: User['id']) {\n        const user = await this.prisma.user.findUniqueOrThrow({\n            where: { id },\n            include: {\n                accounts: true,\n                accountConnections: {\n                    select: {\n                        accounts: true,\n                    },\n                },\n            },\n        })\n\n        const profiler = this.logger.startTimer()\n\n        await Promise.all([\n            ...user.accounts.map((account) =>\n                this.balanceSyncStrategyFactory.for(account).syncAccountBalances(account)\n            ),\n            ...user.accountConnections.flatMap((connection) =>\n                connection.accounts.map((account) =>\n                    this.balanceSyncStrategyFactory.for(account).syncAccountBalances(account)\n                )\n            ),\n        ])\n\n        profiler.done({ message: `Synced user ${id} balances` })\n\n        return user\n    }\n\n    async update(id: User['id'], data: SharedType.UpdateUser) {\n        return this.prisma.user.update({\n            where: { id },\n            data,\n        })\n    }\n\n    async delete(id: User['id']) {\n        const user = await this.get(id)\n\n        // Delete Stripe customer, ending any active subscriptions\n        if (user.stripeCustomerId) await this.stripe.customers.del(user.stripeCustomerId)\n\n        // Delete user from Auth so that it cannot be accessed in a partially-purged state\n        // TODO: Update this to use new Auth\n        this.logger.info(`Removing user ${user.id} from Auth (${user.authId})`)\n        await this.prisma.authUser.delete({ where: { id: user.authId } })\n\n        await this.purgeQueue.add('purge-user', { userId: user.id })\n\n        return user\n    }\n\n    async getNetWorth(\n        userId: User['id'],\n        date: string = DateTime.utc().plus({ days: 1 }).toISODate() // default to one day here to ensure we're grabbing the most recent date's net worth\n    ): Promise<SharedType.NetWorthTimeSeriesData | undefined> {\n        const [netWorth] = await this.queryService.getNetWorthSeries({ userId }, date, date, 'days')\n\n        return netWorth\n    }\n\n    async getNetWorthSeries(\n        userId: User['id'],\n        start = DateTime.utc().minus({ years: 2 }).toISODate(),\n        end = DateTime.utc().toISODate(),\n        interval?: SharedType.TimeSeriesInterval\n    ): Promise<SharedType.NetWorthTimeSeriesResponse> {\n        interval = interval ?? DateUtil.calculateTimeSeriesInterval(start, end)\n\n        const [series, today, minDate] = await Promise.all([\n            this.queryService.getNetWorthSeries({ userId }, start, end, interval),\n            this.getNetWorth(userId),\n            this.getOldestBalanceDate(userId),\n        ])\n\n        return {\n            series: {\n                interval,\n                start,\n                end,\n                data: series,\n            },\n            today,\n            minDate,\n            trend:\n                series.length > 0\n                    ? DbUtil.calculateTrend(series[0].netWorth, series[series.length - 1].netWorth)\n                    : { amount: null, percentage: null, direction: 'flat' },\n        }\n    }\n\n    private async getOldestBalanceDate(userId: User['id']): Promise<string> {\n        const [{ min_start_date }] = await this.prisma.$queryRaw<[{ min_start_date: Date | null }]>`\n            SELECT\n                LEAST(\n                    MIN(a.start_date),\n                    MIN(account_value_start_date(a.id))\n                ) AS min_start_date\n            FROM\n                account a\n                LEFT JOIN account_connection ac ON ac.id = a.account_connection_id\n            WHERE\n                (a.user_id = ${userId} OR ac.user_id = ${userId})\n                AND a.is_active;\n        `\n\n        const minDate = DateTime.min(\n            DateTime.utc().minus({ years: 2 }),\n            ...(min_start_date ? [DateTime.fromJSDate(min_start_date, { zone: 'utc' })] : [])\n        )\n\n        return minDate.toISODate()\n    }\n\n    async getSubscription(userId: User['id']): Promise<SharedType.UserSubscription> {\n        const {\n            trialEnd: trialEndRaw,\n            stripePriceId,\n            stripeCurrentPeriodEnd,\n            stripeCancelAt,\n        } = await this.prisma.user.findUniqueOrThrow({\n            select: {\n                trialEnd: true,\n                stripePriceId: true,\n                stripeCurrentPeriodEnd: true,\n                stripeCancelAt: true,\n            },\n            where: { id: userId },\n        })\n\n        const trialEnd = trialEndRaw ? DateTime.fromJSDate(trialEndRaw) : null\n\n        const trialing = trialEnd != null && trialEnd.diffNow().milliseconds > 0\n\n        const subscribed = !!stripePriceId || trialing\n        const cancelAt = stripeCancelAt ? DateTime.fromJSDate(stripeCancelAt) : null\n\n        return {\n            subscribed,\n            trialing,\n            canceled: stripeCancelAt != null,\n\n            currentPeriodEnd: stripeCurrentPeriodEnd\n                ? DateTime.fromJSDate(stripeCurrentPeriodEnd)\n                : null,\n            trialEnd,\n            cancelAt,\n        }\n    }\n\n    async getMemberCard(memberId: string, clientUrl: string) {\n        const {\n            name,\n            memberNumber,\n            title,\n            createdAt: joinDate,\n            maybe,\n        } = await this.prisma.user.findUniqueOrThrow({\n            where: { memberId },\n            select: {\n                name: true,\n                memberNumber: true,\n                title: true,\n                createdAt: true,\n                maybe: true,\n            },\n        })\n\n        const cardUrl = new URL(`/card/${memberId}`, clientUrl)\n\n        const imageUrl = new URL('api/card', clientUrl)\n        imageUrl.searchParams.append('name', name || 'Maybe User')\n        imageUrl.searchParams.append('number', memberNumber.toString())\n        imageUrl.searchParams.append('title', title ?? '')\n        imageUrl.searchParams.append('date', joinDate.toISOString())\n\n        return {\n            memberNumber,\n            name,\n            title,\n            joinDate,\n            maybe,\n            cardUrl: cardUrl.href,\n            imageUrl: imageUrl.href,\n        }\n    }\n\n    async buildMainOnboarding(userId: User['id']): Promise<SharedType.OnboardingResponse> {\n        function markedComplete(user: MainOnboardingUser, step: Step<MainOnboardingUser>) {\n            return (\n                user.onboarding.steps.find((dbStep) => dbStep.key === step.key)?.markedComplete ??\n                false\n            )\n        }\n\n        const user = await this.prisma.user.findUniqueOrThrow({\n            where: { id: userId },\n            select: {\n                authId: true,\n                onboarding: true,\n                dob: true,\n                household: true,\n                maybeGoals: true,\n                firstName: true,\n                lastName: true,\n                name: true,\n                accounts: { select: { id: true } },\n                accountConnections: {\n                    select: { accounts: { select: { id: true } } },\n                },\n            },\n        })\n\n        const authUser = await this.prisma.authUser.findUniqueOrThrow({\n            where: { id: user.authId },\n        })\n\n        // NextAuth used DateTime for this field\n        const email_verified = authUser.emailVerified === null ? false : true\n\n        const typedOnboarding = user.onboarding as OnboardingState | null\n        const onboardingState = typedOnboarding\n            ? typedOnboarding.main\n            : { markedComplete: false, steps: [] }\n\n        const onboarding = new Onboarding<MainOnboardingUser>(\n            {\n                ...user,\n                onboarding: onboardingState,\n                emailVerified: email_verified,\n                isAppleIdentity: false,\n            },\n            onboardingState.markedComplete\n        )\n\n        onboarding\n            .addStep('intro')\n            .setTitle((user) => `Hey ${user.firstName ?? 'there'}, meet Maybe`)\n            .addToGroup('account')\n            .completeIf(markedComplete)\n\n        onboarding\n            .addStep('profile')\n            .setTitle((_) => \"Let's complete your profile\")\n            .addToGroup('profile')\n            .completeIf((user) => {\n                return user.dob != null && user.household != null\n            })\n\n        onboarding\n            .addStep('verifyEmail')\n            .setTitle((_) => \"Before we start, let's verify your email\")\n            .addToGroup('setup')\n            .completeIf((user) => user.emailVerified)\n            .excludeIf((user) => user.isAppleIdentity || true) // TODO: Needs email service to send, skip for now\n\n        onboarding\n            .addStep('firstAccount')\n            .setTitle((_) => \"Let's add your first account\")\n            .addToGroup('setup')\n            .markedCompleteIf((user, step) => markedComplete(user, step))\n            .completeIf((user, step) => {\n                return (\n                    user.accountConnections.length > 0 ||\n                    user.accounts.length > 0 ||\n                    markedComplete(user, step)\n                )\n            })\n\n        onboarding\n            .addStep('accountSelection')\n            .setTitle((_) => 'What other accounts do you have?')\n            .addToGroup('setup')\n            .completeIf(markedComplete)\n\n        onboarding\n            .addStep('maybe')\n            .setTitle((_) => \"One more thing, what's your maybe?\")\n            .completeIf(markedComplete)\n\n        onboarding\n            .addStep('welcome')\n            .setTitle((user) => {\n                return `Welcome to Maybe${user.name ? `, ${user.name}` : '!'}`\n            })\n            .completeIf(markedComplete)\n\n        return onboarding\n    }\n\n    async buildSidebarOnboarding(userId: User['id']): Promise<SharedType.OnboardingResponse> {\n        function markedComplete(user: SidebarOnboardingUser, step: Step<SidebarOnboardingUser>) {\n            return (\n                user.onboarding.steps.find((dbStep) => dbStep.key === step.key)?.markedComplete ??\n                false\n            )\n        }\n\n        function stepUnregistered(user: SidebarOnboardingUser, step: Step<SidebarOnboardingUser>) {\n            return user.onboarding.steps.findIndex((dbStep) => dbStep.key === step.key) < 0\n        }\n\n        function hasAccountType(\n            user: SidebarOnboardingUser,\n            types: AccountType | AccountType[],\n            category?: {\n                match?: AccountCategory\n                exclude?: AccountCategory\n            }\n        ) {\n            const accounts = [\n                ...user.accounts,\n                ...flatten(user.accountConnections.map((ac) => ac.accounts)),\n            ]\n\n            return (\n                accounts.filter((a) => {\n                    let match = Array.isArray(types) ? types.includes(a.type) : types === a.type\n\n                    if (!match) return false\n\n                    if (category) {\n                        if (category.match) {\n                            match = a.category === category.match\n                        }\n\n                        if (category.exclude) {\n                            match = a.category !== category.exclude\n                        }\n                    }\n\n                    return match\n                }).length > 0\n            )\n        }\n\n        const user = await this.prisma.user.findUniqueOrThrow({\n            where: { id: userId },\n            select: {\n                onboarding: true,\n                accounts: { select: { type: true, category: true } },\n                accountConnections: {\n                    select: { accounts: { select: { type: true, category: true } } },\n                },\n                _count: { select: { plans: true } },\n            },\n        })\n\n        const typedOnboarding = user.onboarding as OnboardingState | null\n        const onboardingState = typedOnboarding\n            ? typedOnboarding.sidebar\n            : { markedComplete: false, steps: [] }\n\n        const onboarding = new Onboarding<SidebarOnboardingUser>(\n            {\n                ...user,\n                onboarding: onboardingState,\n            },\n            onboardingState.markedComplete\n        )\n\n        onboarding\n            .addStep('connect-depository')\n            .setTitle((_) => 'Connect bank accounts')\n            .addToGroup('accounts')\n            .markedCompleteIf(markedComplete)\n            .completeIf((user) => hasAccountType(user, 'DEPOSITORY'))\n            .excludeIf(stepUnregistered)\n\n        onboarding\n            .addStep('connect-investment')\n            .setTitle((_) => 'Connect investment accounts')\n            .addToGroup('accounts')\n            .markedCompleteIf(markedComplete)\n            .completeIf((user) => hasAccountType(user, 'INVESTMENT'))\n            .excludeIf(stepUnregistered)\n\n        onboarding\n            .addStep('connect-liability')\n            .setTitle((_) => 'Connect credit card and loan accounts')\n            .addToGroup('accounts')\n            .markedCompleteIf(markedComplete)\n            .completeIf((user) => hasAccountType(user, ['CREDIT', 'LOAN']))\n            .excludeIf(stepUnregistered)\n\n        onboarding\n            .addStep('add-crypto')\n            .setTitle((_) => 'Manually add crypto accounts')\n            .addToGroup('accounts')\n            .markedCompleteIf(markedComplete)\n            .completeIf((user) => hasAccountType(user, 'OTHER_ASSET', { match: 'crypto' }))\n            .excludeIf(stepUnregistered)\n\n        onboarding\n            .addStep('add-property')\n            .setTitle((_) => 'Manually add real estate')\n            .addToGroup('accounts')\n            .markedCompleteIf(markedComplete)\n            .completeIf((user) => hasAccountType(user, 'PROPERTY'))\n            .excludeIf(stepUnregistered)\n\n        onboarding\n            .addStep('add-vehicle')\n            .setTitle((_) => 'Manually add vehicle')\n            .addToGroup('accounts')\n            .markedCompleteIf(markedComplete)\n            .completeIf((user) => hasAccountType(user, 'VEHICLE'))\n            .excludeIf(stepUnregistered)\n\n        onboarding\n            .addStep('add-other')\n            .addToGroup('accounts')\n            .setTitle((_) => 'Manually add other assets and debts')\n            .markedCompleteIf(markedComplete)\n            .completeIf((user) =>\n                hasAccountType(user, ['OTHER_ASSET', 'OTHER_LIABILITY'], {\n                    exclude: 'crypto',\n                })\n            )\n            .excludeIf(stepUnregistered)\n\n        onboarding\n            .addStep('create-plan')\n            .addToGroup('bonus')\n            .setTitle((_) => 'Setup a financial plan')\n            .completeIf((user) => user._count.plans > 0)\n            .setCTAPath('/plans')\n\n        return onboarding\n    }\n}\n"
  },
  {
    "path": "libs/server/features/src/valuation/index.ts",
    "content": "export * from './valuation.service'\n"
  },
  {
    "path": "libs/server/features/src/valuation/valuation.service.ts",
    "content": "import type { Logger } from 'winston'\nimport type { Account, PrismaClient, Valuation } from '@prisma/client'\nimport type { Prisma } from '@prisma/client'\nimport type { DateTime } from 'luxon'\nimport type { SharedType } from '@maybe-finance/shared'\nimport type { IAccountQueryService } from '../account'\nimport { SharedUtil } from '@maybe-finance/shared'\nimport { DbUtil } from '@maybe-finance/server/shared'\n\nexport class ValuationService {\n    constructor(\n        private readonly logger: Logger,\n        private readonly prisma: PrismaClient,\n        private readonly queryService: IAccountQueryService\n    ) {}\n\n    async getValuations(\n        accountId: Account['id'],\n        start?: DateTime,\n        end?: DateTime\n    ): Promise<SharedType.AccountValuationsResponse> {\n        const [valuations, trends] = await Promise.all([\n            this.prisma.valuation.findMany({\n                where: {\n                    accountId,\n                    date: {\n                        gte: start?.toJSDate(),\n                        lte: end?.toJSDate(),\n                    },\n                },\n                orderBy: { date: 'asc' },\n            }),\n            this.queryService.getValuationTrends(accountId, start, end),\n        ])\n\n        return {\n            valuations: valuations.map((valuation) => {\n                const trend = trends.find((t) => t.valuation_id === valuation.id)\n                return {\n                    ...valuation,\n                    trend: trend\n                        ? {\n                              period: DbUtil.toTrend(trend.period_change, trend.period_change_pct),\n                              total: DbUtil.toTrend(trend.total_change, trend.total_change_pct),\n                          }\n                        : null,\n                }\n            }),\n            trends: trends\n                .filter((trend) => !SharedUtil.nonNull(trend.valuation_id))\n                .map((trend) => ({\n                    date: trend.date,\n                    amount: trend.amount,\n                    period: DbUtil.toTrend(trend.period_change, trend.period_change_pct),\n                    total: DbUtil.toTrend(trend.total_change, trend.total_change_pct),\n                })),\n        }\n    }\n\n    async getValuation(id: Valuation['id']) {\n        return await this.prisma.valuation.findUniqueOrThrow({\n            where: { id },\n            include: { account: true },\n        })\n    }\n\n    async createValuation(data: Prisma.ValuationUncheckedCreateInput) {\n        return await this.prisma.valuation.create({ data })\n    }\n\n    async updateValuation(\n        id: Valuation['id'],\n        data: { date?: Date; amount?: Prisma.Decimal | number }\n    ) {\n        return await this.prisma.valuation.update({\n            where: { id },\n            data,\n        })\n    }\n\n    async deleteValuation(id: Valuation['id']) {\n        return await this.prisma.valuation.delete({ where: { id } })\n    }\n}\n"
  },
  {
    "path": "libs/server/features/tsconfig.json",
    "content": "{\n    \"extends\": \"../../../tsconfig.base.json\",\n    \"compilerOptions\": {\n        \"esModuleInterop\": true,\n        \"noImplicitAny\": false,\n        \"strict\": true,\n        \"strictNullChecks\": true\n    },\n    \"files\": [],\n    \"include\": [],\n    \"references\": [\n        {\n            \"path\": \"./tsconfig.lib.json\"\n        },\n        {\n            \"path\": \"./tsconfig.spec.json\"\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/server/features/tsconfig.lib.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"module\": \"commonjs\",\n        \"outDir\": \"../../../dist/out-tsc\",\n        \"declaration\": true,\n        \"types\": [\"node\"]\n    },\n    \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"jest.config.ts\"],\n    \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "libs/server/features/tsconfig.spec.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../../dist/out-tsc\",\n        \"module\": \"commonjs\",\n        \"types\": [\"jest\", \"node\"]\n    },\n    \"include\": [\n        \"**/*.spec.ts\",\n        \"**/*.test.ts\",\n        \"**/*.spec.tsx\",\n        \"**/*.test.tsx\",\n        \"**/*.spec.js\",\n        \"**/*.test.js\",\n        \"**/*.spec.jsx\",\n        \"**/*.test.jsx\",\n        \"**/*.d.ts\",\n        \"jest.config.ts\"\n    ]\n}\n"
  },
  {
    "path": "libs/server/shared/.babelrc",
    "content": "{\n    \"presets\": [[\"@nrwl/web/babel\", { \"useBuiltIns\": \"usage\" }]]\n}\n"
  },
  {
    "path": "libs/server/shared/.eslintrc.json",
    "content": "{\n    \"extends\": [\"../../../.eslintrc.json\"],\n    \"ignorePatterns\": [\"!**/*\"],\n    \"overrides\": [\n        {\n            \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.ts\", \"*.tsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/server/shared/README.md",
    "content": "# server-shared\n\nThis library was generated with [Nx](https://nx.dev).\n\n## Running unit tests\n\nRun `nx test server-shared` to execute the unit tests via [Jest](https://jestjs.io).\n"
  },
  {
    "path": "libs/server/shared/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n    displayName: 'server-shared',\n    preset: '../../../jest.preset.js',\n    globals: {\n        'ts-jest': {\n            tsconfig: '<rootDir>/tsconfig.spec.json',\n        },\n    },\n    testEnvironment: 'node',\n    transform: {\n        '^.+\\\\.[tj]sx?$': 'ts-jest',\n    },\n    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n    coverageDirectory: '../../../coverage/libs/server/shared',\n}\n"
  },
  {
    "path": "libs/server/shared/src/endpoint.ts",
    "content": "import type { RequestHandler, Request, Response } from 'express'\nimport { z } from 'zod'\n\ntype EndpointSchema<TInput = unknown> = {\n    parse: (input: any) => TInput\n}\n\ntype EndpointResolverArgs<TContext, TInput> = {\n    input: TInput\n    ctx: TContext\n    req: Request\n}\n\ntype EndpointOnSuccess = <TOutput>(req: Request, res: Response, output: TOutput) => any\n\ntype EndpointWithInput<TContext, TInput, TOutput> = {\n    input: EndpointSchema<TInput>\n    authorize?(args: EndpointResolverArgs<TContext, TInput>): boolean | Promise<boolean>\n    resolve(args: EndpointResolverArgs<TContext, TInput>): Promise<TOutput>\n    onSuccess?: EndpointOnSuccess\n}\n\ntype EndpointWithoutInput<TContext, TOutput> = Omit<\n    EndpointWithInput<TContext, undefined, TOutput>,\n    'input'\n>\n\nexport class EndpointFactory<TContext> {\n    constructor(\n        private readonly options: {\n            createContext: (req: Request, res: Response) => TContext | Promise<TContext>\n            onSuccess?: EndpointOnSuccess\n        }\n    ) {}\n\n    create<TInput, TOutput>(opts: EndpointWithInput<TContext, TInput, TOutput>): RequestHandler\n    create<TOutput>(opts: EndpointWithoutInput<TContext, TOutput>): RequestHandler\n    create<TInput, TOutput>(\n        opts: EndpointWithInput<TContext, TInput, TOutput> | EndpointWithoutInput<TContext, TOutput>\n    ): RequestHandler {\n        const { authorize, resolve } = opts\n        const input = 'input' in opts ? opts.input : undefined\n\n        return async (req, res, next) => {\n            let inputData: TInput | undefined\n            try {\n                inputData = input?.parse({ ...req.query, ...req.body })\n            } catch (err) {\n                console.error('input parse error', err)\n                if (err instanceof z.ZodError) {\n                    return res.status(400).json({ errors: err.format() })\n                }\n            }\n\n            const ctx = await this.options.createContext(req, res)\n\n            if (authorize && !(await authorize({ input: inputData as any, ctx, req }))) {\n                return res.status(401).send('Unauthorized')\n            }\n\n            resolve({ input: inputData as any, ctx, req })\n                .then((output) =>\n                    opts.onSuccess\n                        ? opts.onSuccess(req, res, output)\n                        : this.options.onSuccess\n                        ? this.options.onSuccess(req, res, output)\n                        : res.status(200).json(output)\n                )\n                .catch(next)\n        }\n    }\n}\n"
  },
  {
    "path": "libs/server/shared/src/etl.ts",
    "content": "export interface IETL<TInput, TExtracted = any, TTransformed = TExtracted> {\n    extract(input: TInput): Promise<TExtracted>\n    transform(input: TInput, extracted: TExtracted): Promise<TTransformed>\n    load(input: TInput, transformed: TTransformed): Promise<void>\n}\n\nexport async function etl<TInput, TExtracted, TTransformed>(\n    service: IETL<TInput, TExtracted, TTransformed>,\n    input: TInput\n): Promise<void> {\n    const extracted = await service.extract(input)\n    const transformed = await service.transform(input, extracted)\n    await service.load(input, transformed)\n}\n"
  },
  {
    "path": "libs/server/shared/src/index.ts",
    "content": "export * from './services'\nexport * from './endpoint'\nexport * from './etl'\nexport * from './logger'\nexport * from './utils'\nexport { default as sql, raw, join } from './sql-template-tag'\n"
  },
  {
    "path": "libs/server/shared/src/logger.ts",
    "content": "import type { LoggerOptions, Logger } from 'winston'\nimport type Transport from 'winston-transport'\n\nimport { createLogger as _createLogger, transports as _transports, format } from 'winston'\n\nexport const createLogger = (opts?: LoggerOptions): Logger => {\n    const defaultTransport: Transport = new _transports.Console({\n        format:\n            process.env.NODE_ENV === 'production'\n                ? format.json()\n                : format.combine(format.colorize(), format.simple()),\n        handleExceptions: true,\n    })\n\n    const transports = !opts?.transports\n        ? defaultTransport\n        : Array.isArray(opts.transports)\n        ? [defaultTransport, ...opts.transports]\n        : [defaultTransport, opts.transports]\n\n    return _createLogger({\n        ...opts,\n        transports,\n    })\n}\n"
  },
  {
    "path": "libs/server/shared/src/services/cache.service.ts",
    "content": "import type { Logger } from 'winston'\nimport { DateTime, Duration } from 'luxon'\nimport type Redis from 'ioredis'\nimport { superjson } from '@maybe-finance/shared'\n\ninterface ICacheBackend {\n    getItem<TValue>(key: string): Promise<TValue | null>\n    setItem<TValue>(key: string, value: TValue, exp: Duration): Promise<void>\n}\n\nexport class MemoryCacheBackend implements ICacheBackend {\n    constructor(private readonly cache: Record<string, { value: any; exp: DateTime }> = {}) {}\n\n    async getItem<TValue>(key: string): Promise<TValue | null> {\n        const item = this.cache[key]\n        if (item == null) return null\n        return item.exp.diffNow() >= Duration.fromMillis(0) ? item.value : null\n    }\n\n    async setItem<TValue>(key: string, value: TValue, exp: Duration): Promise<void> {\n        this.cache[key] = { value, exp: DateTime.now().plus(exp) }\n    }\n}\n\nexport class RedisCacheBackend implements ICacheBackend {\n    constructor(private readonly redis: Redis) {}\n\n    async getItem<TValue>(key: string): Promise<TValue | null> {\n        const rawValue = await this.redis.get(this.key(key))\n        return rawValue == null ? null : superjson.parse<TValue>(rawValue)\n    }\n\n    async setItem<TValue>(key: string, value: TValue, exp: Duration): Promise<void> {\n        await this.redis.setex(this.key(key), exp.as('seconds'), superjson.stringify(value))\n    }\n\n    private key(key: string) {\n        return `cache:${key}`\n    }\n}\n\nexport class CacheService {\n    constructor(\n        private readonly logger: Logger,\n        private readonly cache: ICacheBackend,\n        private readonly defaultExpiration = Duration.fromObject({ minutes: 15 })\n    ) {}\n\n    async getOrAdd<K extends string, V>(\n        key: K,\n        valueFn: V | ((_key: K) => Promise<V>),\n        exp?: Duration\n    ): Promise<V> {\n        // first check for non-expired cached value\n        const existingValue = await this.cache.getItem<V>(key)\n        if (existingValue) {\n            this.logger.debug(`HIT k=\"${key}\"`)\n            return existingValue\n        }\n\n        this.logger.debug(`MISS k=\"${key}\"`)\n\n        // compute value to cache\n        const newValue = typeof valueFn === 'function' ? await (valueFn as any)(key) : valueFn\n        await this.cache.setItem(key, newValue, exp || this.defaultExpiration)\n        this.logger.debug(`SET k=\"${key}\"`)\n\n        return newValue\n    }\n}\n"
  },
  {
    "path": "libs/server/shared/src/services/crypto.service.ts",
    "content": "import CryptoJS from 'crypto-js'\n\nexport interface ICryptoService {\n    encrypt(plainText: string): string\n    decrypt(encrypted: string): string\n}\n\nexport class CryptoService implements ICryptoService {\n    constructor(private readonly secret: string) {}\n\n    encrypt(plainText: string) {\n        return CryptoJS.AES.encrypt(plainText, this.secret).toString()\n    }\n\n    decrypt(encrypted: string) {\n        return CryptoJS.AES.decrypt(encrypted, this.secret).toString(CryptoJS.enc.Utf8)\n    }\n}\n"
  },
  {
    "path": "libs/server/shared/src/services/index.ts",
    "content": "export * from './crypto.service'\nexport * from './queue.service'\nexport * from './queue'\nexport * from './cache.service'\nexport * from './market-data.service'\nexport * from './pg.service'\n"
  },
  {
    "path": "libs/server/shared/src/services/market-data.service.spec.ts",
    "content": "import { getPolygonTicker } from './market-data.service'\nimport { AssetClass } from '@prisma/client'\n\ndescribe('PolygonMarketDataService', () => {\n    it.each`\n        assetClass            | symbol                   | ticker\n        ${AssetClass.stocks}  | ${'AAPL'}                | ${{ market: 'stocks', ticker: 'AAPL' }}\n        ${AssetClass.other}   | ${'AAPL'}                | ${{ market: 'stocks', ticker: 'AAPL' }}\n        ${AssetClass.options} | ${'AAPL220909C00070000'} | ${{ market: 'options', ticker: 'O:AAPL220909C00070000' }}\n        ${AssetClass.other}   | ${'AAPL220909C00070000'} | ${{ market: 'options', ticker: 'O:AAPL220909C00070000' }}\n        ${AssetClass.crypto}  | ${'BTC'}                 | ${{ market: 'crypto', ticker: 'X:BTCUSD' }}\n        ${AssetClass.cash}    | ${'USD'}                 | ${null}\n        ${AssetClass.cash}    | ${'EUR'}                 | ${{ market: 'fx', ticker: 'C:EURUSD' }}\n    `(\n        'properly parses security symbol: $symbol assetClass: $assetClass',\n        ({ assetClass, symbol, ticker }) => {\n            expect(getPolygonTicker({ assetClass, currencyCode: 'USD', symbol })).toEqual(ticker)\n        }\n    )\n})\n"
  },
  {
    "path": "libs/server/shared/src/services/market-data.service.ts",
    "content": "import { AssetClass, Prisma } from '@prisma/client'\nimport type { Security } from '@prisma/client'\nimport _ from 'lodash'\nimport { DateTime, Duration } from 'luxon'\nimport type { Logger } from 'winston'\nimport type { IRestClient } from '@polygon.io/client-js'\nimport { restClient } from '@polygon.io/client-js'\nimport type { SharedType } from '@maybe-finance/shared'\nimport { MarketUtil, SharedUtil } from '@maybe-finance/shared'\nimport type { CacheService } from '.'\nimport { toDecimal } from '../utils/db-utils'\nimport type { ITickersResults } from '@polygon.io/client-js/lib/rest/reference/tickers'\nimport type { IAggs } from '@polygon.io/client-js/lib/rest/stocks/aggregates'\n\ntype DailyPricing = {\n    date: DateTime\n    priceClose: Prisma.Decimal\n}\n\nexport type TSecurity = Pick<Security, 'assetClass' | 'currencyCode' | 'id' | 'symbol'>\n\nexport type LivePricing<TSecurity> = {\n    security: TSecurity\n    pricing: {\n        ticker: string\n        price: Prisma.Decimal\n        change: Prisma.Decimal\n        changePct: Prisma.Decimal\n        updatedAt: DateTime\n    } | null\n}\n\nexport type EndOfDayPricing<TSecurity> = {\n    security: TSecurity\n    pricing: {\n        ticker: string\n        price: Prisma.Decimal\n        change: Prisma.Decimal\n        changePct: Prisma.Decimal\n        updatedAt: DateTime\n    }\n}\n\ntype OptionDetails = {\n    sharesPerContract: number | undefined\n}\n\nexport interface IMarketDataService {\n    /**\n     * internal identifier for this market data source\n     */\n    get source(): string\n\n    /**\n     * fetches pricing info for inclusive date range\n     */\n    getDailyPricing<TSecurity extends Pick<Security, 'assetClass' | 'currencyCode' | 'symbol'>>(\n        security: TSecurity,\n        start: DateTime,\n        end: DateTime\n    ): Promise<DailyPricing[]>\n\n    /**\n     * fetches end of day pricing info for a batch of securities\n     */\n    getEndOfDayPricing<\n        TSecurity extends Pick<Security, 'assetClass' | 'currencyCode' | 'id' | 'symbol'>\n    >(\n        securities: TSecurity[],\n        allPricing: IAggs\n    ): Promise<EndOfDayPricing<TSecurity>[]>\n\n    /**\n     * fetches all end of day pricing\n     */\n\n    getAllDailyPricing(): Promise<IAggs>\n\n    /**\n     * fetches up-to-date pricing info for a batch of securities\n     */\n    getLivePricing<\n        TSecurity extends Pick<Security, 'assetClass' | 'currencyCode' | 'id' | 'symbol'>\n    >(\n        securities: TSecurity[]\n    ): Promise<LivePricing<TSecurity>[]>\n\n    /**\n     * fetches options contract details\n     */\n    getOptionDetails(symbol: Security['symbol']): Promise<OptionDetails>\n\n    getSecurityDetails(\n        security: Pick<Security, 'assetClass' | 'currencyCode' | 'symbol'>\n    ): Promise<SharedType.SecurityDetails>\n\n    /**\n     * fetches all US stock tickers\n     */\n    getUSStockTickers(): Promise<\n        (ITickersResults & {\n            exchangeAcronym: string\n            exchangeMic: string\n            exchangeName: string\n        })[]\n    >\n}\n\nexport class PolygonMarketDataService implements IMarketDataService {\n    private readonly api: IRestClient\n    private shouldRateLimit = process.env.NX_POLYGON_TIER === 'basic' && !process.env.CI\n\n    readonly source = 'polygon'\n\n    constructor(\n        private readonly logger: Logger,\n        apiKey: string,\n        private readonly cache: CacheService\n    ) {\n        this.api = restClient(apiKey)\n    }\n\n    async getDailyPricing<\n        TSecurity extends Pick<Security, 'assetClass' | 'currencyCode' | 'symbol'>\n    >(security: TSecurity, start: DateTime, end: DateTime): Promise<DailyPricing[]> {\n        const ticker = getPolygonTicker(security)\n        if (!ticker) return []\n\n        /**\n         * https://polygon.io/docs/stocks/get_v2_aggs_ticker__stocksticker__range__multiplier___timespan___from___to\n         */\n        const res = await this.api.stocks.aggregates(\n            ticker.ticker,\n            1,\n            'day',\n            start.toISODate(),\n            end.toISODate()\n        )\n\n        return (\n            res.results\n                ?.filter(({ t, c }) => t != null && c != null)\n                .map(({ t, c }) => ({\n                    date: DateTime.fromMillis(t!, { zone: 'America/New_York' }),\n                    priceClose: new Prisma.Decimal(c!),\n                })) ?? []\n        )\n    }\n\n    async getEndOfDayPricing<\n        TSecurity extends Pick<Security, 'id' | 'symbol' | 'assetClass' | 'currencyCode'>\n    >(securities: TSecurity[], allPricing: IAggs): Promise<EndOfDayPricing<TSecurity>[]> {\n        const securitiesWithTicker = securities.map((security) => ({\n            security,\n            ticker: getPolygonTicker(security),\n        }))\n        const tickers = _(securitiesWithTicker)\n            .map((s) => s.ticker)\n            .filter(SharedUtil.nonNull)\n            .uniqBy((t) => t.ticker)\n            .sortBy((t) => [t.market, t.ticker])\n            .value()\n\n        const stockTickers = tickers.filter((t) => t.market === 'stocks').map((s) => s.ticker)\n\n        if (stockTickers.length > 0) {\n            return allPricing\n                .results!.filter(({ t, c }) => t != null && c != null)\n                .map((pricing) => {\n                    const foundSecurity = securitiesWithTicker.find(\n                        ({ ticker }) => ticker?.ticker === pricing.T\n                    )\n                    if (!foundSecurity) return null\n                    return {\n                        security: foundSecurity.security,\n                        pricing: {\n                            ticker: pricing.T!,\n                            price: new Prisma.Decimal(pricing.c!),\n                            change: new Prisma.Decimal(pricing.c! - pricing.o!),\n                            changePct: new Prisma.Decimal(\n                                ((pricing.c! - pricing.o!) / pricing.o!) * 100\n                            ),\n                            updatedAt: DateTime.fromMillis(pricing.t!, {\n                                zone: 'America/New_York',\n                            }),\n                        },\n                    }\n                })\n                .filter(SharedUtil.nonNull)\n        } else {\n            return []\n        }\n    }\n\n    async getAllDailyPricing(): Promise<IAggs> {\n        return await this.api.stocks.aggregatesGroupedDaily(\n            DateTime.now().minus({ days: 1 }).toISODate(),\n            {\n                adjusted: 'true',\n            }\n        )\n    }\n\n    async getLivePricing<\n        TSecurity extends Pick<Security, 'assetClass' | 'currencyCode' | 'id' | 'symbol'>\n    >(securities: TSecurity[]): Promise<LivePricing<TSecurity>[]> {\n        const securitiesWithTicker = securities.map((security) => ({\n            security,\n            ticker: getPolygonTicker(security),\n        }))\n\n        const tickers = _(securitiesWithTicker)\n            .map((s) => s.ticker)\n            .filter(SharedUtil.nonNull)\n            .uniqBy((t) => t.ticker)\n            .sortBy((t) => [t.market, t.ticker])\n            .value()\n\n        const stockTickers = tickers.filter((t) => t.market === 'stocks').map((s) => s.ticker)\n        const optionTickers = tickers.filter((t) => t.market === 'options').map((o) => o.ticker)\n        const cryptoTickers = tickers.filter((t) => t.market === 'crypto').map((c) => c.ticker)\n\n        const [stocksSnapshot, optionsSnapshot, cryptoSnapshot] = await Promise.all([\n            stockTickers.length > 0\n                ? this.cache.getOrAdd(\n                      `live-pricing[${stockTickers.join(',')}]`,\n                      () => this._snapshotStocks(stockTickers),\n                      Duration.fromObject({ minutes: 2 })\n                  )\n                : null,\n            optionTickers.length > 0\n                ? Promise.allSettled(\n                      optionTickers.map((optionTicker) =>\n                          this.cache\n                              .getOrAdd(\n                                  `live-pricing[${optionTicker}]`,\n                                  () => this._snapshotOption(optionTicker),\n                                  Duration.fromObject({ minutes: 2 })\n                              )\n                              .catch((err) => {\n                                  this.logger.warn(\n                                      `failed to get option snapshot for ${optionTicker}: ${err}`\n                                  )\n                                  return null\n                              })\n                      )\n                  ).then((results) =>\n                      results\n                          .filter(SharedUtil.isFullfilled)\n                          .map((r) => r.value)\n                          .filter(SharedUtil.nonNull)\n                  )\n                : null,\n            cryptoTickers.length > 0\n                ? this.cache.getOrAdd(\n                      `live-pricing[${cryptoTickers.join(',')}]`,\n                      () => this._snapshotCrypto(cryptoTickers),\n                      Duration.fromObject({ minutes: 2 })\n                  )\n                : null,\n        ])\n\n        return securitiesWithTicker.map(({ security, ticker }) => {\n            if (!ticker) {\n                return { security, pricing: null }\n            }\n\n            const snapshot =\n                ticker.market === 'stocks'\n                    ? stocksSnapshot?.find((s) => s.ticker === ticker.ticker)\n                    : ticker.market === 'options'\n                    ? optionsSnapshot?.find((o) => o.ticker === ticker.ticker)\n                    : ticker.market === 'crypto'\n                    ? cryptoSnapshot?.find((c) => c.ticker === ticker.ticker)\n                    : null\n\n            return { security, pricing: snapshot?.pricing ?? null }\n        })\n    }\n\n    async getOptionDetails(symbol: Security['symbol']): Promise<OptionDetails> {\n        const ticker = getPolygonTicker({\n            assetClass: AssetClass.options,\n            currencyCode: 'USD',\n            symbol,\n        })\n\n        if (!ticker) {\n            return { sharesPerContract: 100 }\n        }\n\n        const contractResponse = await this.api.reference.optionsContract(ticker.ticker)\n\n        return {\n            // https://github.com/polygon-io/client-js/issues/95\n            sharesPerContract: (contractResponse.results as any)?.shares_per_contract,\n        }\n    }\n\n    async getSecurityDetails(security: Pick<Security, 'assetClass' | 'currencyCode' | 'symbol'>) {\n        const ticker = getPolygonTicker(security)\n        if (!ticker || ticker.market === 'options') {\n            return {}\n        }\n\n        const now = DateTime.now()\n        const oneYearAgo = DateTime.now().minus({ weeks: 52 })\n\n        try {\n            const [snapshot, yearAggregate, details, financials, dividends] = await Promise.all([\n                this.cache.getOrAdd(\n                    `ticker-snapshot[${ticker}]`,\n                    () => this.api.stocks.snapshotTicker(ticker.ticker),\n                    Duration.fromObject({ minutes: 2 })\n                ),\n\n                this.cache.getOrAdd(\n                    `ticker-year-aggregate[${ticker}]`,\n                    () =>\n                        this.api.stocks.aggregates(\n                            ticker.ticker,\n                            1,\n                            'year',\n                            oneYearAgo.toFormat('yyyy-MM-dd'),\n                            now.toFormat('yyyy-MM-dd')\n                        ),\n                    Duration.fromObject({ minutes: 2 })\n                ),\n\n                this.cache.getOrAdd(\n                    `ticker-details[${ticker}]`,\n                    () => this.api.reference.tickerDetails(ticker.ticker),\n                    Duration.fromObject({ hours: 12 })\n                ),\n\n                this.cache.getOrAdd(\n                    `ticker-financials[${ticker}]`,\n                    () => this.api.reference.stockFinancials({ ticker: ticker.ticker }),\n                    Duration.fromObject({ minutes: 1 })\n                ),\n\n                this.cache.getOrAdd(\n                    `ticker-dividends[${ticker}]`,\n                    () =>\n                        this.api.reference.dividends({\n                            ticker: ticker.ticker,\n                            'pay_date.gt': oneYearAgo.toFormat('yyyy-MM-dd'),\n                            'pay_date.lte': now.toFormat('yyyy-MM-dd'),\n                        }),\n                    Duration.fromObject({ minutes: 1 })\n                ),\n            ])\n\n            return {\n                day: {\n                    open: toDecimal(snapshot.ticker?.day?.o) ?? undefined,\n                    prevClose: toDecimal(snapshot.ticker?.prevDay?.c) ?? undefined,\n                    high: toDecimal(snapshot.ticker?.day?.h) ?? undefined,\n                    low: toDecimal(snapshot.ticker?.day?.l) ?? undefined,\n                },\n                year: {\n                    high: toDecimal(yearAggregate.results?.[0].h) ?? undefined,\n                    low: toDecimal(yearAggregate.results?.[0].l) ?? undefined,\n                    volume: toDecimal(yearAggregate.results?.[0].v) ?? undefined,\n                    dividends:\n                        toDecimal(\n                            dividends.results?.reduce((sum, d) => sum + (d.cash_amount ?? 0), 0)\n                        ) ?? undefined,\n                },\n                marketCap: toDecimal(details.results?.market_cap) ?? undefined,\n                eps:\n                    toDecimal(\n                        financials.results?.[0]?.financials?.income_statement\n                            ?.basic_earnings_per_share.value\n                    ) ?? undefined,\n            }\n        } catch (e) {\n            this.logger.warn(`Failed to get security details for ${ticker}: ${e}`)\n        }\n\n        return {}\n    }\n\n    async getUSStockTickers(): Promise<\n        (ITickersResults & {\n            exchangeAcronym: string\n            exchangeMic: string\n            exchangeName: string\n        })[]\n    > {\n        const allExchanges = await this.api.reference.exchanges({\n            locale: 'us',\n            asset_class: 'stocks',\n        })\n\n        // Only get tickers for exchanges, not TRF or SIP\n        const exchanges = allExchanges.results.filter((exchange) => exchange.type === 'exchange')\n\n        const tickers: (ITickersResults & {\n            exchangeAcronym: string\n            exchangeMic: string\n            exchangeName: string\n        })[] = []\n        for (const exchange of exchanges) {\n            const exchangeTickers: (ITickersResults & {\n                exchangeAcronym: string\n                exchangeMic: string\n                exchangeName: string\n            })[] = await SharedUtil.paginateWithNextUrl({\n                pageSize: 1000,\n                delay: this.shouldRateLimit\n                    ? {\n                          onDelay: (message: string) => this.logger.debug(message),\n                          milliseconds: 15_000, // Basic accounts rate limited at 5 calls / minute\n                      }\n                    : undefined,\n                fetchData: async (limit, nextCursor) => {\n                    try {\n                        const { results, next_url } = await SharedUtil.withRetry(\n                            () =>\n                                this.api.reference.tickers({\n                                    market: 'stocks',\n                                    exchange: exchange.mic,\n                                    cursor: nextCursor,\n                                    limit: limit,\n                                }),\n                            { maxRetries: 1, delay: this.shouldRateLimit ? 15_000 : 0 }\n                        )\n                        const tickersWithExchange = results.map((ticker) => {\n                            return {\n                                ...ticker,\n                                exchangeAcronym: exchange.acronymstring ?? '',\n                                exchangeMic: exchange.mic ?? '',\n                                exchangeName: exchange.name,\n                            }\n                        })\n                        return { data: tickersWithExchange, nextUrl: next_url }\n                    } catch (err) {\n                        this.logger.error('Error while fetching tickers', err)\n                        return { data: [], nextUrl: undefined }\n                    }\n                },\n            })\n            tickers.push(...exchangeTickers)\n        }\n        return tickers\n    }\n\n    private async _snapshotStocks(tickers: string[]) {\n        /**\n         * https://polygon.io/docs/stocks/get_v2_snapshot_locale_us_markets_stocks_tickers\n         */\n        const res = await this.api.stocks.snapshotAllTickers({\n            tickers: tickers.join(','),\n        })\n\n        const snapshots = res.tickers ?? []\n\n        // ENG-601: alert us if polygon returns any prices as 0\n        const emptySnapshots =\n            snapshots.filter((t) => !t.updated && !t.lastTrade?.t && !t.lastQuote?.t) ?? []\n\n        if (emptySnapshots.length > 0) {\n            this.logger.error(\n                `polygon snapshot empty tickers: ${emptySnapshots.map((t) => t.ticker).join(',')}`\n            )\n        }\n\n        return snapshots.map((snapshot) => {\n            // extract the (p)rice + (t)ime to use for the live price\n            // if the (t)ime is 0, we're dealing with an empty/zero snapshot from Polygon\n            //\n            // the order of priority for pricing that we use is:\n            //\n            // 1. The `day` snapshot object from Polygon (OHLCV)\n            // 2. The `lastTrade`\n            // 3. The `lastQuote` - we calculate the midpoint\n            const [p, t] =\n                // it's possible for a snapshot to have a valid `updated` time but\n                // a zeroed out `snapshot.day` object which is why we check for both here\n                snapshot.updated && snapshot.day && Object.values(snapshot.day).some((v) => v > 0)\n                    ? [snapshot.day.c, snapshot.updated]\n                    : snapshot.lastTrade && snapshot.lastTrade.t\n                    ? [snapshot.lastTrade.p, snapshot.lastTrade.t]\n                    : snapshot.lastQuote && snapshot.lastQuote.t\n                    ? [_.mean([snapshot.lastQuote.P, snapshot.lastQuote.p]), snapshot.lastQuote.t]\n                    : [null, null]\n\n            return {\n                ticker: snapshot.ticker,\n                pricing:\n                    t &&\n                    snapshot.ticker &&\n                    snapshot.todaysChange != null &&\n                    snapshot.todaysChangePerc != null\n                        ? {\n                              ticker: snapshot.ticker,\n                              price: new Prisma.Decimal(p!),\n                              change: new Prisma.Decimal(snapshot.todaysChange),\n                              changePct: new Prisma.Decimal(snapshot.todaysChangePerc),\n                              updatedAt: DateTime.fromMillis(t / 1e6, {\n                                  zone: 'America/New_York',\n                              }),\n                          }\n                        : null,\n            }\n        })\n    }\n\n    private async _snapshotOption(ticker: string) {\n        // https://polygon.io/docs/options/get_v3_reference_options_contracts__options_ticker\n        const underlyingTicker =\n            MarketUtil.getUnderlyingTicker(ticker) ??\n            (await this.api.reference\n                .optionsContract(ticker)\n                .then(({ results: oc }) => oc?.underlying_ticker)\n                .catch(() => null))\n\n        if (!underlyingTicker) return null\n\n        const { results: snapshot } = await this.api.options.snapshotOptionContract(\n            underlyingTicker,\n            ticker\n        )\n\n        return {\n            ticker,\n            pricing:\n                snapshot?.day?.close != null &&\n                snapshot.day.change != null &&\n                snapshot.day.change_percent != null &&\n                snapshot.day.last_updated != null\n                    ? {\n                          ticker: ticker,\n                          price: new Prisma.Decimal(snapshot.day.close),\n                          change: new Prisma.Decimal(snapshot.day.change),\n                          changePct: new Prisma.Decimal(snapshot.day.change_percent),\n                          updatedAt: DateTime.fromMillis(snapshot.day.last_updated / 1e6, {\n                              zone: 'America/New_York',\n                          }),\n                      }\n                    : null,\n        }\n    }\n\n    private async _snapshotCrypto(tickers: string[]) {\n        /**\n         * https://polygon.io/docs/crypto/get_v2_snapshot_locale_global_markets_crypto_tickers\n         */\n        const res = await this.api.crypto.snapshotAllTickers({\n            tickers: tickers.join(','),\n        })\n\n        const snapshots = res.tickers ?? []\n\n        // ENG-601: alert us if polygon returns any prices as 0\n        const emptySnapshots = snapshots.filter((t) => !t.updated && !t.lastTrade?.t)\n\n        if (emptySnapshots.length > 0) {\n            this.logger.error(\n                `polygon snapshot empty tickers: ${emptySnapshots.map((t) => t.ticker).join(',')}`\n            )\n        }\n\n        return snapshots.map((snapshot) => {\n            // extract the (p)rice + (t)ime to use for the live price\n            // if the (t)ime is 0, we're dealing with an empty/zero snapshot from Polygon\n            //\n            // the order of priority for pricing that we use is:\n            //\n            // 1. The `day` snapshot object from Polygon (OHLCV)\n            // 2. The `lastTrade`\n            // 3. The `lastQuote` - we calculate the midpoint\n            const [p, t] =\n                // it's possible for a snapshot to have a valid `updated` time but\n                // a zeroed out `snapshot.day` object which is why we check for both here\n                snapshot.updated && snapshot.day && Object.values(snapshot.day).some((v) => v > 0)\n                    ? [snapshot.day.c, snapshot.updated]\n                    : snapshot.lastTrade && snapshot.lastTrade.t\n                    ? [snapshot.lastTrade.p, snapshot.lastTrade.t]\n                    : [null, null]\n\n            return {\n                ticker: snapshot.ticker,\n                pricing:\n                    t &&\n                    snapshot.ticker &&\n                    snapshot.todaysChange != null &&\n                    snapshot.todaysChangePerc != null\n                        ? {\n                              ticker: snapshot.ticker,\n                              price: new Prisma.Decimal(p!),\n                              change: new Prisma.Decimal(snapshot.todaysChange),\n                              changePct: new Prisma.Decimal(snapshot.todaysChangePerc),\n                              updatedAt: DateTime.fromMillis(t / 1e6, {\n                                  zone: 'America/New_York',\n                              }),\n                          }\n                        : null,\n            }\n        })\n    }\n}\n\nclass PolygonTicker {\n    constructor(readonly market: 'stocks' | 'options' | 'fx' | 'crypto', readonly ticker: string) {}\n\n    get key() {\n        return `${this.market}|${this.ticker}`\n    }\n\n    /** override so this object can be used directly in string interpolation for cache keys */\n    toString() {\n        return this.key\n    }\n}\n\nexport function getPolygonTicker({\n    assetClass,\n    currencyCode,\n    symbol,\n}: Pick<Security, 'assetClass' | 'currencyCode' | 'symbol'>): PolygonTicker | null {\n    if (!symbol) return null\n\n    switch (assetClass) {\n        case AssetClass.options: {\n            return new PolygonTicker('options', `O:${symbol}`)\n        }\n        case AssetClass.crypto: {\n            return new PolygonTicker('crypto', `X:${symbol}${currencyCode}`)\n        }\n        case AssetClass.cash: {\n            return symbol === currencyCode\n                ? null // if the symbol matches the currencyCode then we're just dealing with a basic cash holding\n                : new PolygonTicker('fx', `C:${symbol}${currencyCode}`)\n        }\n    }\n\n    if (MarketUtil.isOptionTicker(symbol)) {\n        return new PolygonTicker('options', `O:${symbol}`)\n    }\n\n    return new PolygonTicker('stocks', symbol)\n}\n"
  },
  {
    "path": "libs/server/shared/src/services/pg.service.ts",
    "content": "import { Prisma } from '@prisma/client'\nimport { Pool, types } from 'pg'\nimport type { Logger } from 'winston'\n\n// convert date to string\ntypes.setTypeParser(types.builtins.DATE, (val) => val)\n\n// convert bigint\ntypes.setTypeParser(types.builtins.INT8, (val) => {\n    return val == null ? null : BigInt(val)\n})\n\n// convert numeric to Decimal.js\ntypes.setTypeParser(types.builtins.NUMERIC, (val) => {\n    return val == null ? null : new Prisma.Decimal(val)\n})\n\nexport class PgService {\n    private readonly _pool: Pool\n\n    constructor(private readonly logger: Logger, databaseUrl = process.env.NX_DATABASE_URL) {\n        this._pool = new Pool({\n            connectionString: databaseUrl,\n        })\n\n        this._pool.on('error', (err, _client) => {\n            console.error('[pg.error]', err)\n        })\n    }\n\n    get pool() {\n        return this._pool\n    }\n}\n"
  },
  {
    "path": "libs/server/shared/src/services/queue/bull-queue.ts",
    "content": "import Queue from 'bull'\nimport * as Sentry from '@sentry/node'\nimport type { Transaction } from '@sentry/types'\nimport type { Logger } from 'winston'\nimport type { IJob, IQueue, IQueueFactory, QueueName } from '../queue.service'\n\nconst TRACE_ID_KEY = '__SENTRY_TRACE_ID__'\nconst PARENT_SPAN_ID_KEY = '__SENTRY_PARENT_SPAN_ID__'\n\nexport class BullQueue<TData extends Record<string, any> = any, TJobName extends string = string>\n    implements IQueue<TData, TJobName>\n{\n    constructor(readonly logger: Logger, readonly queue: Queue.Queue<TData>) {}\n\n    get name() {\n        return this.queue.name\n    }\n\n    async isHealthy() {\n        const isReady = await this.queue.isReady()\n        return isReady && this.queue.clients.every((cli) => cli.status === 'ready')\n    }\n\n    async add(name: TJobName, data: TData, options?: Queue.JobOptions | undefined) {\n        const parentSpan = Sentry.getCurrentHub().getScope()?.getSpan()\n        const span = parentSpan?.startChild({\n            op: `queue.send`,\n            description: `${this.name} send`,\n            tags: {\n                'messaging.system': 'bull',\n                'messaging.destination': this.name,\n                'messaging.destination_kind': 'queue',\n                'messaging.bull.job_name': name,\n            },\n        })\n\n        try {\n            const job = await this.queue.add(\n                name,\n                {\n                    ...data,\n                    [TRACE_ID_KEY]: span?.traceId,\n                    [PARENT_SPAN_ID_KEY]: span?.parentSpanId,\n                },\n                options\n            )\n            span?.setTag('messaging.message_id', job.id)\n            return job\n        } finally {\n            span?.finish()\n        }\n    }\n\n    async addBulk(jobs: { name: TJobName; data: TData; options?: Queue.JobOptions | undefined }[]) {\n        const parentSpan = Sentry.getCurrentHub().getScope()?.getSpan()\n\n        const spans = jobs.map((job) =>\n            parentSpan?.startChild({\n                op: `queue.send`,\n                description: `${this.name} send`,\n                tags: {\n                    'messaging.system': 'bull',\n                    'messaging.destination': this.name,\n                    'messaging.destination_kind': 'queue',\n                    'messaging.bull.job_name': job.name,\n                },\n            })\n        )\n\n        try {\n            const added = await this.queue.addBulk(jobs)\n            spans.forEach((span, idx) => span?.setTag('messaging.message_id', added[idx]?.id))\n            return added\n        } finally {\n            spans.forEach((span) => span?.finish())\n        }\n    }\n\n    async process(\n        name: TJobName,\n        callback: (job: Queue.Job<TData>) => Promise<void>,\n        options: { concurrency?: number } = {}\n    ) {\n        const { concurrency = 1 } = options\n\n        return this.queue.process(name, concurrency, async (job) => {\n            let transaction: Transaction | null = null\n\n            try {\n                // https://docs.sentry.io/platforms/javascript/performance/instrumentation/custom-instrumentation/\n                transaction = Sentry.startTransaction({\n                    op: 'queue.process',\n                    name: `${this.name} process`,\n                    tags: {\n                        'messaging.system': 'bull',\n                        'messaging.operation': 'process',\n                        'messaging.destination': this.name,\n                        'messaging.destination_kind': 'queue',\n                        'messaging.message_id': job.id,\n                        'messaging.bull.job_name': job.name,\n                    },\n                    traceId: job.data?.[TRACE_ID_KEY],\n                    parentSpanId: job.data?.[PARENT_SPAN_ID_KEY],\n                })\n\n                Sentry.getCurrentHub().configureScope((scope) => scope.setSpan(transaction!))\n            } catch (err) {\n                this.logger.error(`error starting sentry transaction`, err)\n            }\n\n            try {\n                await callback(job)\n            } finally {\n                transaction?.finish()\n            }\n        })\n    }\n\n    async getActiveJobs() {\n        return this.queue.getActive()\n    }\n\n    async getRepeatableJobs(): Promise<Queue.JobInformation[]> {\n        return this.queue.getRepeatableJobs()\n    }\n\n    async removeRepeatableByKey(key: string) {\n        return this.queue.removeRepeatableByKey(key)\n    }\n\n    async cancelJobs() {\n        await this.queue.pause(true, true)\n        await this.queue.removeJobs('*')\n        const activeJobs = await this.queue.getActive()\n        await Promise.all(\n            activeJobs.map((job) => job.moveToFailed({ message: 'Force Remove' }, true))\n        )\n        await this.queue.removeJobs('*')\n        await this.queue.resume(true)\n    }\n\n    on(event: 'active', callback: (job: IJob<TData>) => void): void\n    on(event: 'completed', callback: (job: IJob<TData>) => void): void\n    on(event: 'failed', callback: (job: IJob<TData>, error: Error) => void): void\n    on(event: 'error', callback: (error: Error) => void): void\n    on(event: string, callback: (...args: any[]) => void) {\n        return this.queue.on(event, callback)\n    }\n}\n\nexport interface IBullQueueEventHandler {\n    onQueueCreated(queue: BullQueue): void\n}\n\n/**\n * This service uses shared Bull queue connection, to avoid connection limit issues and easily share between apps and libs\n *\n * @see https://github.com/OptimalBits/bull/blob/HEAD/PATTERNS.md#reusing-redis-connections\n */\nexport class BullQueueFactory implements IQueueFactory {\n    constructor(\n        private readonly logger: Logger,\n        private readonly redisUrl: string,\n        private readonly eventHandler?: IBullQueueEventHandler\n    ) {}\n\n    createQueue(name: QueueName) {\n        const logger = this.logger.child({ service: `BullQueue[${name}]` })\n        const queue = new BullQueue(logger, new Queue(name, this.redisUrl))\n        this.eventHandler?.onQueueCreated(queue)\n        return queue\n    }\n}\n"
  },
  {
    "path": "libs/server/shared/src/services/queue/in-memory-queue.ts",
    "content": "import type { F } from 'ts-toolbelt'\nimport type { IQueue, IJob, JobOptions, IQueueFactory, QueueName } from '../queue.service'\n\n/**\n * This is a mock implementation of a queue used for testing only.\n * @see BullQueue for a production implementation.\n */\nexport class InMemoryQueue<\n    TData extends Record<string, any> = any,\n    TJobName extends string = string\n> implements IQueue<TData, TJobName>\n{\n    private readonly processFns: Record<\n        string,\n        F.Parameters<IQueue<TData, TJobName>['process']>[1]\n    > = {}\n\n    constructor(readonly name: string, private readonly ignoreJobNames: string[] = []) {}\n\n    async isHealthy() {\n        return true\n    }\n\n    async add(name: TJobName, data: TData, _options?: JobOptions | undefined) {\n        const job: IJob<TData> = {\n            id: `${name}.${new Date().getTime()}.${Math.random()}`,\n            name,\n            data,\n            progress: () => Promise.resolve(),\n        }\n\n        if (!this.ignoreJobNames.includes(name)) {\n            try {\n                // immediately run job\n                await this.processFns[name](job)\n            } catch (err) {\n                // ignore\n            }\n        }\n\n        return job\n    }\n\n    async addBulk(jobs: { name: TJobName; data: TData; options?: JobOptions | undefined }[]) {\n        return Promise.all(jobs.map((job) => this.add(job.name, job.data)))\n    }\n\n    async process(name: TJobName, fn: (job: IJob<TData>) => Promise<void>) {\n        this.processFns[name] = fn\n    }\n\n    async getActiveJobs() {\n        return []\n    }\n\n    async getRepeatableJobs() {\n        return []\n    }\n\n    async removeRepeatableByKey(_key: string) {\n        // no-op\n    }\n\n    async cancelJobs() {\n        // no-op\n    }\n}\n\nexport class InMemoryQueueFactory implements IQueueFactory {\n    constructor(\n        private readonly ignoreJobNames: string[] = [\n            'sync-all-securities',\n            'sync-teller-institutions',\n            'trial-reminders',\n            'send-email',\n        ]\n    ) {}\n\n    createQueue(name: QueueName) {\n        return new InMemoryQueue(name, this.ignoreJobNames)\n    }\n}\n"
  },
  {
    "path": "libs/server/shared/src/services/queue/index.ts",
    "content": "export * from './bull-queue'\nexport * from './in-memory-queue'\n"
  },
  {
    "path": "libs/server/shared/src/services/queue.service.ts",
    "content": "import type { AccountConnection, User, Account } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport type { Job, JobOptions, JobInformation } from 'bull'\nimport type { SharedType } from '@maybe-finance/shared'\n\nexport type IJob<T> = Pick<Job<T>, 'id' | 'name' | 'data' | 'progress'>\n\nexport type { JobOptions }\n\nexport type IQueue<TData extends Record<string, any> = {}, TJobName extends string = string> = {\n    name: string\n    isHealthy(): Promise<boolean>\n    add(name: TJobName, data: TData, options?: JobOptions): Promise<IJob<TData>>\n    addBulk(\n        jobs: { name: TJobName; data: TData; options?: JobOptions | undefined }[]\n    ): Promise<IJob<TData>[]>\n    process(\n        name: TJobName,\n        callback: (job: IJob<TData>) => Promise<void>,\n        options?: { concurrency: number }\n    ): Promise<void>\n    getActiveJobs(): Promise<IJob<TData>[]>\n    getRepeatableJobs(): Promise<JobInformation[]>\n    removeRepeatableByKey(key: string): Promise<void>\n    cancelJobs(): Promise<void>\n}\n\nexport type SyncUserOptions = {}\nexport type SyncUserQueueJobData = {\n    userId: User['id']\n    options?: SyncUserOptions\n}\n\nexport type SyncAccountOptions = {}\nexport type SyncAccountQueueJobData = {\n    accountId: Account['id']\n    options?: SyncAccountOptions\n}\n\nexport type SyncConnectionOptions = { type: 'teller'; initialSync?: boolean }\n\nexport type SyncConnectionQueueJobData = {\n    accountConnectionId: AccountConnection['id']\n    options?: SyncConnectionOptions\n}\n\nexport type SyncSecurityQueueJobData = {}\n\nexport type SendEmailQueueJobData =\n    | {\n          type: 'trial-reminders'\n      }\n    | {\n          type: 'plain'\n          messages: SharedType.PlainEmailMessage | SharedType.PlainEmailMessage[]\n      }\n    | {\n          type: 'template'\n          messages: SharedType.TemplateEmailMessage | SharedType.TemplateEmailMessage[]\n      }\n\nexport type SyncUserQueue = IQueue<SyncUserQueueJobData, 'sync-user'>\nexport type SyncAccountQueue = IQueue<SyncAccountQueueJobData, 'sync-account'>\nexport type SyncConnectionQueue = IQueue<SyncConnectionQueueJobData, 'sync-connection'>\nexport type SyncSecurityQueue = IQueue<\n    SyncSecurityQueueJobData,\n    'sync-all-securities' | 'sync-us-stock-tickers'\n>\nexport type PurgeUserQueue = IQueue<{ userId: User['id'] }, 'purge-user'>\nexport type SyncInstitutionQueue = IQueue<{}, 'sync-teller-institutions'>\nexport type SendEmailQueue = IQueue<SendEmailQueueJobData, 'send-email'>\n\nexport type QueueName =\n    | 'sync-user'\n    | 'sync-account-connection'\n    | 'sync-account'\n    | 'purge-user'\n    | 'sync-security'\n    | 'sync-institution'\n    | 'send-email'\n\nexport interface IQueueFactory {\n    createQueue(name: 'sync-user'): SyncUserQueue\n    createQueue(name: 'sync-account'): SyncAccountQueue\n    createQueue(name: 'sync-account-connection'): SyncConnectionQueue\n    createQueue(name: 'sync-security'): SyncSecurityQueue\n    createQueue(name: 'purge-user'): PurgeUserQueue\n    createQueue(name: 'sync-institution'): SyncInstitutionQueue\n    createQueue(name: 'send-email'): SendEmailQueue\n    createQueue(name: QueueName): IQueue\n}\n\nexport class QueueService {\n    private readonly queues: Record<string, IQueue<any>> = {}\n\n    constructor(private readonly logger: Logger, private readonly queueFactory: IQueueFactory) {\n        this.createQueue('sync-user')\n        this.createQueue('sync-account')\n        this.createQueue('sync-account-connection')\n        this.createQueue('sync-security')\n        this.createQueue('purge-user')\n        this.createQueue('sync-institution')\n        this.createQueue('send-email')\n    }\n\n    get allQueues() {\n        return Object.values(this.queues)\n    }\n\n    getQueue(name: 'sync-user'): SyncUserQueue\n    getQueue(name: 'sync-account'): SyncAccountQueue\n    getQueue(name: 'sync-account-connection'): SyncConnectionQueue\n    getQueue(name: 'sync-security'): SyncSecurityQueue\n    getQueue(name: 'purge-user'): PurgeUserQueue\n    getQueue(name: 'sync-institution'): SyncInstitutionQueue\n    getQueue(name: 'send-email'): SendEmailQueue\n    getQueue<TData extends Record<string, any> = any>(name: QueueName): IQueue<TData> {\n        return this.queues[name] ?? this.createQueue(name)\n    }\n\n    async cancelAllJobs() {\n        await Promise.allSettled(this.allQueues.map((q) => q.cancelJobs()))\n    }\n\n    private createQueue(name: QueueName) {\n        return (this.queues[name] = this.queueFactory.createQueue(name))\n    }\n}\n"
  },
  {
    "path": "libs/server/shared/src/sql-template-tag.ts",
    "content": "/**\n * This is a copy of the sql-template-tag package, I wasn't able to use the npm package due to errors with nx/webpack and ESM modules\n * https://github.com/blakeembrey/sql-template-tag\n */\nexport type Value =\n    | string\n    | number\n    | boolean\n    | Date\n    | null\n    | undefined\n    | Value[]\n    | { [key: string | number]: Value }\n\nexport type RawValue = Value | Sql\n\n/**\n * A SQL instance can be nested within each other to build SQL strings.\n */\nexport class Sql {\n    values: Value[]\n    strings: string[]\n\n    constructor(rawStrings: ReadonlyArray<string>, rawValues: ReadonlyArray<RawValue>) {\n        let valuesLength = rawValues.length\n        let stringsLength = rawStrings.length\n\n        if (stringsLength === 0) {\n            throw new TypeError('Expected at least 1 string')\n        }\n\n        if (stringsLength - 1 !== valuesLength) {\n            throw new TypeError(\n                `Expected ${stringsLength} strings to have ${stringsLength - 1} values`\n            )\n        }\n\n        for (const child of rawValues) {\n            if (child instanceof Sql) {\n                valuesLength += child.values.length - 1\n                stringsLength += child.strings.length - 2\n            }\n        }\n\n        this.values = new Array(valuesLength)\n        this.strings = new Array(stringsLength)\n\n        this.strings[0] = rawStrings[0]\n\n        // Iterate over raw values, strings, and children. The value is always\n        // positioned between two strings, e.g. `index + 1`.\n        let index = 1\n        let position = 0\n        while (index < rawStrings.length) {\n            const child = rawValues[index - 1]\n            const rawString = rawStrings[index++]\n\n            // Check for nested `sql` queries.\n            if (child instanceof Sql) {\n                // Append child prefix text to current string.\n                this.strings[position] += child.strings[0]\n\n                let childIndex = 0\n                while (childIndex < child.values.length) {\n                    this.values[position++] = child.values[childIndex++]\n                    this.strings[position] = child.strings[childIndex]\n                }\n\n                // Append raw string to current string.\n                this.strings[position] += rawString\n            } else {\n                this.values[position++] = child\n                this.strings[position] = rawString\n            }\n        }\n    }\n\n    get text() {\n        return this.strings.reduce((text, part, index) => `${text}$${index}${part}`)\n    }\n\n    get sql() {\n        return this.strings.join('?')\n    }\n\n    inspect() {\n        return {\n            text: this.text,\n            sql: this.sql,\n            values: this.values,\n        }\n    }\n}\n\n/**\n * Create a SQL query for a list of values.\n */\nexport function join(values: RawValue[], separator = ',') {\n    if (values.length === 0) {\n        throw new TypeError(\n            'Expected `join([])` to be called with an array of multiple elements, but got an empty array'\n        )\n    }\n\n    return new Sql(['', ...Array(values.length - 1).fill(separator), ''], values)\n}\n\n/**\n * Create raw SQL statement.\n */\nexport function raw(value: string) {\n    return new Sql([value], [])\n}\n\n/**\n * Placeholder value for \"no text\".\n */\nexport const empty = raw('')\n\n/**\n * Create a SQL object from a template string.\n */\nexport default function sql(strings: ReadonlyArray<string>, ...values: RawValue[]) {\n    return new Sql(strings, values)\n}\n"
  },
  {
    "path": "libs/server/shared/src/utils/db-utils.ts",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport { Prisma } from '@prisma/client'\nimport type { Logger } from 'winston'\nimport { NumberUtil, SharedUtil } from '@maybe-finance/shared'\n\n// prisma middleware that reports slow queries\nexport function slowQueryMiddleware(logger: Logger, cutoffDuration = 1_000): Prisma.Middleware {\n    return async (params, next) => {\n        const start = Date.now()\n        const res = await next(params)\n        const duration = Date.now() - start\n\n        // log slow queries\n        if (duration > cutoffDuration) {\n            logger.warn(\n                `[SLOW_QUERY] ${params.model ? `${params.model}.` : ''}${\n                    params.action\n                } took ${duration}ms`,\n                { duration }\n            )\n\n            logger.debug(`[SLOW_QUERY] query`, params)\n        }\n\n        return res\n    }\n}\n\n/**\n * converts a `TimeSeriesInterval` to a postgres interval literal\n */\nexport function toPgInterval(interval: SharedType.TimeSeriesInterval): string {\n    switch (interval) {\n        case 'days':\n            return '1 day'\n        case 'weeks':\n            return '1 week'\n        case 'months':\n            return '30 days'\n        case 'quarters':\n            return '91 days'\n        case 'years':\n            return '365 days'\n        default:\n            throw new Error(`invalid interval: ${interval}`)\n    }\n}\n\ntype NumberOrDecimal = Prisma.Decimal | number\n\nfunction getTrendDirection(_amount: NumberOrDecimal | null): SharedType.Trend['direction'] {\n    const amount = toDecimal(_amount)\n    if (!SharedUtil.nonNull(amount)) return 'flat'\n    return amount.lt(-0.01) ? 'down' : amount.gt(0.01) ? 'up' : 'flat'\n}\n\nexport function calculateTrend(_from: NumberOrDecimal, _to: NumberOrDecimal): SharedType.Trend {\n    const from = toDecimal(_from)\n    const to = toDecimal(_to)\n\n    const amount = to.minus(from)\n    const percentage = NumberUtil.calculatePercentChange(from, to)\n\n    return {\n        direction: getTrendDirection(amount),\n        amount,\n        percentage,\n    }\n}\n\nexport function toTrend(\n    _amount: NumberOrDecimal | null,\n    _percentage: NumberOrDecimal | null\n): SharedType.Trend {\n    const amount = toDecimal(_amount)\n    const percentage = toDecimal(_percentage)\n\n    return {\n        direction: getTrendDirection(amount),\n        amount,\n        percentage,\n    }\n}\n\nexport function toDecimal(x: NumberOrDecimal): Prisma.Decimal\nexport function toDecimal(x?: NumberOrDecimal | null): Prisma.Decimal | null\nexport function toDecimal(x?: NumberOrDecimal | null): Prisma.Decimal | null {\n    return x == null ? null : typeof x === 'number' ? new Prisma.Decimal(x).toDP(16) : x\n}\n"
  },
  {
    "path": "libs/server/shared/src/utils/error-utils.ts",
    "content": "import type { SharedType } from '@maybe-finance/shared'\nimport type { AxiosError } from 'axios'\nimport { Prisma } from '@prisma/client'\nimport axios from 'axios'\n\ntype PrismaError =\n    | Prisma.PrismaClientKnownRequestError\n    | Prisma.PrismaClientUnknownRequestError\n    | Prisma.PrismaClientRustPanicError\n    | Prisma.PrismaClientInitializationError\n    | Prisma.PrismaClientValidationError\n\n// Current no simple `isPrismaError()` method provided, so we must check all Class interfaces\nfunction isPrismaError(error: unknown): error is PrismaError {\n    return (\n        error instanceof Prisma.PrismaClientKnownRequestError ||\n        error instanceof Prisma.PrismaClientUnknownRequestError ||\n        error instanceof Prisma.PrismaClientRustPanicError ||\n        error instanceof Prisma.PrismaClientInitializationError ||\n        error instanceof Prisma.PrismaClientValidationError\n    )\n}\n\nexport function isTellerError(err: unknown): err is SharedType.AxiosTellerError {\n    if (!err) return false\n    if (!axios.isAxiosError(err)) return false\n    if (typeof err.response?.data !== 'object') return false\n\n    const { data } = err.response\n    return 'code' in data.error && 'message' in data.error\n}\n\nexport function parseError(error: unknown): SharedType.ParsedError {\n    if (axios.isAxiosError(error)) {\n        return parseAxiosError(error)\n    }\n\n    if (isPrismaError(error)) {\n        return parsePrismaError(error)\n    }\n\n    if (error instanceof Error) {\n        return parseJSError(error)\n    }\n\n    if (typeof error === 'string') {\n        return {\n            message: error,\n        }\n    }\n\n    if (typeof error === 'number') {\n        return {\n            message: error.toString(),\n        }\n    }\n\n    return {\n        message: '[unknown-error] Unable to parse',\n        metadata: error,\n    }\n}\n\nfunction parseAxiosError(error: AxiosError): SharedType.ParsedError {\n    return {\n        message: error.message,\n        statusCode: error.response ? error.response.status.toString() : '500',\n        metadata: error.response ? error.response.data : undefined,\n        stackTrace: error.stack,\n        sentryContexts: {\n            'axios error': error.response?.data,\n        },\n    }\n}\n\nfunction parseJSError(error: Error): SharedType.ParsedError {\n    return {\n        message: error.message,\n        stackTrace: error.stack,\n    }\n}\n\nfunction parsePrismaError(error: PrismaError): SharedType.ParsedError {\n    return {\n        message: `[prisma-error] name=${error.name} message=${error.message}`,\n        stackTrace: error.stack,\n    }\n}\n"
  },
  {
    "path": "libs/server/shared/src/utils/index.ts",
    "content": "export * as DbUtil from './db-utils'\nexport * as TellerUtil from './teller-utils'\nexport * as ErrorUtil from './error-utils'\n\n// All \"generic\" server utils grouped here\nexport * as ServerUtil from './server-utils'\n"
  },
  {
    "path": "libs/server/shared/src/utils/server-utils.ts",
    "content": "/**\n * @returns redis retry strategy\n */\nexport function redisRetryStrategy({\n    maxAttempts = Infinity,\n    delayMs = 2_000,\n    backoff = 'linear',\n}: {\n    maxAttempts?: number\n    delayMs?: number\n    backoff?: 'linear' | 'exponential'\n} = {}) {\n    return (attempt: number) => {\n        const delay = backoff === 'linear' ? delayMs : delayMs * attempt\n        return attempt <= maxAttempts ? delay : null\n    }\n}\n\n/**\n * wrapper for executing sync pattern, basically a try-catch-else-finally pattern\n */\nexport function useSync<TEntity>({\n    onStart,\n    sync,\n    onSyncError,\n    onSyncSuccess,\n    onEnd,\n}: {\n    onStart?: (entity: TEntity) => Promise<any>\n    sync: (entity: TEntity) => Promise<any>\n    onSyncError: (entity: TEntity, error: unknown) => Promise<any>\n    onSyncSuccess: (entity: TEntity) => Promise<any>\n    onEnd?: (entity: TEntity) => Promise<any>\n}): (entity: TEntity) => Promise<void> {\n    return async (entity: TEntity) => {\n        await onStart?.(entity)\n\n        await tryCatchElseFinally(\n            () => sync(entity),\n            (error) => onSyncError(entity, error),\n            () => onSyncSuccess(entity),\n            () => onEnd?.(entity) ?? Promise.resolve()\n        )\n    }\n}\n\nasync function tryCatchElseFinally(\n    _try: () => Promise<void>,\n    _catch: (error: unknown) => Promise<void>,\n    _else: () => Promise<void>,\n    _finally: () => Promise<void>\n): Promise<void> {\n    try {\n        let success = true\n\n        try {\n            await _try()\n        } catch (error) {\n            success = false\n            await _catch(error)\n        }\n\n        if (success) {\n            await _else()\n        }\n    } finally {\n        await _finally()\n    }\n}\n\n// Temporary until Prisma Client Extensions work better\nexport type SignerConfig = {\n    cdnUrl: string\n    pubKeyId: string\n    privKey: string\n}\n"
  },
  {
    "path": "libs/server/shared/src/utils/teller-utils.ts",
    "content": "import { Prisma, AccountCategory, AccountType } from '@prisma/client'\nimport type { AccountClassification } from '@prisma/client'\nimport type { Account } from '@prisma/client'\nimport type { TellerTypes } from '@maybe-finance/teller-api'\nimport { Duration } from 'luxon'\n\n/**\n * Update this with the max window that Teller supports\n */\nexport const TELLER_WINDOW_MAX = Duration.fromObject({ years: 2 })\n\nexport function getAccountBalanceData(\n    { balance, currency }: Pick<TellerTypes.AccountWithBalances, 'balance' | 'currency'>,\n    classification: AccountClassification\n): Pick<\n    Account,\n    | 'currentBalanceProvider'\n    | 'currentBalanceStrategy'\n    | 'availableBalanceProvider'\n    | 'availableBalanceStrategy'\n    | 'currencyCode'\n> {\n    return {\n        currentBalanceProvider: new Prisma.Decimal(balance.ledger ? Number(balance.ledger) : 0),\n        currentBalanceStrategy: 'current',\n        availableBalanceProvider: new Prisma.Decimal(\n            balance.available ? Number(balance.available) : 0\n        ),\n        availableBalanceStrategy: 'available',\n        currencyCode: currency,\n    }\n}\n\nexport function getType(type: TellerTypes.AccountTypes): AccountType {\n    switch (type) {\n        case 'depository':\n            return AccountType.DEPOSITORY\n        case 'credit':\n            return AccountType.CREDIT\n        default:\n            return AccountType.OTHER_ASSET\n    }\n}\n\nexport function tellerTypesToCategory(tellerType: TellerTypes.AccountTypes): AccountCategory {\n    switch (tellerType) {\n        case 'depository':\n            return AccountCategory.cash\n        case 'credit':\n            return AccountCategory.credit\n        default:\n            return AccountCategory.other\n    }\n}\n"
  },
  {
    "path": "libs/server/shared/tsconfig.json",
    "content": "{\n    \"extends\": \"../../../tsconfig.base.json\",\n    \"compilerOptions\": {\n        \"esModuleInterop\": true,\n        \"noImplicitAny\": false,\n        \"strict\": true,\n        \"strictNullChecks\": true\n    },\n    \"files\": [],\n    \"include\": [],\n    \"references\": [\n        {\n            \"path\": \"./tsconfig.lib.json\"\n        },\n        {\n            \"path\": \"./tsconfig.spec.json\"\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/server/shared/tsconfig.lib.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"module\": \"commonjs\",\n        \"outDir\": \"../../../dist/out-tsc\",\n        \"declaration\": true,\n        \"types\": [\"node\"]\n    },\n    \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"jest.config.ts\"],\n    \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "libs/server/shared/tsconfig.spec.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../../dist/out-tsc\",\n        \"module\": \"commonjs\",\n        \"types\": [\"jest\", \"node\"]\n    },\n    \"include\": [\n        \"**/*.spec.ts\",\n        \"**/*.test.ts\",\n        \"**/*.spec.tsx\",\n        \"**/*.test.tsx\",\n        \"**/*.spec.js\",\n        \"**/*.test.js\",\n        \"**/*.spec.jsx\",\n        \"**/*.test.jsx\",\n        \"**/*.d.ts\",\n        \"jest.config.ts\"\n    ]\n}\n"
  },
  {
    "path": "libs/shared/.babelrc",
    "content": "{\n    \"presets\": [[\"@nrwl/web/babel\", { \"useBuiltIns\": \"usage\" }]]\n}\n"
  },
  {
    "path": "libs/shared/.eslintrc.json",
    "content": "{\n    \"extends\": [\"../../.eslintrc.json\"],\n    \"ignorePatterns\": [\"!**/*\"],\n    \"overrides\": [\n        {\n            \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.ts\", \"*.tsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.ts\"],\n            \"rules\": {\n                \"@typescript-eslint/no-unused-vars\": [\"warn\", { \"argsIgnorePattern\": \"^_\" }]\n            }\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/shared/README.md",
    "content": "# shared\n\nThis library was generated with [Nx](https://nx.dev).\n\n## Running unit tests\n\nRun `nx test shared` to execute the unit tests via [Jest](https://jestjs.io).\n"
  },
  {
    "path": "libs/shared/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n    displayName: 'shared',\n    preset: '../../jest.preset.js',\n    globals: {\n        'ts-jest': {\n            tsconfig: '<rootDir>/tsconfig.spec.json',\n        },\n    },\n    testEnvironment: 'node',\n    transform: {\n        '^.+\\\\.[tj]sx?$': 'ts-jest',\n    },\n    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n    coverageDirectory: '../../coverage/libs/shared',\n}\n"
  },
  {
    "path": "libs/shared/src/index.ts",
    "content": "export * as SharedType from './types'\n\n// namespaces for these are exported from ./utils/index.ts\nexport * from './utils'\n\nexport { default as superjson } from './superjson'\n"
  },
  {
    "path": "libs/shared/src/superjson.spec.ts",
    "content": "import { Prisma } from '@prisma/client'\nimport { Decimal } from 'decimal.js'\nimport { DateTime } from 'luxon'\nimport superjson from './superjson'\n\ndescribe('superjson', () => {\n    it.each`\n        type                | value\n        ${`BigInt`}         | ${BigInt(123)}\n        ${`Decimal`}        | ${new Decimal(1.23)}\n        ${`Prisma.Decimal`} | ${new Prisma.Decimal(1.23)}\n        ${`Date`}           | ${new Date()}\n        ${`DateTime`}       | ${DateTime.now()}\n    `('can serialize $type', ({ type, value }) => {\n        const s = superjson.stringify(value)\n        const v = superjson.parse(s)\n\n        // Prisma.Decimal always gets converted to a decimal.js Decimal\n        // so we need to special case that equality check\n        expect(v).toEqual(type === 'Prisma.Decimal' ? new Decimal(value) : value)\n    })\n})\n"
  },
  {
    "path": "libs/shared/src/superjson.ts",
    "content": "import superjson from 'superjson'\nimport type { Prisma } from '@prisma/client'\nimport { Decimal } from 'decimal.js'\nimport { DateTime } from 'luxon'\n\nsuperjson.registerCustom<Decimal | Prisma.Decimal, string>(\n    {\n        isApplicable: (v): v is Decimal | Prisma.Decimal => Decimal.isDecimal(v),\n        serialize: (d) => d.toJSON(),\n        deserialize: (s) => new Decimal(s),\n    },\n    'Decimal'\n)\n\nsuperjson.registerCustom<DateTime, string>(\n    {\n        isApplicable: (v): v is DateTime => DateTime.isDateTime(v),\n        serialize: (dt) => dt.toISO(),\n        deserialize: (s) => DateTime.fromISO(s),\n    },\n    'DateTime'\n)\n\nexport default superjson\n"
  },
  {
    "path": "libs/shared/src/types/account-types.ts",
    "content": "import type {\n    Account,\n    AccountType,\n    AccountClassification,\n    AccountConnection,\n    AccountConnectionType,\n    AccountConnectionStatus,\n    Holding,\n    InvestmentTransaction,\n    Security,\n    Transaction,\n    Valuation,\n    Prisma,\n    AccountCategory,\n    TransactionType,\n} from '@prisma/client'\nimport type { TimeSeries, TimeSeriesResponseWithDetail, Trend } from './general-types'\nimport type { TransactionEnriched } from './transaction-types'\n\n/**\n * ================================================================\n * ======               Account Detail                       ======\n * ================================================================\n */\nexport type {\n    Account,\n    AccountType,\n    AccountClassification,\n    AccountConnection,\n    AccountConnectionType,\n    AccountConnectionStatus,\n    Valuation,\n}\n\nexport type Loan = {\n    originationDate?: string\n    maturityDate?: string\n    originationPrincipal?: number\n    interestRate: { type: 'fixed'; rate?: number } | { type: 'arm' } | { type: 'variable' }\n    loanDetail: { type: 'student' } | { type: 'mortgage' } | { type: 'other' }\n}\n\nexport type Credit = {\n    isOverdue?: boolean\n    lastPaymentAmount?: number\n    lastPaymentDate?: string\n    lastStatementAmount?: number\n    lastStatementDate?: string\n    minimumPayment?: number\n}\n\nexport type AccountSyncProgress = {\n    description: string\n    progress?: number // 0-1\n}\n\nexport type AccountDetail = Omit<Account, 'loan' | 'credit'> & {\n    accountConnection: AccountConnection\n    transactions: Transaction[]\n    investmentTransactions: InvestmentTransaction[]\n    valuations: Array<Valuation & { security: Security }>\n    holdings: Holding[]\n    loan: Loan | null\n    credit: Credit | null\n}\n\nexport type AccountWithConnection = Account & { accountConnection?: AccountConnection }\n\nexport type AccountsResponse = {\n    accounts: Account[]\n    connections: (ConnectionWithAccounts & ConnectionWithSyncProgress)[]\n}\n\nexport enum PageSize {\n    Transaction = 50,\n    InvestmentTransaction = 25,\n    Valuation = 50,\n    Holding = 50,\n    Institution = 50,\n}\n\nexport type NormalizedCategory = {\n    value: AccountCategory\n    singular: string\n    plural: string\n}\n\n/**\n * ================================================================\n * ======                  Connections                       ======\n * ================================================================\n */\n\nexport type ConnectionWithAccounts = AccountConnection & {\n    accounts: Account[]\n}\n\nexport type ConnectionWithSyncProgress = AccountConnection & {\n    syncProgress?: AccountSyncProgress\n}\n\n/**\n * ================================================================\n * ======               Account Timeseries                   ======\n * ================================================================\n */\n\nexport type AccountBalanceTimeSeriesData = {\n    date: string // yyyy-mm-dd\n    balance: Prisma.Decimal\n}\n\nexport type AccountBalanceResponse = TimeSeriesResponseWithDetail<\n    TimeSeries<AccountBalanceTimeSeriesData>\n>\n\nexport type AccountReturnTimeSeriesData = {\n    date: string\n    account: {\n        rateOfReturn: Prisma.Decimal\n        contributions?: Prisma.Decimal\n        contributionsPeriod?: Prisma.Decimal\n    }\n    comparisons?: {\n        [ticker in string]?: Prisma.Decimal\n    }\n}\n\nexport type AccountReturnResponse = TimeSeries<AccountReturnTimeSeriesData>\n\nexport type AccountTransactionResponse = {\n    transactions: TransactionEnriched[]\n    totalTransactions: number\n}\n\nexport type AccountHolding = Pick<\n    Holding,\n    | 'id'\n    | 'securityId'\n    | 'costBasis'\n    | 'costBasisUser'\n    | 'costBasisProvider'\n    | 'quantity'\n    | 'value'\n    | 'excluded'\n> &\n    Pick<Security, 'symbol' | 'name' | 'sharesPerContract'> & {\n        price: Prisma.Decimal\n        trend: {\n            total: Trend | null\n            today: Trend | null\n        }\n    }\n\nexport type AccountHoldingResponse = {\n    holdings: AccountHolding[]\n    totalHoldings: number\n}\n\nexport type AccountInvestmentTransaction = InvestmentTransaction & { security?: Security }\n\nexport type AccountInvestmentTransactionResponse = {\n    investmentTransactions: AccountInvestmentTransaction[]\n    totalInvestmentTransactions: number\n}\n\nexport type AccountRollupTimeSeries = TimeSeries<\n    Pick<AccountBalanceTimeSeriesData, 'date' | 'balance'> & {\n        rollupPct: Prisma.Decimal\n        totalPct: Prisma.Decimal\n    }\n>\n\ntype AccountRollupGroup<TKey, TItem> = {\n    key: TKey\n    title: string\n    balances: AccountRollupTimeSeries\n    items: TItem[]\n}\n\nexport type AccountRollup = AccountRollupGroup<\n    AccountClassification,\n    AccountRollupGroup<\n        AccountCategory,\n        Pick<Account, 'id' | 'name' | 'mask'> & {\n            connection: Pick<AccountConnection, 'name'> | null\n            syncing: boolean\n            balances: AccountRollupTimeSeries\n        }\n    >\n>[]\n\nexport type AccountValuationsResponse = {\n    valuations: (Valuation & {\n        trend: {\n            period: Trend\n            total: Trend\n        } | null\n    })[]\n    trends: {\n        date: string\n        amount: Prisma.Decimal\n        period: Trend\n        total: Trend\n    }[]\n}\n\nexport type AccountInsights = {\n    portfolio?: {\n        return: {\n            '1m': Trend | null\n            '1y': Trend | null\n            ytd: Trend | null\n        }\n        pnl: Trend | null\n        costBasis: Prisma.Decimal | null\n        contributions: {\n            ytd: {\n                amount: Prisma.Decimal | null\n                monthlyAvg: Prisma.Decimal | null\n            }\n            lastYear: {\n                amount: Prisma.Decimal | null\n                monthlyAvg: Prisma.Decimal | null\n            }\n        }\n        fees: Prisma.Decimal\n        holdingBreakdown: {\n            asset_class: string // 'stocks' | 'fixed_income' | 'cash' | 'crypto' | 'other'\n            amount: Prisma.Decimal\n            percentage: Prisma.Decimal\n        }[]\n    }\n}\n"
  },
  {
    "path": "libs/shared/src/types/api-types.ts",
    "content": "// A simplified version of: https://jsonapi.org/examples/#error-objects-multiple-errors\nexport interface ErrorResponse {\n    errors: Array<{ status: string; title: string; detail?: string }>\n}\n\nexport interface SuccessResponse {\n    data: {\n        json: any // eslint-disable-line\n        meta?: any // eslint-disable-line\n    }\n    [metadata: string]: any // eslint-disable-line\n}\n\n// Can be used in Axios typings in components\n// eslint-disable-next-line\nexport interface ApiResponse<T = any> {\n    data?: T\n    errors?: ErrorResponse['errors']\n}\n\n// eslint-disable-next-line\nexport type BaseResponse = SuccessResponse | ErrorResponse\n"
  },
  {
    "path": "libs/shared/src/types/email-types.ts",
    "content": "import type { MessageSendingResponse } from 'postmark/dist/client/models'\nimport type { SentMessageInfo } from 'nodemailer'\n\ntype EmailCommon = {\n    from?: string\n    to: string\n    replyTo?: string\n}\n\ntype EmailTemplate = { alias: string; model: Record<string, any> }\n\ntype PlainMessageContent = { subject: string; textBody?: string; htmlBody?: string }\n\nexport type PlainEmailMessage = EmailCommon & PlainMessageContent\nexport type TemplateEmailMessage = EmailCommon & { template: EmailTemplate }\nexport type EmailSendingResponse = MessageSendingResponse | SentMessageInfo\n\nexport type SendTestEmail = {\n    recipient: string\n    subject: string\n    textBody?: string\n}\n"
  },
  {
    "path": "libs/shared/src/types/general-types.ts",
    "content": "import type { Prisma } from '@prisma/client'\nimport type { AxiosError } from 'axios'\nimport type { TellerTypes } from '@maybe-finance/teller-api'\nimport type { Contexts, Primitive } from '@sentry/types'\nimport type DecimalJS from 'decimal.js'\nimport type { O } from 'ts-toolbelt'\n\nexport type Decimal = DecimalJS | Prisma.Decimal\n\nexport type DateRange<TDate = string> = {\n    start: TDate\n    end: TDate\n}\n\n/**\n * ================================================================\n * ======                TimeSeries Data                     ======\n * ================================================================\n */\nexport type TimeSeriesInterval = 'days' | 'weeks' | 'months' | 'quarters' | 'years'\n\nexport type TimeSeries<\n    TData extends { date: string },\n    TInterval extends string = TimeSeriesInterval\n> = {\n    interval: TInterval\n    start: string // yyyy-mm-dd\n    end: string // yyyy-mm-dd\n    data: TData[]\n}\n\nexport type TimeSeriesResponseWithDetail<TSeries> = TSeries extends TimeSeries<\n    infer TData,\n    infer _TInterval // eslint-disable-line\n>\n    ? {\n          series: TSeries\n          today?: TData\n          minDate: string\n          trend: Trend\n      }\n    : never\n\n/**\n * ================================================================\n * ======             Calculations / Metrics                 ======\n * ================================================================\n */\n\nexport type Trend = {\n    direction: 'up' | 'down' | 'flat'\n    amount: Decimal | null\n    percentage: Decimal | null\n}\n\nexport type FormatString = 'currency' | 'short-currency' | 'percent' | 'decimal' | 'short-decimal'\n\n/**\n * ================================================================\n * ======             Error types                            ======\n * ================================================================\n */\nexport type ParsedError = {\n    // Parser will attempt to produce a descriptive message from the error\n    message: string\n\n    // Any extra error data to include in logs\n    metadata?: any\n\n    // Not safe for production, but provided for local logs\n    stackTrace?: any\n\n    // This parser covers more than just HTTP errors, so this is optional\n    statusCode?: string\n\n    sentryContexts?: Contexts\n    sentryTags?: { [key: string]: Primitive }\n}\n\nexport type AxiosTellerError = O.Required<\n    AxiosError<TellerTypes.TellerError>,\n    'response' | 'config'\n>\n\nexport type StatusPageResponse = {\n    page?: {\n        id?: string\n        name?: string\n        url?: string\n        updated_at?: string\n    }\n    status?: {\n        description?: string\n        indicator?: 'none' | 'minor' | 'major' | 'critical'\n    }\n}\n"
  },
  {
    "path": "libs/shared/src/types/holding-types.ts",
    "content": "import type { Holding, Prisma, Security } from '@prisma/client'\n\nexport type HoldingInsights = {\n    holding: Holding\n    dividends: Prisma.Decimal | null\n    allocation: Prisma.Decimal | null\n}\n\nexport type HoldingEnriched = Pick<Holding, 'id' | 'quantity' | 'value' | 'excluded'> & {\n    name: Security['name']\n    security_id: Security['id']\n    symbol: Security['symbol']\n    shares_per_contract: Security['sharesPerContract']\n    cost_basis: Prisma.Decimal | null\n    cost_basis_per_share: Prisma.Decimal | null\n    price: Prisma.Decimal\n    price_prev: Prisma.Decimal | null\n}\n"
  },
  {
    "path": "libs/shared/src/types/index.ts",
    "content": "export * from './api-types'\nexport * from './account-types'\nexport * from './email-types'\nexport * from './institution-types'\nexport * from './security-types'\nexport * from './transaction-types'\nexport * from './investment-transaction-types'\nexport * from './user-types'\nexport * from './general-types'\nexport * from './holding-types'\nexport * from './plan-types'\n"
  },
  {
    "path": "libs/shared/src/types/institution-types.ts",
    "content": "import type {\n    Institution as PrismaInstitution,\n    ProviderInstitution as PrismaProviderInstitution,\n} from '@prisma/client'\n\nexport type ProviderInstitution = Pick<\n    PrismaProviderInstitution,\n    'id' | 'provider' | 'providerId' | 'rank'\n>\n\nexport type Institution = Pick<\n    PrismaInstitution | PrismaProviderInstitution,\n    'name' | 'url' | 'logo' | 'logoUrl' | 'primaryColor'\n> & {\n    id: string | number // we allow a `string` ID so we can construct artifical IDs for Institutions backed by a ProviderInstitution\n    providers: ProviderInstitution[]\n}\n\nexport type InstitutionsResponse = {\n    institutions: Institution[]\n    totalInstitutions: number\n}\n"
  },
  {
    "path": "libs/shared/src/types/investment-transaction-types.ts",
    "content": "import type { InvestmentTransaction, InvestmentTransactionCategory } from '@prisma/client'\n\nexport type { InvestmentTransaction, InvestmentTransactionCategory }\n"
  },
  {
    "path": "libs/shared/src/types/plan-types.ts",
    "content": "import type {\n    Prisma,\n    Plan as PrismaPlan,\n    PlanEvent as PrismaPlanEvent,\n    PlanMilestone as PrismaPlanMilestone,\n} from '@prisma/client'\nimport type { O } from 'ts-toolbelt'\nimport type { Decimal, TimeSeries } from './general-types'\n\nexport type PlanEvent = O.NonNullable<PrismaPlanEvent, 'initialValue'>\nexport type PlanMilestone = PrismaPlanMilestone\n\nexport type Plan = PrismaPlan & {\n    events: PlanEvent[]\n    milestones: PlanMilestone[]\n}\n\nexport type PlansResponse = {\n    plans: Plan[]\n}\n\nexport type PlanProjectionEvent = {\n    event: PlanEvent\n    calculatedValue: Decimal\n}\n\nexport type PlanProjectionMilestone = PlanMilestone\n\nexport type PlanProjectionData = {\n    date: string\n    values: {\n        year: number\n        age: number\n        netWorth: Decimal\n        events: PlanProjectionEvent[]\n        milestones: PlanProjectionMilestone[]\n        successRate: Decimal\n    }\n}\n\nexport type PlanSimulationData = {\n    date: string\n    values: {\n        year: number\n        age: number\n        netWorth: Decimal\n    }\n}\n\n// API response\nexport type PlanProjectionResponse = {\n    projection: TimeSeries<PlanProjectionData>\n    simulations: {\n        percentile: Decimal\n        simulation: TimeSeries<PlanSimulationData>\n    }[]\n}\n\nexport type ProjectionAssetType =\n    | 'stocks'\n    | 'fixed_income'\n    | 'cash'\n    | 'crypto'\n    | 'property'\n    | 'other'\nexport type ProjectionLiabilityType = 'credit' | 'loan' | 'other'\n\nexport type PlanInsights = {\n    projectionAssetBreakdown: {\n        type: ProjectionAssetType\n        amount: Prisma.Decimal\n    }[]\n    projectionLiabilityBreakdown: {\n        type: ProjectionLiabilityType\n        amount: Prisma.Decimal\n    }[]\n    income: Prisma.Decimal\n    expenses: Prisma.Decimal\n}\n"
  },
  {
    "path": "libs/shared/src/types/security-types.ts",
    "content": "import type { Prisma, Security, SecurityPricing } from '@prisma/client'\nimport type { DateTime } from 'luxon'\n\nexport type { Security, SecurityPricing }\n\nexport type SecurityWithPricing = Security & {\n    pricing: SecurityPricing[]\n}\n\nexport type SecuritySymbolExchange = {\n    symbol: string\n    exchangeName: string\n}\n\nexport type SecurityDetails = {\n    day?: {\n        open?: Prisma.Decimal\n        prevClose?: Prisma.Decimal\n        high?: Prisma.Decimal\n        low?: Prisma.Decimal\n    }\n    year?: {\n        high?: Prisma.Decimal\n        low?: Prisma.Decimal\n        volume?: Prisma.Decimal\n        dividends?: Prisma.Decimal\n    }\n    marketCap?: Prisma.Decimal\n    peRatio?: Prisma.Decimal\n    expenseRatio?: Prisma.Decimal\n    eps?: Prisma.Decimal\n}\n\nexport type DailyPricing = {\n    date: DateTime\n    priceClose: Prisma.Decimal\n}\n"
  },
  {
    "path": "libs/shared/src/types/transaction-types.ts",
    "content": "import type {\n    Account,\n    AccountClassification,\n    AccountConnection,\n    AccountType,\n    Transaction,\n    TransactionType,\n    User,\n} from '@prisma/client'\n\nexport type { Transaction }\n\nexport type TransactionEnriched = Transaction & {\n    type: TransactionType\n    userId: User['id']\n    accountClassification: AccountClassification\n    accountType: AccountType\n}\n\nexport type TransactionWithAccountDetail = Transaction & {\n    account: Account & {\n        accountConnection: AccountConnection | null\n    }\n}\n\nexport type TransactionsResponse = {\n    transactions: TransactionWithAccountDetail[]\n    pageCount: number\n}\n"
  },
  {
    "path": "libs/shared/src/types/user-types.ts",
    "content": "import type {\n    AccountCategory,\n    AccountClassification,\n    Holding,\n    Prisma,\n    Security,\n    User as PrismaUser,\n    AuthUser,\n} from '@prisma/client'\nimport type { TimeSeries, TimeSeriesResponseWithDetail, Trend } from './general-types'\nimport type { DateTime } from 'luxon'\n\n/**\n * ================================================================\n * ======                      User                          ======\n * ================================================================\n */\n\nexport type User = Omit<PrismaUser, 'riskAnswers'> & { riskAnswers: RiskAnswer[] }\nexport type UpdateUser = Partial<\n    Prisma.UserUncheckedUpdateInput & {\n        monthlyDebtUser: number | null\n        monthlyIncomeUser: number | null\n        monthlyExpensesUser: number | null\n    }\n>\n\n/**\n * ================================================================\n * ======                 Auth User                          ======\n * ================================================================\n */\n\nexport type { AuthUser }\n\n/**\n * ================================================================\n * ======                   Net Worth                        ======\n * ================================================================\n */\nexport type NetWorthTimeSeriesData = {\n    date: string // yyyy-mm-dd\n    netWorth: Prisma.Decimal\n    assets: Prisma.Decimal\n    liabilities: Prisma.Decimal\n    categories: Partial<Record<AccountCategory, Prisma.Decimal>>\n}\n\nexport type NetWorthTimeSeriesResponse = TimeSeriesResponseWithDetail<\n    TimeSeries<NetWorthTimeSeriesData>\n>\n\n/**\n * ================================================================\n * ======                     Insights                       ======\n * ================================================================\n */\n\nexport type UserInsights = {\n    netWorthToday: Prisma.Decimal\n    netWorth: {\n        yearly: Trend\n        monthly: Trend\n        weekly: Trend\n    }\n    safetyNet: {\n        months: Prisma.Decimal\n        spending: Prisma.Decimal\n    }\n    debtIncome: {\n        ratio: Prisma.Decimal\n        debt: Prisma.Decimal\n        income: Prisma.Decimal\n        user: {\n            debt: Prisma.Decimal | null\n            income: Prisma.Decimal | null\n        }\n        calculated: {\n            debt: Prisma.Decimal\n            income: Prisma.Decimal\n        }\n    }\n    debtAsset: {\n        ratio: Prisma.Decimal\n        debt: Prisma.Decimal\n        asset: Prisma.Decimal\n    }\n    accountSummary: {\n        classification: AccountClassification\n        category: AccountCategory\n        balance: Prisma.Decimal\n        allocation: Prisma.Decimal\n    }[]\n    assetSummary: {\n        liquid: {\n            amount: Prisma.Decimal\n            percentage: Prisma.Decimal\n        }\n        illiquid: {\n            amount: Prisma.Decimal\n            percentage: Prisma.Decimal\n        }\n        yielding: {\n            amount: Prisma.Decimal\n            percentage: Prisma.Decimal\n        }\n    }\n    debtSummary: {\n        good: {\n            amount: Prisma.Decimal\n            percentage: Prisma.Decimal\n        }\n        bad: {\n            amount: Prisma.Decimal\n            percentage: Prisma.Decimal\n        }\n        total: {\n            amount: Prisma.Decimal\n            percentage: Prisma.Decimal\n        }\n    }\n    holdingBreakdown: {\n        category: 'stocks' | 'fixed_income' | 'cash' | 'crypto' | 'other'\n        value: Holding['value']\n        allocation: Prisma.Decimal\n        holdings: {\n            security: Pick<Security, 'id' | 'symbol' | 'name'>\n            value: Holding['value']\n            allocation: Prisma.Decimal\n        }[]\n    }[]\n    transactionSummary: {\n        income: Prisma.Decimal\n        expenses: Prisma.Decimal\n        payments: Prisma.Decimal\n    }\n    transactionBreakdown: {\n        category: string | null\n        amount: Prisma.Decimal\n        avg_6mo: Prisma.Decimal\n    }[]\n}\n\n/**\n * ================================================================\n * ======               User Profile/Account                 ======\n * ================================================================\n */\n\n// Arbitrary custom namespaces to avoid collision with Auth0 properties\nexport enum Auth0CustomNamespace {\n    Email = 'https://maybe.co/email',\n    Picture = 'https://maybe.co/picture',\n    Roles = 'https://maybe.co/roles',\n    UserMetadata = 'https://maybe.co/user-metadata',\n    AppMetadata = 'https://maybe.co/app-metadata',\n\n    // A convenience property (so we dont have to parse the Auth0 `identities` array every time)\n    PrimaryIdentity = 'https://maybe.co/primary-identity',\n}\n\n// Maybe's \"normalized\" Auth0 `user.user_metadata` object\nexport type MaybeUserMetadata = Partial<{\n    enrolled_mfa: boolean\n    hasDuplicateAccounts: boolean\n    shouldPromptUserAccountLink: boolean\n}>\n\n// Maybe's \"normalized\" Auth0 `user.app_metadata` object\nexport type MaybeAppMetadata = {}\n\nexport type PrimaryAuth0Identity = Partial<{\n    connection: string\n    provider: string\n    isSocial: boolean\n}>\n\n// Added to access and ID tokens via Auth0 rules\nexport type MaybeCustomClaims = {\n    [Auth0CustomNamespace.Email]?: string | null\n    [Auth0CustomNamespace.Picture]?: string | null\n    [Auth0CustomNamespace.UserMetadata]?: MaybeUserMetadata\n    [Auth0CustomNamespace.AppMetadata]?: MaybeAppMetadata\n    [Auth0CustomNamespace.PrimaryIdentity]?: PrimaryAuth0Identity\n}\n\nexport interface PasswordReset {\n    currentPassword: string\n    newPassword: string\n}\n\nexport type UserSubscription = {\n    subscribed: boolean\n    trialing: boolean\n    canceled: boolean\n\n    trialEnd: DateTime | null\n    cancelAt: DateTime | null\n\n    currentPeriodEnd: DateTime | null\n}\n\nexport type UserMemberCardDetails = {\n    memberNumber: number\n    name: string\n    title: string\n    joinDate: Date\n    maybe: string\n    cardUrl: string\n    imageUrl: string\n}\n\nexport type RiskQuestionChoice = {\n    key: string\n    text: string\n    riskScore: number\n}\n\nexport type RiskQuestion = {\n    key: string\n    text: string\n    choices: RiskQuestionChoice[]\n}\n\nexport type RiskAnswer = {\n    questionKey: string\n    choiceKey: string\n}\n\n/**\n * main - the fullscreen \"takeover\" every user must go through\n * sidebar - the post-onboarding sidebar for connecting accounts\n */\nexport type OnboardingFlow = 'main' | 'sidebar'\n\nexport type OnboardingStep = {\n    key: string\n    title: string\n    isComplete: boolean\n    isMarkedComplete: boolean\n    group?: string\n    ctaPath?: string\n}\n\nexport type OnboardingResponse = {\n    steps: OnboardingStep[]\n    currentStep: OnboardingStep | null\n    progress: {\n        completed: number\n        total: number\n        percent: number\n    }\n    isComplete: boolean\n    isMarkedComplete: boolean\n}\n"
  },
  {
    "path": "libs/shared/src/utils/account-utils.ts",
    "content": "import type { SharedType } from '..'\nimport type { AccountCategory, AccountClassification, AccountType } from '@prisma/client'\nimport groupBy from 'lodash/groupBy'\nimport keyBy from 'lodash/keyBy'\nimport mapValues from 'lodash/mapValues'\n\nexport const ACCOUNT_TYPES: AccountType[] = [\n    'CREDIT',\n    'DEPOSITORY',\n    'INVESTMENT',\n    'LOAN',\n    'OTHER_ASSET',\n    'OTHER_LIABILITY',\n    'PROPERTY',\n    'VEHICLE',\n]\n\nexport const CATEGORIES: Record<AccountCategory, SharedType.NormalizedCategory> = {\n    cash: {\n        value: 'cash',\n        singular: 'Cash',\n        plural: 'Cash',\n    },\n    credit: {\n        value: 'credit',\n        singular: 'Credit Card',\n        plural: 'Credit Cards',\n    },\n    crypto: {\n        value: 'crypto',\n        singular: 'Crypto',\n        plural: 'Crypto',\n    },\n    investment: {\n        value: 'investment',\n        singular: 'Investment',\n        plural: 'Investments',\n    },\n    loan: {\n        value: 'loan',\n        singular: 'Loan',\n        plural: 'Loans',\n    },\n    property: {\n        value: 'property',\n        singular: 'Real Estate',\n        plural: 'Real Estate',\n    },\n    valuable: {\n        value: 'valuable',\n        singular: 'Valuable',\n        plural: 'Valuables',\n    },\n    vehicle: {\n        value: 'vehicle',\n        singular: 'Vehicle',\n        plural: 'Vehicles',\n    },\n    other: {\n        value: 'other',\n        singular: 'Other',\n        plural: 'Other',\n    },\n}\n\nexport const LIABILITY_CATEGORIES = [CATEGORIES.loan, CATEGORIES.credit, CATEGORIES.other]\nexport const ASSET_CATEGORIES = [\n    CATEGORIES.cash,\n    CATEGORIES.investment,\n    CATEGORIES.property,\n    CATEGORIES.vehicle,\n    CATEGORIES.crypto,\n    CATEGORIES.valuable,\n    CATEGORIES.other,\n]\n\nexport const CATEGORY_MAP_SIMPLE: Record<AccountType, AccountCategory[]> = {\n    INVESTMENT: ['investment', 'cash', 'other'],\n    DEPOSITORY: ['cash', 'other'],\n    CREDIT: ['credit'],\n    LOAN: ['loan'],\n    PROPERTY: ['property'],\n    VEHICLE: ['vehicle'],\n    OTHER_ASSET: ['cash', 'investment', 'crypto', 'valuable', 'other'],\n    OTHER_LIABILITY: ['other'],\n}\n\nexport const CATEGORY_MAP = mapValues(keyBy(ACCOUNT_TYPES), (accountType) =>\n    CATEGORY_MAP_SIMPLE[accountType].map((category) => CATEGORIES[category])\n) as Record<AccountType, SharedType.NormalizedCategory[]>\n\n/**\n * Same logic used in the dbgenerated() classification column, used for cases\n * where the Account context is not available\n */\nexport function getClassification(type: AccountType): AccountClassification {\n    switch (type) {\n        case 'CREDIT':\n        case 'LOAN':\n        case 'OTHER_LIABILITY':\n            return 'liability'\n        default:\n            return 'asset'\n    }\n}\n\nexport function groupAccountsByCategory<TAccount extends Pick<SharedType.Account, 'category'>>(\n    accounts: TAccount[]\n) {\n    return Object.entries(groupBy(accounts, (a) => a.category)).map(([category, accounts]) => ({\n        category: CATEGORIES[category as AccountCategory].plural,\n        subtitle:\n            accounts.length === 1\n                ? `1 ${CATEGORIES[category as AccountCategory].singular}`\n                : `${accounts.length} ${CATEGORIES[category as AccountCategory].plural}`,\n        accounts,\n    }))\n}\n\n/**\n * Determines a user-friendly account type name based on the account's category\n * and subcategory\n */\nexport function getAccountTypeName(category: string, subcategory: string): string | null {\n    switch (category) {\n        case 'cash':\n            switch (subcategory) {\n                case 'cd':\n                    return 'CD account'\n                case 'ebt':\n                    return 'EBT account'\n                case 'hsa':\n                    return 'HSA'\n                case 'prepaid':\n                    return 'prepaid debit card'\n            }\n\n            return `${subcategory} account`\n\n        case 'investment':\n            switch (subcategory) {\n                case 'hsa':\n                case 'ira':\n                case 'isa':\n                    return subcategory.toUpperCase()\n                case '401k':\n                    return '401(k) account'\n                case 'roth':\n                    return 'Roth IRA'\n                case 'roth 401k':\n                    return 'Roth 401(k)'\n                case 'brokerage':\n                case 'pension':\n                case 'retirement':\n                    return `${subcategory} account`\n            }\n\n            return 'investment account'\n\n        case 'loan':\n            switch (subcategory) {\n                case 'line of credit':\n                    return 'line of credit'\n                case 'home equity':\n                    return 'home equity line of credit'\n                case 'other':\n                    return 'loan'\n            }\n            return subcategory ?? 'loan'\n\n        case 'credit':\n            return 'credit card'\n    }\n\n    return null\n}\n\nexport const flattenAccounts = (\n    data?: SharedType.AccountsResponse\n): Array<SharedType.AccountWithConnection> => {\n    if (!data) return []\n\n    const { accounts, connections } = data\n\n    const manual = accounts ?? []\n    const connected = (connections ?? [])\n        .flatMap((c) => c.accounts)\n        .map((account) => {\n            const cnx = connections.find((c) => c.accounts.some((a) => a.id === account.id))\n            return { ...account, accountConnection: cnx }\n        })\n\n    return [...manual, ...connected]\n}\n"
  },
  {
    "path": "libs/shared/src/utils/date-utils.spec.ts",
    "content": "import { DateTime } from 'luxon'\nimport { ageToYear, calculateTimeSeriesInterval, dobToAge, yearToAge } from './date-utils'\n\ndescribe('calculateTimeSeriesInterval', () => {\n    it.each`\n        duration         | chunks | interval\n        ${{ weeks: 1 }}  | ${150} | ${'days'}\n        ${{ weeks: 1 }}  | ${250} | ${'days'}\n        ${{ months: 1 }} | ${150} | ${'days'}\n        ${{ months: 1 }} | ${250} | ${'days'}\n        ${{ months: 3 }} | ${150} | ${'days'}\n        ${{ months: 3 }} | ${250} | ${'days'}\n        ${{ months: 6 }} | ${150} | ${'days'}\n        ${{ months: 6 }} | ${250} | ${'days'}\n        ${{ years: 1 }}  | ${150} | ${'days'}\n        ${{ years: 1 }}  | ${250} | ${'days'}\n        ${{ years: 2 }}  | ${150} | ${'weeks'}\n        ${{ years: 2 }}  | ${250} | ${'days'}\n        ${{ years: 3 }}  | ${150} | ${'weeks'}\n        ${{ years: 3 }}  | ${250} | ${'weeks'}\n        ${{ years: 5 }}  | ${150} | ${'weeks'}\n        ${{ years: 5 }}  | ${250} | ${'weeks'}\n        ${{ years: 10 }} | ${150} | ${'months'}\n        ${{ years: 10 }} | ${250} | ${'weeks'}\n        ${{ years: 20 }} | ${150} | ${'months'}\n        ${{ years: 20 }} | ${250} | ${'months'}\n    `(\n        `should calculate properly for duration: $duration chunks: $chunks`,\n        ({ duration, chunks, interval }) => {\n            const d = DateTime.now()\n            expect(calculateTimeSeriesInterval(d, d.plus(duration), chunks)).toBe(interval)\n        }\n    )\n})\n\ndescribe('converts between years and ages', () => {\n    it.each`\n        dateOfBirth                                        | currentAge\n        ${'1995-02-20'}                                    | ${27}\n        ${new Date('Feb 20 1995')}                         | ${27}\n        ${DateTime.fromISO('1995-02-20', { zone: 'utc' })} | ${27}\n        ${null}                                            | ${null}\n        ${undefined}                                       | ${null}\n        ${'2022-10-15'}                                    | ${0}\n        ${'2021-10-12'}                                    | ${1}\n        ${'2021-10-18'}                                    | ${0}\n    `(`dob $dateOfBirth is $currentAge years old today`, ({ dateOfBirth, currentAge }) => {\n        const now = DateTime.fromISO('2022-10-15', { zone: 'utc' })\n\n        expect(dobToAge(dateOfBirth, now)).toBe(currentAge)\n    })\n\n    it.each`\n        age   | year\n        ${30} | ${2027}\n        ${20} | ${2017}\n    `(`at age $currentAge the year will be $year`, ({ age, year }) => {\n        const currentAge = 25\n\n        expect(ageToYear(age, currentAge, 2022)).toBe(year)\n    })\n\n    it.each`\n        year    | age\n        ${2027} | ${30}\n        ${2017} | ${20}\n    `(`at year $year the age will be $age`, ({ age, year }) => {\n        const currentAge = 25\n\n        expect(yearToAge(year, currentAge, 2022)).toBe(age)\n    })\n})\n"
  },
  {
    "path": "libs/shared/src/utils/date-utils.ts",
    "content": "import type { SharedType } from '..'\nimport type { NiceTime } from '@visx/scale'\nimport type { TimeSeriesInterval } from '../types'\nimport { DateTime } from 'luxon'\nimport range from 'lodash/range'\n\nexport function generateDailySeries(start: string, end: string, zone = 'utc'): string[] {\n    const s = DateTime.fromISO(start, { zone })\n    const e = DateTime.fromISO(end, { zone })\n    const daysBetween = Math.abs(s.diff(e, 'days').days)\n\n    return range(0, daysBetween + 1, 1).map((idx) => s.plus({ days: idx }).toFormat('yyyy-MM-dd'))\n}\n\nexport function isToday(date: Date | string | null | undefined, today = DateTime.utc()): boolean {\n    if (!date) return false\n    return isSameDate(datetimeTransform(date), today)\n}\n\nexport function isSameDate(date: DateTime, as: DateTime): boolean {\n    return date.toUTC().toISODate() === as.toUTC().toISODate()\n}\n\nexport function datetimeTransform(val: Date | string): DateTime\nexport function datetimeTransform(val: Date | string | null): DateTime | null\nexport function datetimeTransform(val: Date | string | undefined): DateTime | undefined\nexport function datetimeTransform(\n    val: Date | string | null | undefined\n): DateTime | null | undefined {\n    if (val === undefined) return undefined\n    if (val === null) return null\n    const dt =\n        typeof val === 'string'\n            ? DateTime.fromISO(val, { zone: 'utc' })\n            : DateTime.fromJSDate(val, { zone: 'utc' })\n    if (!dt.isValid) throw new Error(`invalid datetime: ${val}`)\n    return dt\n}\n\n// Validates ISO string date and returns ISO string\nexport function dateTransform(val: Date | string): string\nexport function dateTransform(val: Date | string | null): string | null\nexport function dateTransform(val: Date | string | undefined): string | undefined\nexport function dateTransform(val: Date | string | null | undefined): string | null | undefined {\n    if (val === undefined) return undefined\n    if (val === null) return null\n    const d =\n        typeof val === 'string'\n            ? DateTime.fromISO(val, { zone: 'utc' })\n            : DateTime.fromJSDate(val, { zone: 'utc' })\n    if (!d.isValid) throw new Error(`invalid ISO8601 date: ${val}`)\n    return d.toISODate()\n}\n\nexport function strToDate(val: string, zone = 'utc'): Date {\n    return DateTime.fromISO(val, { zone }).toJSDate()\n}\n\nexport function dateToStr(val: Date, zone = 'utc'): string {\n    return DateTime.fromJSDate(val, { zone }).toISODate()\n}\n\nexport function calculateTimeSeriesInterval(\n    start: string | DateTime,\n    end: string | DateTime,\n    desiredChunks = 150\n): SharedType.TimeSeriesInterval {\n    const INTERVALS: [SharedType.TimeSeriesInterval, number][] = [\n        ['days', 1],\n        ['weeks', 7],\n        ['months', 30],\n        ['quarters', 91],\n        ['years', 365],\n    ]\n\n    const startDate = typeof start === 'string' ? DateTime.fromISO(start) : start\n    const endDate = typeof end === 'string' ? DateTime.fromISO(end) : end\n    const diff = endDate.diff(startDate, 'days')\n\n    // determine exact optimal interval and then find the closest actual interval\n    const goal = Math.abs(diff.days) / desiredChunks\n    const closestInterval = INTERVALS.reduce((best, curr) => {\n        return Math.abs(curr[1] - goal) < Math.abs(best[1] - goal) ? curr : best\n    })\n\n    return closestInterval[0]\n}\n\n/**\n * Temporary mapping to avoid full refactor of all time-series values\n * @todo - update `TimeSeriesInterval` to have same types as `NiceTime`\n */\nexport function toD3Interval(interval: TimeSeriesInterval): NiceTime {\n    switch (interval) {\n        case 'days':\n            return 'day'\n        case 'weeks':\n            return 'week'\n        case 'months':\n            return 'month'\n        case 'quarters':\n            return 'month' // no quarterly value available\n        case 'years':\n            return 'year'\n        default:\n            return 'day'\n    }\n}\n\n/**\n * Converts a calendar year to an age based on the current age\n */\nexport function yearToAge(year: number, currentAge = 30, currentYear = DateTime.now().year) {\n    return year - currentYear + currentAge\n}\n\n/**\n * Converts an age to a calendar year based on the current age\n */\nexport function ageToYear(age: number, currentAge = 30, currentYear = DateTime.now().year) {\n    return age - currentAge + currentYear\n}\n\n/** Calculates an age from a DOB in ISO string format */\nexport function dobToAge(dob: string | Date | DateTime | null | undefined, now = DateTime.now()) {\n    if (!dob) return null\n\n    const normalizedDate =\n        typeof dob === 'string'\n            ? DateTime.fromISO(dob, { zone: 'utc' })\n            : dob instanceof Date\n            ? DateTime.fromJSDate(dob, { zone: 'utc' })\n            : dob\n\n    return Math.floor(now.diff(normalizedDate, 'years').years)\n}\n\n// We allow a maximum of 30 years of history for performance reasons (hypertable chunking)\nexport const MIN_SUPPORTED_DATE = DateTime.now().minus({ years: 30 })\nexport const MAX_SUPPORTED_DATE = DateTime.now()\n"
  },
  {
    "path": "libs/shared/src/utils/geo-utils.ts",
    "content": "export const countries = [\n    { name: 'Afghanistan', code: 'AF' },\n    { name: 'Åland Islands', code: 'AX' },\n    { name: 'Albania', code: 'AL' },\n    { name: 'Algeria', code: 'DZ' },\n    { name: 'American Samoa', code: 'AS' },\n    { name: 'Andorra', code: 'AD' },\n    { name: 'Angola', code: 'AO' },\n    { name: 'Anguilla', code: 'AI' },\n    { name: 'Antarctica', code: 'AQ' },\n    { name: 'Antigua and Barbuda', code: 'AG' },\n    { name: 'Argentina', code: 'AR' },\n    { name: 'Armenia', code: 'AM' },\n    { name: 'Aruba', code: 'AW' },\n    { name: 'Australia', code: 'AU' },\n    { name: 'Austria', code: 'AT' },\n    { name: 'Azerbaijan', code: 'AZ' },\n    { name: 'Bahamas', code: 'BS' },\n    { name: 'Bahrain', code: 'BH' },\n    { name: 'Bangladesh', code: 'BD' },\n    { name: 'Barbados', code: 'BB' },\n    { name: 'Belarus', code: 'BY' },\n    { name: 'Belgium', code: 'BE' },\n    { name: 'Belize', code: 'BZ' },\n    { name: 'Benin', code: 'BJ' },\n    { name: 'Bermuda', code: 'BM' },\n    { name: 'Bhutan', code: 'BT' },\n    { name: 'Bolivia (Plurinational State of)', code: 'BO' },\n    { name: 'Bonaire, Sint Eustatius and Saba', code: 'BQ' },\n    { name: 'Bosnia and Herzegovina', code: 'BA' },\n    { name: 'Botswana', code: 'BW' },\n    { name: 'Bouvet Island', code: 'BV' },\n    { name: 'Brazil', code: 'BR' },\n    { name: 'British Indian Ocean Territory', code: 'IO' },\n    { name: 'Brunei Darussalam', code: 'BN' },\n    { name: 'Bulgaria', code: 'BG' },\n    { name: 'Burkina Faso', code: 'BF' },\n    { name: 'Burundi', code: 'BI' },\n    { name: 'Cabo Verde', code: 'CV' },\n    { name: 'Cambodia', code: 'KH' },\n    { name: 'Cameroon', code: 'CM' },\n    { name: 'Canada', code: 'CA' },\n    { name: 'Cayman Islands', code: 'KY' },\n    { name: 'Central African Republic', code: 'CF' },\n    { name: 'Chad', code: 'TD' },\n    { name: 'Chile', code: 'CL' },\n    { name: 'China', code: 'CN' },\n    { name: 'Christmas Island', code: 'CX' },\n    { name: 'Cocos (Keeling) Islands', code: 'CC' },\n    { name: 'Colombia', code: 'CO' },\n    { name: 'Comoros', code: 'KM' },\n    { name: 'Congo', code: 'CG' },\n    { name: 'Congo, Democratic Republic of the', code: 'CD' },\n    { name: 'Cook Islands', code: 'CK' },\n    { name: 'Costa Rica', code: 'CR' },\n    { name: \"Côte d'Ivoire\", code: 'CI' },\n    { name: 'Croatia', code: 'HR' },\n    { name: 'Cuba', code: 'CU' },\n    { name: 'Curaçao', code: 'CW' },\n    { name: 'Cyprus', code: 'CY' },\n    { name: 'Czechia', code: 'CZ' },\n    { name: 'Denmark', code: 'DK' },\n    { name: 'Djibouti', code: 'DJ' },\n    { name: 'Dominica', code: 'DM' },\n    { name: 'Dominican Republic', code: 'DO' },\n    { name: 'Ecuador', code: 'EC' },\n    { name: 'Egypt', code: 'EG' },\n    { name: 'El Salvador', code: 'SV' },\n    { name: 'Equatorial Guinea', code: 'GQ' },\n    { name: 'Eritrea', code: 'ER' },\n    { name: 'Estonia', code: 'EE' },\n    { name: 'Eswatini', code: 'SZ' },\n    { name: 'Ethiopia', code: 'ET' },\n    { name: 'Falkland Islands (Malvinas)', code: 'FK' },\n    { name: 'Faroe Islands', code: 'FO' },\n    { name: 'Fiji', code: 'FJ' },\n    { name: 'Finland', code: 'FI' },\n    { name: 'France', code: 'FR' },\n    { name: 'French Guiana', code: 'GF' },\n    { name: 'French Polynesia', code: 'PF' },\n    { name: 'French Southern Territories', code: 'TF' },\n    { name: 'Gabon', code: 'GA' },\n    { name: 'Gambia', code: 'GM' },\n    { name: 'Georgia', code: 'GE' },\n    { name: 'Germany', code: 'DE' },\n    { name: 'Ghana', code: 'GH' },\n    { name: 'Gibraltar', code: 'GI' },\n    { name: 'Greece', code: 'GR' },\n    { name: 'Greenland', code: 'GL' },\n    { name: 'Grenada', code: 'GD' },\n    { name: 'Guadeloupe', code: 'GP' },\n    { name: 'Guam', code: 'GU' },\n    { name: 'Guatemala', code: 'GT' },\n    { name: 'Guernsey', code: 'GG' },\n    { name: 'Guinea', code: 'GN' },\n    { name: 'Guinea-Bissau', code: 'GW' },\n    { name: 'Guyana', code: 'GY' },\n    { name: 'Haiti', code: 'HT' },\n    { name: 'Heard Island and McDonald Islands', code: 'HM' },\n    { name: 'Holy See', code: 'VA' },\n    { name: 'Honduras', code: 'HN' },\n    { name: 'Hong Kong', code: 'HK' },\n    { name: 'Hungary', code: 'HU' },\n    { name: 'Iceland', code: 'IS' },\n    { name: 'India', code: 'IN' },\n    { name: 'Indonesia', code: 'ID' },\n    { name: 'Iran (Islamic Republic of)', code: 'IR' },\n    { name: 'Iraq', code: 'IQ' },\n    { name: 'Ireland', code: 'IE' },\n    { name: 'Isle of Man', code: 'IM' },\n    { name: 'Israel', code: 'IL' },\n    { name: 'Italy', code: 'IT' },\n    { name: 'Jamaica', code: 'JM' },\n    { name: 'Japan', code: 'JP' },\n    { name: 'Jersey', code: 'JE' },\n    { name: 'Jordan', code: 'JO' },\n    { name: 'Kazakhstan', code: 'KZ' },\n    { name: 'Kenya', code: 'KE' },\n    { name: 'Kiribati', code: 'KI' },\n    { name: \"Korea (Democratic People's Republic of)\", code: 'KP' },\n    { name: 'Korea, Republic of', code: 'KR' },\n    { name: 'Kuwait', code: 'KW' },\n    { name: 'Kyrgyzstan', code: 'KG' },\n    { name: \"Lao People's Democratic Republic\", code: 'LA' },\n    { name: 'Latvia', code: 'LV' },\n    { name: 'Lebanon', code: 'LB' },\n    { name: 'Lesotho', code: 'LS' },\n    { name: 'Liberia', code: 'LR' },\n    { name: 'Libya', code: 'LY' },\n    { name: 'Liechtenstein', code: 'LI' },\n    { name: 'Lithuania', code: 'LT' },\n    { name: 'Luxembourg', code: 'LU' },\n    { name: 'Macao', code: 'MO' },\n    { name: 'Madagascar', code: 'MG' },\n    { name: 'Malawi', code: 'MW' },\n    { name: 'Malaysia', code: 'MY' },\n    { name: 'Maldives', code: 'MV' },\n    { name: 'Mali', code: 'ML' },\n    { name: 'Malta', code: 'MT' },\n    { name: 'Marshall Islands', code: 'MH' },\n    { name: 'Martinique', code: 'MQ' },\n    { name: 'Mauritania', code: 'MR' },\n    { name: 'Mauritius', code: 'MU' },\n    { name: 'Mayotte', code: 'YT' },\n    { name: 'Mexico', code: 'MX' },\n    { name: 'Micronesia (Federated States of)', code: 'FM' },\n    { name: 'Moldova, Republic of', code: 'MD' },\n    { name: 'Monaco', code: 'MC' },\n    { name: 'Mongolia', code: 'MN' },\n    { name: 'Montenegro', code: 'ME' },\n    { name: 'Montserrat', code: 'MS' },\n    { name: 'Morocco', code: 'MA' },\n    { name: 'Mozambique', code: 'MZ' },\n    { name: 'Myanmar', code: 'MM' },\n    { name: 'Namibia', code: 'NA' },\n    { name: 'Nauru', code: 'NR' },\n    { name: 'Nepal', code: 'NP' },\n    { name: 'Netherlands', code: 'NL' },\n    { name: 'New Caledonia', code: 'NC' },\n    { name: 'New Zealand', code: 'NZ' },\n    { name: 'Nicaragua', code: 'NI' },\n    { name: 'Niger', code: 'NE' },\n    { name: 'Nigeria', code: 'NG' },\n    { name: 'Niue', code: 'NU' },\n    { name: 'Norfolk Island', code: 'NF' },\n    { name: 'North Macedonia', code: 'MK' },\n    { name: 'Northern Mariana Islands', code: 'MP' },\n    { name: 'Norway', code: 'NO' },\n    { name: 'Oman', code: 'OM' },\n    { name: 'Pakistan', code: 'PK' },\n    { name: 'Palau', code: 'PW' },\n    { name: 'Palestine, State of', code: 'PS' },\n    { name: 'Panama', code: 'PA' },\n    { name: 'Papua New Guinea', code: 'PG' },\n    { name: 'Paraguay', code: 'PY' },\n    { name: 'Peru', code: 'PE' },\n    { name: 'Philippines', code: 'PH' },\n    { name: 'Pitcairn', code: 'PN' },\n    { name: 'Poland', code: 'PL' },\n    { name: 'Portugal', code: 'PT' },\n    { name: 'Puerto Rico', code: 'PR' },\n    { name: 'Qatar', code: 'QA' },\n    { name: 'Réunion', code: 'RE' },\n    { name: 'Romania', code: 'RO' },\n    { name: 'Russian Federation', code: 'RU' },\n    { name: 'Rwanda', code: 'RW' },\n    { name: 'Saint Barthélemy', code: 'BL' },\n    { name: 'Saint Helena, Ascension and Tristan da Cunha', code: 'SH' },\n    { name: 'Saint Kitts and Nevis', code: 'KN' },\n    { name: 'Saint Lucia', code: 'LC' },\n    { name: 'Saint Martin (French part)', code: 'MF' },\n    { name: 'Saint Pierre and Miquelon', code: 'PM' },\n    { name: 'Saint Vincent and the Grenadines', code: 'VC' },\n    { name: 'Samoa', code: 'WS' },\n    { name: 'San Marino', code: 'SM' },\n    { name: 'Sao Tome and Principe', code: 'ST' },\n    { name: 'Saudi Arabia', code: 'SA' },\n    { name: 'Senegal', code: 'SN' },\n    { name: 'Serbia', code: 'RS' },\n    { name: 'Seychelles', code: 'SC' },\n    { name: 'Sierra Leone', code: 'SL' },\n    { name: 'Singapore', code: 'SG' },\n    { name: 'Sint Maarten (Dutch part)', code: 'SX' },\n    { name: 'Slovakia', code: 'SK' },\n    { name: 'Slovenia', code: 'SI' },\n    { name: 'Solomon Islands', code: 'SB' },\n    { name: 'Somalia', code: 'SO' },\n    { name: 'South Africa', code: 'ZA' },\n    { name: 'South Georgia and the South Sandwich Islands', code: 'GS' },\n    { name: 'South Sudan', code: 'SS' },\n    { name: 'Spain', code: 'ES' },\n    { name: 'Sri Lanka', code: 'LK' },\n    { name: 'Sudan', code: 'SD' },\n    { name: 'Suriname', code: 'SR' },\n    { name: 'Svalbard and Jan Mayen', code: 'SJ' },\n    { name: 'Sweden', code: 'SE' },\n    { name: 'Switzerland', code: 'CH' },\n    { name: 'Syrian Arab Republic', code: 'SY' },\n    { name: 'Taiwan, Province of China', code: 'TW' },\n    { name: 'Tajikistan', code: 'TJ' },\n    { name: 'Tanzania, United Republic of', code: 'TZ' },\n    { name: 'Thailand', code: 'TH' },\n    { name: 'Timor-Leste', code: 'TL' },\n    { name: 'Togo', code: 'TG' },\n    { name: 'Tokelau', code: 'TK' },\n    { name: 'Tonga', code: 'TO' },\n    { name: 'Trinidad and Tobago', code: 'TT' },\n    { name: 'Tunisia', code: 'TN' },\n    { name: 'Turkey', code: 'TR' },\n    { name: 'Turkmenistan', code: 'TM' },\n    { name: 'Turks and Caicos Islands', code: 'TC' },\n    { name: 'Tuvalu', code: 'TV' },\n    { name: 'Uganda', code: 'UG' },\n    { name: 'Ukraine', code: 'UA' },\n    { name: 'United Arab Emirates', code: 'AE' },\n    { name: 'United Kingdom of Great Britain and Northern Ireland', code: 'GB' },\n    { name: 'United States', code: 'US' },\n    { name: 'United States Minor Outlying Islands', code: 'UM' },\n    { name: 'Uruguay', code: 'UY' },\n    { name: 'Uzbekistan', code: 'UZ' },\n    { name: 'Vanuatu', code: 'VU' },\n    { name: 'Venezuela (Bolivarian Republic of)', code: 'VE' },\n    { name: 'Viet Nam', code: 'VN' },\n    { name: 'Virgin Islands (British)', code: 'VG' },\n    { name: 'Virgin Islands (U.S.)', code: 'VI' },\n    { name: 'Wallis and Futuna', code: 'WF' },\n    { name: 'Western Sahara', code: 'EH' },\n    { name: 'Yemen', code: 'YE' },\n    { name: 'Zambia', code: 'ZM' },\n    { name: 'Zimbabwe', code: 'ZW' },\n]\n\nexport const states = [\n    { name: 'Alabama', code: 'AL' },\n    { name: 'Alaska', code: 'AK' },\n    { name: 'Arizona', code: 'AZ' },\n    { name: 'Arkansas', code: 'AR' },\n    { name: 'California', code: 'CA' },\n    { name: 'Colorado', code: 'CO' },\n    { name: 'Connecticut', code: 'CT' },\n    { name: 'Delaware', code: 'DE' },\n    { name: 'District of Columbia', code: 'DC' },\n    { name: 'Florida', code: 'FL' },\n    { name: 'Georgia', code: 'GA' },\n    { name: 'Hawaii', code: 'HI' },\n    { name: 'Idaho', code: 'ID' },\n    { name: 'Illinois', code: 'IL' },\n    { name: 'Indiana', code: 'IN' },\n    { name: 'Iowa', code: 'IA' },\n    { name: 'Kansas', code: 'KS' },\n    { name: 'Kentucky', code: 'KY' },\n    { name: 'Louisiana', code: 'LA' },\n    { name: 'Maine', code: 'ME' },\n    { name: 'Maryland', code: 'MD' },\n    { name: 'Massachusetts', code: 'MA' },\n    { name: 'Michigan', code: 'MI' },\n    { name: 'Minnesota', code: 'MN' },\n    { name: 'Mississippi', code: 'MS' },\n    { name: 'Missouri', code: 'MO' },\n    { name: 'Montana', code: 'MT' },\n    { name: 'Nebraska', code: 'NE' },\n    { name: 'Nevada', code: 'NV' },\n    { name: 'New Hampshire', code: 'NH' },\n    { name: 'New Jersey', code: 'NJ' },\n    { name: 'New Mexico', code: 'NM' },\n    { name: 'New York', code: 'NY' },\n    { name: 'North Carolina', code: 'NC' },\n    { name: 'North Dakota', code: 'ND' },\n    { name: 'Ohio', code: 'OH' },\n    { name: 'Oklahoma', code: 'OK' },\n    { name: 'Oregon', code: 'OR' },\n    { name: 'Pennsylvania', code: 'PA' },\n    { name: 'Rhode Island', code: 'RI' },\n    { name: 'South Carolina', code: 'SC' },\n    { name: 'South Dakota', code: 'SD' },\n    { name: 'Tennessee', code: 'TN' },\n    { name: 'Texas', code: 'TX' },\n    { name: 'Utah', code: 'UT' },\n    { name: 'Vermont', code: 'VT' },\n    { name: 'Virginia', code: 'VA' },\n    { name: 'Washington', code: 'WA' },\n    { name: 'West Virginia', code: 'WV' },\n    { name: 'Wisconsin', code: 'WI' },\n    { name: 'Wyoming', code: 'WY' },\n]\n"
  },
  {
    "path": "libs/shared/src/utils/index.ts",
    "content": "export * as DateUtil from './date-utils'\nexport * as NumberUtil from './number-utils'\nexport * as SharedUtil from './shared-utils'\nexport * as TestUtil from './test-utils'\nexport * as AccountUtil from './account-utils'\nexport * as TransactionUtil from './transaction-utils'\nexport * as MarketUtil from './market-utils'\nexport * as PlanUtil from './plan-utils'\nexport * as StatsUtil from './stats-utils'\nexport * as UserUtil from './user-utils'\nexport * as Geo from './geo-utils'\n"
  },
  {
    "path": "libs/shared/src/utils/market-utils.ts",
    "content": "// Option ticker reference: https://www.investopedia.com/ask/answers/05/052505.asp\n\nexport function isOptionTicker(ticker: string): boolean {\n    return ticker.length >= 16\n}\n\nexport function getUnderlyingTicker(ticker: string): string | null {\n    return ticker.length >= 16 ? ticker.slice(0, -15).trim() : null\n}\n"
  },
  {
    "path": "libs/shared/src/utils/number-utils.spec.ts",
    "content": "import { calculatePercentChange, format } from './number-utils'\n\ndescribe('number-utils', () => {\n    describe('calculatePercentChange', () => {\n        test.each([\n            [0, 100, Infinity],\n            [0, -100, -Infinity],\n            [0, 0, 0],\n            [100, 100, 0],\n            [-100, -100, 0],\n            [100, 200, 1],\n            [200, 100, -0.5],\n            [null, 100, NaN],\n            [100, null, NaN],\n            [null, null, NaN],\n            [NaN, 100, NaN],\n            [100, NaN, NaN],\n            [NaN, NaN, NaN],\n        ])('(%i, %i) === %s', (from, to, percentage) => {\n            expect(calculatePercentChange(from, to)).toStrictEqual(percentage)\n        })\n    })\n\n    describe('currency formatting edge cases', () => {\n        test.each([\n            [NaN, '--'],\n            [Infinity, `∞`],\n            [-Infinity, `-∞`],\n            [undefined, '--'],\n            [null, '--'],\n            ['invalid number string', '--'],\n        ])('input=%s output=%s', (value, expectedValue) => {\n            expect(format(value, 'currency')).toStrictEqual(expectedValue)\n        })\n    })\n\n    describe('percentage formatting edge cases', () => {\n        test.each([\n            [NaN, '--'],\n            [Infinity, `∞%`],\n            [-Infinity, `-∞%`],\n            [undefined, '--'],\n            [null, '--'],\n            ['invalid number string', '--'],\n        ])('input=%s output=%s', (value, expectedValue) => {\n            expect(format(value, 'percent')).toStrictEqual(expectedValue)\n        })\n    })\n\n    describe('currency format', () => {\n        test.each([\n            ['20', '$20.00', undefined],\n            [20, '$20.00', undefined],\n            [20.45, '$20.45', undefined],\n            [20, '$20.00', undefined],\n            [2000, '$2,000.00', undefined],\n            [2000, '$2,000', { minimumFractionDigits: 0 }],\n            [2000.2, '$2,000.2', { minimumFractionDigits: 0 }],\n            [2000.25, '$2,000.25', { minimumFractionDigits: 0 }],\n            [2000.25, '$2,000', { minimumFractionDigits: 0, maximumFractionDigits: 0 }],\n        ])('input=%s output=%s', (value, expectedValue, options) => {\n            expect(format(value, 'currency', options)).toStrictEqual(expectedValue)\n        })\n    })\n\n    describe('short currency format', () => {\n        test.each([\n            ['20', '$20.00', undefined],\n            [20, '$20.00', undefined],\n            [20.2, '$20.20', { minimumFractionDigits: 2, maximumFractionDigits: 2 }],\n            [20, '$20.0', { minimumFractionDigits: 1, maximumFractionDigits: 2 }],\n            [20.2, '$20.20', undefined],\n            [20000, '$20k', undefined],\n            [-20000, '-$20k', undefined],\n            [-28000, '-$28k', undefined],\n            [-28000, '-$28k', { minimumFractionDigits: 2, maximumFractionDigits: 2 }],\n            [-28500, '-$28.5k', { minimumFractionDigits: 2, maximumFractionDigits: 2 }],\n            [-28000, '-$28k', { minimumFractionDigits: 1, maximumFractionDigits: 2 }],\n            [-28500, '-$28.5k', { minimumFractionDigits: 1, maximumFractionDigits: 2 }],\n            [-28550, '-$28.55k', { minimumFractionDigits: 1, maximumFractionDigits: 2 }],\n            [-28550, '-$28.6k', { minimumFractionDigits: 1, maximumFractionDigits: 1 }],\n            [-28550, '-$29k', { minimumFractionDigits: 0, maximumFractionDigits: 0 }],\n            [2_000_000, '$2m', undefined],\n            [2_000_000, '$2m', undefined],\n            [2_000_000_000, '$2b', undefined],\n            [2_500_000_000, '$2.5b', undefined],\n            [2_540_000_000, '$2.54b', undefined],\n            [2_560_000_000, '$2.56b', undefined],\n            [2_560_000_000, '$2.56b', { minimumFractionDigits: 2, maximumFractionDigits: 2 }],\n            [2_500_000_000, '$2.5b', { minimumFractionDigits: 2, maximumFractionDigits: 2 }],\n            [0, '$0.00', undefined],\n        ])('input=%s output=%s', (value, expectedValue, options) => {\n            expect(format(value, 'short-currency', options)).toStrictEqual(expectedValue)\n        })\n    })\n\n    describe('percent format', () => {\n        test.each([\n            ['0.02', '+2%', undefined],\n            [0.02, '+2%', undefined],\n            ['-0.02', '-2%', undefined],\n            [-0.02, '-2%', undefined],\n            [20, '+2,000%', undefined],\n            [20000, '+2,000,000%', undefined],\n            [-20000, '-2,000,000%', undefined],\n            [0, '0%', undefined],\n        ])('input=%s output=%s', (value, expectedValue, options) => {\n            expect(format(value, 'percent', options)).toStrictEqual(expectedValue)\n        })\n    })\n})\n"
  },
  {
    "path": "libs/shared/src/utils/number-utils.ts",
    "content": "import type { Decimal, FormatString } from '../types'\nimport DecimalJS from 'decimal.js'\n\nexport function calculatePercentChange(_from: Decimal | null, _to: Decimal | null): Decimal\nexport function calculatePercentChange(_from: number | null, _to: number | null): number\nexport function calculatePercentChange(\n    _from: Decimal | number | null,\n    _to: Decimal | number | null\n): Decimal | number {\n    const isDecimal = DecimalJS.isDecimal(_from) || DecimalJS.isDecimal(_to)\n\n    if (_from == null || _to == null) return isDecimal ? new DecimalJS(NaN) : NaN\n\n    const from = new DecimalJS(_from.toString())\n    const to = new DecimalJS(_to.toString())\n\n    const diff = to.minus(from)\n\n    const pctChange = diff.isZero() ? new DecimalJS(0) : diff.dividedBy(from.abs())\n\n    return isDecimal ? pctChange : pctChange.toNumber()\n}\n\nexport function format(\n    value: Decimal | number | string | undefined | null,\n    format: FormatString,\n    options?: Intl.NumberFormatOptions\n): string {\n    if (value == null) return '--'\n    const _value = +value\n\n    // Catches anything that's not a valid number\n    if (!Number.isFinite(_value)) {\n        switch (_value) {\n            case Infinity:\n                return format === 'percent' ? `∞%` : `∞`\n            case -Infinity:\n                return format === 'percent' ? `-∞%` : `-∞`\n            default:\n                return '--'\n        }\n    }\n\n    const defaultCurrencyOptions: Intl.NumberFormatOptions = {\n        style: 'currency',\n        currency: 'USD',\n        minimumFractionDigits: 2, // defaults to $X.XX\n        maximumFractionDigits: 2,\n    }\n\n    const defaultDecimalOptions: Intl.NumberFormatOptions = {\n        style: 'decimal',\n        currency: 'USD',\n        minimumFractionDigits: 0,\n        maximumFractionDigits: 2,\n    }\n\n    if (format === 'currency') {\n        return _value.toLocaleString('en-US', { ...defaultCurrencyOptions, ...options })\n    }\n\n    if (format === 'percent') {\n        const defaultPercentageOptions: Intl.NumberFormatOptions = {\n            style: 'percent',\n            signDisplay: 'exceptZero',\n            minimumFractionDigits: 0,\n            maximumFractionDigits: 1,\n        }\n\n        return _value.toLocaleString('en-US', { ...defaultPercentageOptions, ...options })\n    }\n\n    if (format === 'decimal') {\n        return _value.toLocaleString('en-US', { ...defaultDecimalOptions, ...options })\n    }\n\n    const shortUnits = [\n        { value: 1e12, symbol: 't' },\n        { value: 1e9, symbol: 'b' },\n        { value: 1e6, symbol: 'm' },\n        { value: 1e3, symbol: 'k' },\n        { value: 1, symbol: '' },\n    ]\n\n    // This should always be the last case because regexp are expensive computations\n    if (['short-currency', 'short-decimal'].includes(format)) {\n        const defaultOptions =\n            format === 'short-currency' ? defaultCurrencyOptions : defaultDecimalOptions\n\n        const item = shortUnits.find(function (item) {\n            return Math.abs(_value) >= item.value\n        })\n\n        if (!item) {\n            return _value.toLocaleString('en-US', { ...defaultOptions, ...options })\n        }\n\n        const initialString = (_value / item.value).toLocaleString('en-US', {\n            ...defaultOptions,\n            ...options,\n        })\n\n        // For larger numbers like \"$20.00k\", strip the zeroes at the end ($20.00k => $20k)\n        const rx = /\\.0+$|(\\.[0-9]*[1-9])0+$/\n        const stripZeroString =\n            Math.abs(_value) > 999 ? initialString.replace(rx, '$1') : initialString\n        return stripZeroString + item.symbol\n    }\n\n    throw new Error('Invalid format type')\n}\n\n/**\n * Lodash `sumBy` equivalent that supports Decimal.js\n */\nexport function sumBy<T>(a: T[] | null | undefined, by: (item: T) => DecimalJS): DecimalJS {\n    return a != null && a.length > 0 ? DecimalJS.sum(...a.map(by)) : new DecimalJS(0)\n}\n"
  },
  {
    "path": "libs/shared/src/utils/plan-utils.ts",
    "content": "import type { SharedType } from '..'\nimport type { PlanProjectionResponse } from '../types'\n\nexport const DEFAULT_AGE = 30\nexport const DEFAULT_LIFE_EXPECTANCY = 85\nexport const CONFIDENCE_INTERVAL = 0.9\nexport const RETIREMENT_MILESTONE_AGE = 65\n\nexport enum PlanEventCategory {}\nexport enum PlanMilestoneCategory {\n    Retirement = 'retirement',\n    FI = 'fi',\n}\n\nexport function resolveMilestoneYear(\n    projection: PlanProjectionResponse['projection'],\n    id: SharedType.PlanMilestone['id']\n): number | undefined {\n    return projection.data.find((d) => d.values.milestones.some((m) => m.id === id))?.values.year\n}\n"
  },
  {
    "path": "libs/shared/src/utils/shared-utils.spec.ts",
    "content": "import _ from 'lodash'\nimport { paginate, paginateIt, chunkIt, withRetry } from './shared-utils'\n\ndescribe('paginate', () => {\n    it.each`\n        pageSize | dataSize | fetchCalls\n        ${10}    | ${0}     | ${1}\n        ${10}    | ${1}     | ${1}\n        ${10}    | ${10}    | ${2}\n        ${10}    | ${11}    | ${2}\n        ${10}    | ${20}    | ${3}\n    `(\n        `paginates correctly: (pageSize: $pageSize, dataSize: $dataSize)`,\n        async ({ pageSize, dataSize, fetchCalls }) => {\n            const mockFetchData = jest.fn((offset, count) =>\n                Promise.resolve(_.slice(_.range(dataSize), offset, offset + count))\n            )\n\n            const result = await paginate({\n                pageSize,\n                fetchData: mockFetchData,\n            })\n\n            expect(result).toHaveLength(dataSize)\n            expect(result).toEqual(_.range(dataSize))\n\n            expect(mockFetchData).toHaveBeenCalledTimes(fetchCalls)\n            _.range(fetchCalls).map((i) => {\n                expect(mockFetchData).toHaveBeenNthCalledWith(i + 1, i * pageSize, pageSize)\n            })\n        }\n    )\n})\n\ndescribe('paginateIt', () => {\n    it.each`\n        pageSize | dataSize | fetchCalls\n        ${10}    | ${0}     | ${1}\n        ${10}    | ${1}     | ${1}\n        ${10}    | ${10}    | ${2}\n        ${10}    | ${11}    | ${2}\n        ${10}    | ${20}    | ${3}\n    `(\n        `paginates correctly: (pageSize: $pageSize, dataSize: $dataSize)`,\n        async ({ pageSize, dataSize, fetchCalls }) => {\n            const mockFetchData = jest.fn((offset, count) =>\n                Promise.resolve(_.slice(_.range(dataSize), offset, offset + count))\n            )\n\n            const it = paginateIt({\n                pageSize,\n                fetchData: mockFetchData,\n            })\n\n            const result = []\n            for await (const page of it) {\n                result.push(...page)\n            }\n\n            expect(result).toHaveLength(dataSize)\n            expect(result).toEqual(_.range(dataSize))\n\n            expect(mockFetchData).toHaveBeenCalledTimes(fetchCalls)\n            _.range(fetchCalls).map((i) => {\n                expect(mockFetchData).toHaveBeenNthCalledWith(i + 1, i * pageSize, pageSize)\n            })\n        }\n    )\n})\n\ndescribe('chunkIt', () => {\n    it('lazily chunks iterable', async () => {\n        const data = ['a', 'b', 'c']\n\n        const yieldTracker = jest.fn(() => {\n            /* noop */\n        })\n\n        const iterable: AsyncIterable<string> = {\n            async *[Symbol.asyncIterator]() {\n                for (const x of data) {\n                    yieldTracker()\n                    yield x\n                }\n            },\n        }\n\n        const it = chunkIt(iterable, 2)\n\n        const chunk1 = await it.next()\n        expect(chunk1.value).toHaveLength(2)\n        expect(chunk1.value).toEqual(['a', 'b'])\n        expect(yieldTracker).toHaveBeenCalledTimes(2)\n\n        const chunk2 = await it.next()\n        expect(chunk2.value).toHaveLength(1)\n        expect(chunk2.value).toEqual(['c'])\n        expect(yieldTracker).toHaveBeenCalledTimes(3)\n    })\n})\n\ndescribe('withRetry', () => {\n    it.each`\n        failAttempts | maxRetries\n        ${0}         | ${5}\n        ${1}         | ${5}\n        ${2}         | ${5}\n        ${5}         | ${5}\n    `(\n        `retries correctly: (failAttempts: $failAttempts, maxRetries: $maxRetries)`,\n        async ({ failAttempts, maxRetries }) => {\n            const mock = jest.fn((attempt) => {\n                if (attempt < failAttempts) throw new Error(`keep trying!`)\n                return 'done'\n            })\n\n            await withRetry(mock, { maxRetries })\n\n            expect(mock).toHaveBeenCalledTimes(failAttempts + 1)\n            _.range(failAttempts + 1).map((i) => {\n                expect(mock).toHaveBeenNthCalledWith(i + 1, i)\n            })\n        }\n    )\n\n    it(`throws last error`, async () => {\n        const maxRetries = 5\n\n        const mock = jest.fn((attempt) => {\n            throw new Error(`keep trying! attempt: ${attempt}`)\n        })\n\n        expect(withRetry(mock, { maxRetries })).rejects.toThrow()\n        expect(mock).toHaveBeenCalledTimes(maxRetries + 1)\n    })\n\n    it(`obeys onError poison pill`, async () => {\n        const maxRetries = 5\n        const exitAfterAttempts = 1\n\n        const mock = jest.fn((attempt) => {\n            throw new Error(`keep trying! attempt: ${attempt}`)\n        })\n\n        const mockOnError = jest.fn((_err, attempt) => attempt < exitAfterAttempts)\n\n        expect(withRetry(mock, { maxRetries, onError: mockOnError })).rejects.toThrow()\n        expect(mock).toHaveBeenCalledTimes(exitAfterAttempts + 1)\n        expect(mockOnError).toHaveBeenCalledTimes(exitAfterAttempts + 1)\n    })\n})\n"
  },
  {
    "path": "libs/shared/src/utils/shared-utils.ts",
    "content": "export function isNull<T>(value: T | null | undefined): value is null | undefined {\n    return value == null\n}\n\nexport function nonNull<T>(value: T | null | undefined): value is NonNullable<T> {\n    return !isNull(value)\n}\n\nexport function isFullfilled<T>(p: PromiseSettledResult<T>): p is PromiseFulfilledResult<T> {\n    return p.status === 'fulfilled'\n}\n\nexport function stringToArray(s: string): string[]\nexport function stringToArray(s: string | null | undefined): string[] | undefined\nexport function stringToArray(s: string | null | undefined, sep = ','): string[] | undefined {\n    return s?.split(sep).map((x) => x.trim())\n}\n\n/**\n * Helper function for paginating data (typically from an API)\n */\nexport async function paginate<TData>({\n    fetchData,\n    pageSize,\n    delay,\n}: {\n    fetchData: (offset: number, count: number) => Promise<TData[]>\n    pageSize: number\n    delay?: { onDelay: (message: string) => void; milliseconds: number }\n}): Promise<TData[]> {\n    const result: TData[] = []\n    let offset = 0\n    let data: TData[] = []\n\n    do {\n        // fetch one page of data\n        data = await fetchData(offset, pageSize)\n\n        // yield each item in the page (lets the async iterator move through one page of data)\n        result.push(...data)\n\n        // increase the offset by the page count so the next iteration fetches fresh data\n        offset += pageSize\n\n        if (delay && data.length >= pageSize) {\n            delay.onDelay(`Waiting ${delay.milliseconds / 1000} seconds`)\n            await new Promise((resolve) => setTimeout(resolve, delay.milliseconds))\n        }\n    } while (data.length >= pageSize)\n\n    return result\n}\n\n/**\n * Helper function for paginating data with a next data url\n */\nexport async function paginateWithNextUrl<TData>({\n    fetchData,\n    pageSize,\n    delay,\n}: {\n    fetchData: (\n        limit: number,\n        nextCursor: string | undefined\n    ) => Promise<{ data: TData[]; nextUrl: string | undefined }>\n    pageSize: number\n    delay?: { onDelay: (message: string) => void; milliseconds: number }\n}): Promise<TData[]> {\n    let hasNextPage = true\n    let nextCursor: string | undefined = undefined\n    const result: TData[] = []\n\n    while (hasNextPage) {\n        const response: { data: TData[]; nextUrl: string | undefined } = await fetchData(\n            pageSize,\n            nextCursor\n        )\n        const data = response.data\n        const nextUrl: string | undefined = response.nextUrl ?? undefined\n        nextCursor = nextUrl ? new URL(nextUrl).searchParams.get('cursor') ?? undefined : undefined\n\n        result.push(...data)\n\n        hasNextPage = !!nextCursor\n\n        if (delay) {\n            delay.onDelay(`Waiting ${delay.milliseconds / 1000} seconds`)\n            await new Promise((resolve) => setTimeout(resolve, delay.milliseconds))\n        }\n    }\n\n    return result\n}\n\n/**\n * Helper function for paginating data using a generator (typically from an API)\n */\nexport async function* paginateIt<TData>({\n    fetchData,\n    pageSize,\n}: {\n    fetchData: (offset: number, count: number) => Promise<TData[]>\n    pageSize: number\n}): AsyncGenerator<TData[]> {\n    let offset = 0\n    let data: TData[] = []\n\n    do {\n        // fetch one page of data\n        data = await fetchData(offset, pageSize)\n\n        // yield each item in the page (lets the async iterator move through one page of data)\n        yield data\n\n        // increase the offset by the page count so the next iteration fetches fresh data\n        offset += pageSize\n    } while (data.length >= pageSize)\n}\n\n/**\n * Helper function for generating chunks from an `AsyncIterable`\n */\nexport async function* chunkIt<T>(it: AsyncIterable<T>, size: number): AsyncGenerator<T[]> {\n    let chunk: T[] = []\n\n    for await (const item of it) {\n        chunk.push(item)\n\n        if (chunk.length === size) {\n            yield chunk\n            chunk = []\n        }\n    }\n\n    if (chunk.length > 0) yield chunk\n}\n\n/**\n * Wraps a function with basic retry logic\n */\nexport async function withRetry<TResult>(\n    fn: (attempt: number) => TResult | Promise<TResult>,\n    {\n        maxRetries = 10,\n        onError,\n        delay,\n    }: {\n        maxRetries?: number\n        onError?(error: unknown, attempt: number): boolean | undefined // true = retry, false = stop\n        delay?: number // milliseconds\n    } = {}\n) {\n    let retries = 0\n    let lastError: unknown\n\n    while (retries <= maxRetries) {\n        if (delay && retries > 0) {\n            await new Promise((resolve) => setTimeout(resolve, delay))\n        }\n\n        try {\n            const res = await fn(retries)\n            return res\n        } catch (err) {\n            lastError = err\n\n            if (onError) {\n                const shouldRetry = onError(err, retries)\n                if (!shouldRetry) {\n                    break\n                }\n            }\n\n            retries++\n        }\n    }\n\n    throw lastError\n}\n\n/**\n * Ensures a URL contains an HTTP(s) protocol and does NOT end with a slash\n */\nexport function normalizeUrl(url: string) {\n    // Add protocol if missing\n    if (!/^https?:\\/\\//i.test(url)) url = `http://${url}`\n\n    // Remove trailing slash\n    if (url.endsWith('/')) url = url.slice(0, -1)\n\n    return url\n}\n"
  },
  {
    "path": "libs/shared/src/utils/stats-utils.spec.ts",
    "content": "import Decimal from 'decimal.js'\nimport { confidenceInterval, mean, stddev, variance, quantiles, quantilesBy } from './stats-utils'\n\nconst d = (x: Decimal.Value) => new Decimal(x)\n\ndescribe('stats', () => {\n    it.each`\n        data             | mean | variance | stddev | ci\n        ${[1, 7, 9, 15]} | ${8} | ${25}    | ${5}   | ${[3.1, 12.9]}\n    `('calculates data=$data μ=$mean σ²=$variance σ=$stddev ci=$ci', (x) => {\n        expect(mean(x.data)).toEqual(d(x.mean))\n        expect(variance(x.data)).toEqual(d(x.variance))\n        expect(stddev(x.data)).toEqual(d(x.stddev))\n        expect(confidenceInterval(x.data)).toEqual(x.ci.map(d))\n    })\n\n    it('calculates quantiles', () => {\n        const res = quantiles([1, 3, 2, 5, 4], ['0', '0.1', '0.25', '0.5', '0.75', '0.9', '1'])\n\n        expect(res).toHaveLength(7)\n        expect(res).toEqual([1, 1, 2, 3, 4, 5, 5].map(d))\n    })\n\n    it('calculates median using average of middle 2 elements', () => {\n        const res = quantiles([1, 2, 3, 4], ['0.5'])\n\n        expect(res[0]).toEqual(d(2.5))\n    })\n\n    it('calcualtes quantiles by property', () => {\n        const data = [{ a: 1 }, { a: 2 }, { a: 3 }, { a: 4 }]\n\n        const res = quantilesBy(data, (item) => new Decimal(item.a), ['0.5'])\n\n        expect(res[0]).toEqual(data[1])\n    })\n})\n"
  },
  {
    "path": "libs/shared/src/utils/stats-utils.ts",
    "content": "import Decimal from 'decimal.js'\nimport sortBy from 'lodash/sortBy'\n\nexport function mean(x: Decimal.Value[]): Decimal {\n    if (!x.length) throw new Error('mean requires at least 1 data point')\n    return Decimal.sum(...x).div(x.length)\n}\n\nexport function variance(x: Decimal.Value[]): Decimal {\n    const meanValue = mean(x)\n    return Decimal.sum(...x.map((k) => Decimal.sub(k, meanValue).pow(2))).div(x.length)\n}\n\nexport function stddev(x: Decimal.Value[]): Decimal {\n    return variance(x).sqrt()\n}\n\nexport function confidenceInterval(\n    x: Decimal.Value[],\n    z: Decimal.Value = 1.96 // 90% = 1.645, 95% = 1.96, 99% = 2.58\n): [Decimal, Decimal] {\n    const meanValue = mean(x)\n    const v = Decimal.mul(z, stddev(x).div(Decimal.sqrt(x.length)))\n    return [meanValue.minus(v), meanValue.plus(v)]\n}\n\nfunction boxMullerTransform(): [z0: number, z1: number] {\n    const u1 = Math.random()\n    const u2 = Math.random()\n\n    return [\n        Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2),\n        Math.sqrt(-2.0 * Math.log(u1)) * Math.sin(2.0 * Math.PI * u2),\n    ]\n}\n\n/**\n * Generates random number using normal distribution around a specified mean / stddev\n */\nexport function randomNormal(mean: Decimal.Value, std: Decimal.Value): Decimal {\n    const [z0] = boxMullerTransform()\n    return Decimal.mul(z0, std).plus(mean)\n}\n\nfunction countIf<T>(data: T[], fn: (item: T) => boolean): number {\n    let count = 0\n    for (const item of data) {\n        if (fn(item)) count++\n    }\n    return count\n}\n\n/**\n * returns percentage of items meeting a specific criteria\n */\nexport function rateOf<T>(data: T[], fn: (item: T) => boolean): Decimal {\n    return Decimal.div(countIf(data, fn), data.length)\n}\n\nexport function quantiles(data: Decimal.Value[], p: Decimal.Value[]): Decimal[] {\n    const tiles = p.map(_toDecimal)\n    const sorted = data.map(_toDecimal).sort((a, b) => a.cmp(b))\n\n    return tiles.map((tile) => _quantileSorted<Decimal>(sorted, tile, mean))\n}\n\nexport function quantilesBy<T>(data: T[], by: (item: T) => Decimal, p: Decimal.Value[]): T[] {\n    const tiles = p.map(_toDecimal)\n    const sorted = sortBy(data, (item) => by(item).toNumber())\n\n    return tiles.map((tile) => _quantileSorted<T>(sorted, tile, (data) => data[0]))\n}\n\nfunction _quantileSorted<T>(x: T[], p: Decimal, avg: (data: [T, T]) => T): T {\n    const idx = x.length * +p\n    if (x.length === 0) {\n        throw new Error('quantile requires at least one data point.')\n    } else if (p.lt(0) || p.gt(1)) {\n        throw new Error('quantiles must be between 0 and 1')\n    } else if (p.eq(1)) {\n        // If p is 1, directly return the last element\n        return x[x.length - 1]\n    } else if (p.isZero()) {\n        // If p is 0, directly return the first element\n        return x[0]\n    } else if (idx % 1 !== 0) {\n        // If p is not integer, return the next element in array\n        return x[Math.ceil(idx) - 1]\n    } else if (x.length % 2 === 0) {\n        // If the list has even-length, we'll take the average of this number\n        // and the next value, if there is one\n        return avg([x[idx - 1], x[idx]])\n    } else {\n        // Finally, in the simple case of an integer value\n        // with an odd-length list, return the x value at the index.\n        return x[idx]\n    }\n}\n\nfunction _toDecimal(x: Decimal.Value): Decimal {\n    return x instanceof Decimal ? x : new Decimal(x)\n}\n"
  },
  {
    "path": "libs/shared/src/utils/test-utils.ts",
    "content": "export function axiosSuccess<T>(data: T) {\n    return {\n        status: 200,\n        statusText: '200',\n        headers: {},\n        config: {},\n        data,\n    }\n}\n\nexport function axios400Error<T>(data: T) {\n    return {\n        config: {},\n        response: {\n            status: 400,\n            statusText: '400',\n            headers: {},\n            config: {},\n            data,\n        },\n        isAxiosError: true,\n    }\n}\n"
  },
  {
    "path": "libs/shared/src/utils/transaction-utils.ts",
    "content": "// For now, we'll maintain a fixed list of categories.  Eventually, we'll accept any string input\nexport const CATEGORIES = [\n    'Income',\n    'Shopping',\n    'Utilities',\n    'Food and Drink',\n    'Home Improvement',\n    'Health',\n    'Transportation',\n    'Travel',\n    'Housing Payments',\n    'Vehicle Payments',\n    'Other Payments',\n    'Other',\n]\n"
  },
  {
    "path": "libs/shared/src/utils/user-utils.ts",
    "content": "export const MAX_MAYBE_LENGTH = 180\n\nexport const userTitles = [\n    'DIY Investor',\n    'Internet Businessman',\n    'Soon-to-be Millionaire',\n    'Inevitable Billionaire',\n    'Passive Income Pro',\n    'Curious Self-Starter',\n    'Crypto Whale',\n    'Complete Degen',\n    'Stock Market Maverick',\n    'Wall Street Insider',\n    'NFT Collector',\n    'Real Estate Mogul',\n    'Diamond Hands',\n    'Savings Savant',\n    'Aspiring Retiree',\n    'FIRE seeker',\n    'FatFIRE hopeful',\n    'Credit Card Connoisseur',\n    'Debt Destroyer',\n    'Investing Novice',\n    'Economic Enthusiast',\n    'Freedom Seeker',\n    'Bootstrapped Founder',\n    'Smart Saver',\n    'Wealth Builder',\n    'Budget Master',\n    'Financial Freedom Fighter',\n    'Stock Market Enthusiast',\n    'Savvy Spender',\n]\n\nexport const randomUserTitle = (except?: string) =>\n    userTitles.filter((title) => title !== except)[\n        Math.floor(Math.random() * (userTitles.length - (except ? 1 : 0)))\n    ]\n"
  },
  {
    "path": "libs/shared/tsconfig.json",
    "content": "{\n    \"extends\": \"../../tsconfig.base.json\",\n    \"compilerOptions\": {\n        \"esModuleInterop\": true,\n        \"strict\": true\n    },\n    \"files\": [],\n    \"include\": [],\n    \"references\": [\n        {\n            \"path\": \"./tsconfig.lib.json\"\n        },\n        {\n            \"path\": \"./tsconfig.spec.json\"\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/shared/tsconfig.lib.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"module\": \"commonjs\",\n        \"outDir\": \"../../dist/out-tsc\",\n        \"declaration\": true,\n        \"types\": [\"node\"]\n    },\n    \"exclude\": [\"**/*.spec.ts\", \"**/*.test.ts\", \"jest.config.ts\"],\n    \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "libs/shared/tsconfig.spec.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../dist/out-tsc\",\n        \"module\": \"commonjs\",\n        \"types\": [\"jest\", \"node\"]\n    },\n    \"include\": [\n        \"**/*.spec.ts\",\n        \"**/*.test.ts\",\n        \"**/*.spec.tsx\",\n        \"**/*.test.tsx\",\n        \"**/*.spec.js\",\n        \"**/*.test.js\",\n        \"**/*.spec.jsx\",\n        \"**/*.test.jsx\",\n        \"**/*.d.ts\",\n        \"jest.config.ts\"\n    ]\n}\n"
  },
  {
    "path": "libs/teller-api/.eslintrc.json",
    "content": "{\n    \"extends\": [\"../../.eslintrc.json\"],\n    \"ignorePatterns\": [\"!**/*\"],\n    \"overrides\": [\n        {\n            \"files\": [\"*.ts\", \"*.tsx\", \"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.ts\", \"*.tsx\"],\n            \"rules\": {}\n        },\n        {\n            \"files\": [\"*.js\", \"*.jsx\"],\n            \"rules\": {}\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/teller-api/jest.config.ts",
    "content": "/* eslint-disable */\nexport default {\n    displayName: 'teller-api',\n    preset: '../../jest.preset.js',\n    globals: {\n        'ts-jest': {\n            tsconfig: '<rootDir>/tsconfig.spec.json',\n        },\n    },\n    testEnvironment: 'node',\n    transform: {\n        '^.+\\\\.[tj]sx?$': 'ts-jest',\n    },\n    moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],\n    coverageDirectory: '../../coverage/libs/teller-api',\n}\n"
  },
  {
    "path": "libs/teller-api/src/index.ts",
    "content": "export * from './teller-api'\nexport * as TellerTypes from './types'\n"
  },
  {
    "path": "libs/teller-api/src/teller-api.ts",
    "content": "import type { AxiosInstance, AxiosRequestConfig } from 'axios'\nimport type {\n    GetAccountResponse,\n    GetAccountsResponse,\n    GetAccountBalancesResponse,\n    GetIdentityResponse,\n    GetTransactionResponse,\n    GetTransactionsResponse,\n    DeleteAccountResponse,\n    GetAccountDetailsResponse,\n    GetInstitutionsResponse,\n    AuthenticatedRequest,\n    GetAccountRequest,\n    DeleteAccountRequest,\n    GetAccountDetailsRequest,\n    GetAccountBalancesRequest,\n    GetTransactionsRequest,\n    GetTransactionRequest,\n} from './types'\nimport axios from 'axios'\nimport * as fs from 'fs'\nimport * as https from 'https'\n\n/**\n * Basic typed mapping for Teller API\n */\nexport class TellerApi {\n    private api: AxiosInstance | null = null\n\n    /**\n     * List accounts a user granted access to in Teller Connect\n     *\n     * https://teller.io/docs/api/accounts\n     */\n\n    async getAccounts({ accessToken }: AuthenticatedRequest): Promise<GetAccountsResponse> {\n        const accounts = await this.get<GetAccountsResponse>(`/accounts`, accessToken)\n        const accountsWithBalances = await Promise.all(\n            accounts.map(async (account) => {\n                const balance = await this.getAccountBalances({\n                    accountId: account.id,\n                    accessToken,\n                })\n                return {\n                    ...account,\n                    balance,\n                }\n            })\n        )\n        return accountsWithBalances\n    }\n\n    /**\n     * Get a single account by id\n     *\n     * https://teller.io/docs/api/accounts\n     */\n\n    async getAccount({ accountId, accessToken }: GetAccountRequest): Promise<GetAccountResponse> {\n        return this.get<GetAccountResponse>(`/accounts/${accountId}`, accessToken)\n    }\n\n    /**\n     * Delete the application's access to an account. Does not delete the account itself.\n     *\n     * https://teller.io/docs/api/accounts\n     */\n\n    async deleteAccount({\n        accountId,\n        accessToken,\n    }: DeleteAccountRequest): Promise<DeleteAccountResponse> {\n        return this.delete<DeleteAccountResponse>(`/accounts/${accountId}`, accessToken)\n    }\n\n    /**\n     * Get account details for a single account\n     *\n     * https://teller.io/docs/api/account/details\n     */\n\n    async getAccountDetails({\n        accountId,\n        accessToken,\n    }: GetAccountDetailsRequest): Promise<GetAccountDetailsResponse> {\n        return this.get<GetAccountDetailsResponse>(`/accounts/${accountId}/details`, accessToken)\n    }\n\n    /**\n     * Get account balances for a single account\n     *\n     * https://teller.io/docs/api/account/balances\n     */\n\n    async getAccountBalances({\n        accountId,\n        accessToken,\n    }: GetAccountBalancesRequest): Promise<GetAccountBalancesResponse> {\n        return this.get<GetAccountBalancesResponse>(`/accounts/${accountId}/balances`, accessToken)\n    }\n\n    /**\n     * Get transactions for a single account\n     *\n     * https://teller.io/docs/api/transactions\n     */\n\n    async getTransactions({\n        accountId,\n        accessToken,\n    }: GetTransactionsRequest): Promise<GetTransactionsResponse> {\n        return this.get<GetTransactionsResponse>(`/accounts/${accountId}/transactions`, accessToken)\n    }\n\n    /**\n     * Get a single transaction by id\n     *\n     * https://teller.io/docs/api/transactions\n     */\n\n    async getTransaction({\n        accountId,\n        transactionId,\n        accessToken,\n    }: GetTransactionRequest): Promise<GetTransactionResponse> {\n        return this.get<GetTransactionResponse>(\n            `/accounts/${accountId}/transactions/${transactionId}`,\n            accessToken\n        )\n    }\n\n    /**\n     * Get identity for a single account\n     *\n     * https://teller.io/docs/api/identity\n     */\n\n    async getIdentity({ accessToken }: AuthenticatedRequest): Promise<GetIdentityResponse> {\n        return this.get<GetIdentityResponse>(`/identity`, accessToken)\n    }\n\n    /**\n     * Get list of supported institutions, access token not needed\n     *\n     * https://teller.io/docs/api/identity\n     */\n\n    async getInstitutions(): Promise<GetInstitutionsResponse> {\n        return this.get<GetInstitutionsResponse>(`/institutions`, '')\n    }\n\n    private async getApi(accessToken: string): Promise<AxiosInstance> {\n        const cert = fs.readFileSync('./certs/certificate.pem')\n        const key = fs.readFileSync('./certs/private_key.pem')\n\n        const agent = new https.Agent({\n            cert: cert,\n            key: key,\n        })\n\n        if (!this.api) {\n            this.api = axios.create({\n                httpsAgent: agent,\n                baseURL: `https://api.teller.io`,\n                timeout: 30_000,\n                headers: {\n                    Accept: 'application/json',\n                },\n                auth: {\n                    username: accessToken,\n                    password: '',\n                },\n            })\n        } else if (this.api.defaults.auth?.username !== accessToken) {\n            this.api.defaults.auth = {\n                username: accessToken,\n                password: '',\n            }\n        }\n\n        return this.api\n    }\n\n    /** Generic API GET request method */\n    private async get<TResponse>(\n        path: string,\n        accessToken: string,\n        params?: any,\n        config?: AxiosRequestConfig\n    ): Promise<TResponse> {\n        const api = await this.getApi(accessToken)\n        return api.get<TResponse>(path, { params, ...config }).then(({ data }) => data)\n    }\n\n    /** Generic API POST request method */\n    private async post<TResponse>(\n        path: string,\n        accessToken: string,\n        body?: any,\n        config?: AxiosRequestConfig\n    ): Promise<TResponse> {\n        const api = await this.getApi(accessToken)\n        return api.post<TResponse>(path, body, config).then(({ data }) => data)\n    }\n\n    /** Generic API DELETE request method */\n    private async delete<TResponse>(\n        path: string,\n        accessToken: string,\n        params?: any,\n        config?: AxiosRequestConfig\n    ): Promise<TResponse> {\n        const api = await this.getApi(accessToken)\n        return api.delete<TResponse>(path, { params, ...config }).then(({ data }) => data)\n    }\n}\n"
  },
  {
    "path": "libs/teller-api/src/types/account-balance.ts",
    "content": "// https://teller.io/docs/api/account/balances\nimport type { AuthenticatedRequest } from './authentication'\n\nexport type AccountBalance = {\n    account_id: string\n    ledger: string\n    available: string\n    links: {\n        self: string\n        account: string\n    }\n}\n\nexport type GetAccountBalancesResponse = AccountBalance\nexport interface GetAccountBalancesRequest extends AuthenticatedRequest {\n    accountId: string\n}\n"
  },
  {
    "path": "libs/teller-api/src/types/account-details.ts",
    "content": "// https://teller.io/docs/api/account/details\n\nimport type { AuthenticatedRequest } from './authentication'\n\nexport type AccountDetails = {\n    account_id: string\n    account_number: string\n    links: {\n        account: string\n        self: string\n    }\n    routing_numbers: {\n        ach?: string\n        wire?: string\n        bacs?: string\n    }\n}\n\nexport type GetAccountDetailsResponse = AccountDetails\nexport interface GetAccountDetailsRequest extends AuthenticatedRequest {\n    accountId: string\n}\n"
  },
  {
    "path": "libs/teller-api/src/types/accounts.ts",
    "content": "// https://teller.io/docs/api/accounts\nimport type { AccountBalance } from './account-balance'\nimport type { AuthenticatedRequest } from './authentication'\n\nexport type AccountTypes = 'depository' | 'credit'\n\nexport enum AccountType {\n    'depository',\n    'credit',\n}\n\nexport type DepositorySubtypes =\n    | 'checking'\n    | 'savings'\n    | 'money_market'\n    | 'certificate_of_deposit'\n    | 'treasury'\n    | 'sweep'\n\nexport type CreditSubtype = 'credit_card'\n\nexport type AccountStatus = 'open' | 'closed'\n\ninterface BaseAccount {\n    enrollment_id: string\n    links: {\n        balances: string\n        self: string\n        transactions: string\n    }\n    institution: {\n        name: string\n        id: string\n    }\n    name: string\n    currency: string\n    id: string\n    last_four: string\n    status: AccountStatus\n}\n\ninterface DepositoryAccount extends BaseAccount {\n    type: 'depository'\n    subtype: DepositorySubtypes\n}\n\ninterface CreditAccount extends BaseAccount {\n    type: 'credit'\n    subtype: CreditSubtype\n}\n\nexport type Account = DepositoryAccount | CreditAccount\n\nexport type AccountWithBalances = Account & {\n    balance: AccountBalance\n}\n\nexport type GetAccountsResponse = AccountWithBalances[]\nexport type GetAccountResponse = Account\nexport type DeleteAccountResponse = void\n\nexport interface GetAccountRequest extends AuthenticatedRequest {\n    accountId: string\n}\n\nexport type DeleteAccountRequest = GetAccountRequest\n"
  },
  {
    "path": "libs/teller-api/src/types/authentication.ts",
    "content": "// https://teller.io/docs/api/authentication\n\nexport type AuthenticationResponse = {\n    token: string\n}\n\nexport type AuthenticatedRequest = {\n    accessToken: string\n}\n"
  },
  {
    "path": "libs/teller-api/src/types/enrollment.ts",
    "content": "export type Enrollment = {\n    accessToken: string\n    user: {\n        id: string\n    }\n    enrollment: {\n        id: string\n        institution: {\n            name: string\n        }\n    }\n    signatures?: string[]\n}\n"
  },
  {
    "path": "libs/teller-api/src/types/error.ts",
    "content": "export type TellerError = {\n    error: {\n        code: string\n        message: string\n    }\n}\n"
  },
  {
    "path": "libs/teller-api/src/types/identity.ts",
    "content": "// https://teller.io/docs/api/identity\n\nimport type { Account } from './accounts'\n\nexport type Identity = {\n    type: 'person' | 'business'\n    names: Name[]\n    data: string\n    addresses: Address[]\n    phone_numbers: PhoneNumber[]\n    emails: Email[]\n}\n\ntype Name = {\n    type: 'name' | 'alias'\n}\n\ntype Address = {\n    primary: boolean\n    street: string\n    city: string\n    region: string\n    postal_code: string\n    country_code: string\n}\n\ntype Email = {\n    data: string\n}\n\ntype PhoneNumber = {\n    type: 'mobile' | 'home' | 'work' | 'unknown'\n    data: string\n}\n\nexport type GetIdentityResponse = {\n    account: Account\n    owners: Identity[]\n}[]\n"
  },
  {
    "path": "libs/teller-api/src/types/index.ts",
    "content": "export * from './accounts'\nexport * from './account-balance'\nexport * from './account-details'\nexport * from './authentication'\nexport * from './error'\nexport * from './enrollment'\nexport * from './identity'\nexport * from './institutions'\nexport * from './transactions'\nexport * from './webhooks'\n"
  },
  {
    "path": "libs/teller-api/src/types/institutions.ts",
    "content": "// https://api.teller.io/institutions\n// Note: Teller says this is subject to change, specifically the `capabilities` field\n\nexport type Institution = {\n    id: string\n    name: string\n    capabilities: Capability[]\n}\n\ntype Capability = 'detail' | 'balance' | 'transaction' | 'identity'\n\nexport type GetInstitutionsResponse = Institution[]\n"
  },
  {
    "path": "libs/teller-api/src/types/transactions.ts",
    "content": "// https://teller.io/docs/api/account/transactions\n\nimport type { AuthenticatedRequest } from './authentication'\n\ntype DetailCategory =\n    | 'accommodation'\n    | 'advertising'\n    | 'bar'\n    | 'charity'\n    | 'clothing'\n    | 'dining'\n    | 'education'\n    | 'electronics'\n    | 'entertainment'\n    | 'fuel'\n    | 'general'\n    | 'groceries'\n    | 'health'\n    | 'home'\n    | 'income'\n    | 'insurance'\n    | 'investment'\n    | 'loan'\n    | 'office'\n    | 'phone'\n    | 'service'\n    | 'shopping'\n    | 'software'\n    | 'sport'\n    | 'tax'\n    | 'transport'\n    | 'transportation'\n    | 'utilities'\n\ntype DetailProcessingStatus = 'pending' | 'complete'\n\nexport type Transaction = {\n    details: {\n        category?: DetailCategory\n        processing_status: DetailProcessingStatus\n        counterparty?: {\n            name?: string\n            type?: 'organization' | 'person'\n        }\n    }\n    running_balance: string | null\n    description: string\n    id: string\n    date: string\n    account_id: string\n    links: {\n        self: string\n        account: string\n    }\n    amount: string\n    status: string\n    type: string\n}\n\nexport type GetTransactionsResponse = Transaction[]\nexport type GetTransactionResponse = Transaction\nexport interface GetTransactionsRequest extends AuthenticatedRequest {\n    accountId: string\n}\nexport interface GetTransactionRequest extends AuthenticatedRequest {\n    accountId: string\n    transactionId: string\n}\n"
  },
  {
    "path": "libs/teller-api/src/types/webhooks.ts",
    "content": "// https://teller.io/docs/api/webhooks\n\nexport type WebhookData = {\n    id: string\n    payload: {\n        enrollment_id: string\n        reason: string\n    }\n    timestamp: string\n    type: string\n}\n"
  },
  {
    "path": "libs/teller-api/tsconfig.json",
    "content": "{\n    \"extends\": \"../../tsconfig.base.json\",\n    \"files\": [],\n    \"include\": [],\n    \"references\": [\n        {\n            \"path\": \"./tsconfig.lib.json\"\n        },\n        {\n            \"path\": \"./tsconfig.spec.json\"\n        }\n    ]\n}\n"
  },
  {
    "path": "libs/teller-api/tsconfig.lib.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"module\": \"commonjs\",\n        \"outDir\": \"../../dist/out-tsc\",\n        \"declaration\": true,\n        \"types\": [\"node\"]\n    },\n    \"exclude\": [\"jest.config.ts\", \"**/*.spec.ts\", \"s**/*.test.ts\"],\n    \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "libs/teller-api/tsconfig.spec.json",
    "content": "{\n    \"extends\": \"./tsconfig.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../../dist/out-tsc\",\n        \"module\": \"commonjs\",\n        \"types\": [\"jest\", \"node\"]\n    },\n    \"include\": [\n        \"jest.config.ts\",\n        \"**/*.test.ts\",\n        \"**/*.spec.ts\",\n        \"**/*.test.tsx\",\n        \"**/*.spec.tsx\",\n        \"**/*.test.js\",\n        \"**/*.spec.js\",\n        \"**/*.test.jsx\",\n        \"**/*.spec.jsx\",\n        \"**/*.d.ts\"\n    ]\n}\n"
  },
  {
    "path": "nx.json",
    "content": "{\n    \"npmScope\": \"maybe-finance\",\n    \"affected\": {\n        \"defaultBase\": \"main\"\n    },\n    \"tasksRunnerOptions\": {\n        \"default\": {\n            \"runner\": \"nx/tasks-runners/default\",\n            \"options\": {\n                \"cacheableOperations\": [\"build\", \"lint\", \"test\", \"e2e\", \"build-storybook\"],\n                \"parallel\": 1\n            }\n        }\n    },\n    \"generators\": {\n        \"@nrwl/react\": {\n            \"application\": {\n                \"style\": \"css\",\n                \"linter\": \"eslint\",\n                \"babel\": true\n            },\n            \"component\": {\n                \"style\": \"css\"\n            },\n            \"library\": {\n                \"style\": \"css\",\n                \"linter\": \"eslint\"\n            }\n        },\n        \"@nrwl/next\": {\n            \"application\": {\n                \"style\": \"none\",\n                \"linter\": \"eslint\"\n            }\n        }\n    },\n    \"defaultProject\": \"workers\",\n    \"$schema\": \"./node_modules/nx/schemas/nx-schema.json\",\n    \"targetDefaults\": {\n        \"build\": {\n            \"dependsOn\": [\"^build\"],\n            \"inputs\": [\"production\", \"^production\"]\n        },\n        \"e2e\": {\n            \"inputs\": [\"default\", \"^production\"]\n        },\n        \"test\": {\n            \"inputs\": [\"default\", \"^production\", \"{workspaceRoot}/jest.preset.js\"]\n        },\n        \"lint\": {\n            \"inputs\": [\"default\", \"{workspaceRoot}/.eslintrc.json\"]\n        },\n        \"build-storybook\": {\n            \"inputs\": [\"default\", \"^production\", \"{workspaceRoot}/.storybook/**/*\"]\n        }\n    },\n    \"namedInputs\": {\n        \"default\": [\"{projectRoot}/**/*\", \"sharedGlobals\"],\n        \"sharedGlobals\": [\"{workspaceRoot}/prisma/**\", \"{workspaceRoot}/babel.config.json\"],\n        \"production\": [\n            \"default\",\n            \"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)\",\n            \"!{projectRoot}/tsconfig.spec.json\",\n            \"!{projectRoot}/jest.config.[jt]s\",\n            \"!{projectRoot}/.eslintrc.json\",\n            \"!{projectRoot}/.storybook/**/*\",\n            \"!{projectRoot}/**/*.stories.@(js|jsx|ts|tsx|mdx)\"\n        ]\n    }\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"name\": \"maybe-finance\",\n    \"version\": \"0.0.0\",\n    \"license\": \"MIT\",\n    \"scripts\": {\n        \"preinstall\": \"npx only-allow pnpm\",\n        \"dev\": \"nx run-many --target serve --projects=client,server,workers --parallel --host 0.0.0.0 --nx-bail=true --maxParallel=100\",\n        \"dev:services\": \"docker compose --profile services up -d\",\n        \"dev:services:all\": \"docker compose --profile services --profile ngrok up -d\",\n        \"dev:workers:test\": \"nx test workers --skip-nx-cache --runInBand\",\n        \"dev:server:test\": \"nx test server --skip-nx-cache --runInBand\",\n        \"dev:test:unit\": \"pnpm dev:ci:test --testPathPattern='^(?!.*integration).*$' --verbose --skip-nx-cache\",\n        \"dev:test:integration\": \"NX_PORT=3335 pnpm dev:ci:test --testPathPattern='^.*.integration.spec.ts$' --verbose --skip-nx-cache\",\n        \"dev:lint\": \"nx affected --target=lint\",\n        \"dev:ci:e2e\": \"nx run e2e:e2e\",\n        \"dev:ci\": \"pnpm dev:ci:lint:all && pnpm dev:ci:test:all && pnpm dev:ci:format:all && nx run-many --target build --all\",\n        \"dev:ci:test\": \"nx run-many --target=test --all --runInBand\",\n        \"dev:ci:dependency-graph\": \"nx dep-graph\",\n        \"prisma:gen\": \"prisma generate\",\n        \"prisma:db:push\": \"prisma db push\",\n        \"prisma:migrate:dev\": \"prisma migrate dev\",\n        \"prisma:migrate:create\": \"prisma migrate dev --create-only\",\n        \"prisma:migrate:deploy\": \"prisma migrate deploy\",\n        \"prisma:migrate:reset\": \"prisma migrate reset\",\n        \"prisma:seed\": \"prisma db seed\",\n        \"prisma:studio\": \"prisma studio\",\n        \"dev:docker:reset\": \"docker compose down -v --rmi all --remove-orphans && docker system prune --all --volumes && docker compose build\",\n        \"dev:circular\": \"pnpx madge --circular --extensions ts libs\",\n        \"analyze:client\": \"ANALYZE=true nx build client --skip-nx-cache\",\n        \"tools:pages\": \"live-server tools/pages\",\n        \"prepare\": \"husky install\"\n    },\n    \"prisma\": {\n        \"seed\": \"ts-node --transpile-only prisma/seed.ts\"\n    },\n    \"private\": true,\n    \"dependencies\": {\n        \"@auth/prisma-adapter\": \"^1.0.14\",\n        \"@bull-board/api\": \"^5.14.0\",\n        \"@bull-board/express\": \"^5.14.0\",\n        \"@casl/ability\": \"^6.3.2\",\n        \"@casl/prisma\": \"^1.4.1\",\n        \"@fast-csv/format\": \"^4.3.5\",\n        \"@headlessui/react\": \"^1.7.2\",\n        \"@hookform/resolvers\": \"^2.9.6\",\n        \"@polygon.io/client-js\": \"^6.0.6\",\n        \"@popperjs/core\": \"^2.11.5\",\n        \"@prisma/client\": \"^5.8.0\",\n        \"@sentry/node\": \"^7.98.0\",\n        \"@sentry/react\": \"^7.22.0\",\n        \"@sentry/tracing\": \"^7.98.0\",\n        \"@storybook/core-server\": \"6.5.15\",\n        \"@stripe/stripe-js\": \"^1.44.1\",\n        \"@tanstack/react-query\": \"^4.19.1\",\n        \"@tanstack/react-query-devtools\": \"^4.19.1\",\n        \"@tanstack/react-table\": \"^8.3.0\",\n        \"@tippyjs/react\": \"^4.2.6\",\n        \"@tiptap/extension-placeholder\": \"^2.0.0-beta.209\",\n        \"@tiptap/react\": \"^2.0.0-beta.209\",\n        \"@tiptap/starter-kit\": \"^2.0.0-beta.209\",\n        \"@trpc/client\": \"^10.4.3\",\n        \"@trpc/next\": \"^10.4.3\",\n        \"@trpc/react-query\": \"^10.4.3\",\n        \"@trpc/server\": \"^10.4.3\",\n        \"@types/sanitize-html\": \"^2.8.0\",\n        \"@uppy/core\": \"^3.0.4\",\n        \"@uppy/dashboard\": \"^3.2.0\",\n        \"@uppy/drag-drop\": \"^3.0.1\",\n        \"@uppy/file-input\": \"^3.0.1\",\n        \"@uppy/progress-bar\": \"^3.0.1\",\n        \"@uppy/react\": \"^3.0.2\",\n        \"@uppy/screen-capture\": \"^3.0.1\",\n        \"@uppy/webcam\": \"^3.2.1\",\n        \"@vercel/analytics\": \"^0.1.8\",\n        \"@vercel/og\": \"^0.0.25\",\n        \"@visx/axis\": \"^2.11.1\",\n        \"@visx/curve\": \"^2.1.0\",\n        \"@visx/event\": \"^2.6.0\",\n        \"@visx/glyph\": \"^2.10.0\",\n        \"@visx/gradient\": \"^2.10.0\",\n        \"@visx/grid\": \"^2.11.1\",\n        \"@visx/group\": \"^2.10.0\",\n        \"@visx/responsive\": \"^2.10.0\",\n        \"@visx/scale\": \"^2.2.2\",\n        \"@visx/shape\": \"^2.11.1\",\n        \"@visx/threshold\": \"^2.12.2\",\n        \"@visx/tooltip\": \"^2.10.0\",\n        \"autoprefixer\": \"10.4.13\",\n        \"axios\": \"^0.26.1\",\n        \"bcrypt\": \"^5.1.1\",\n        \"bull\": \"^4.10.2\",\n        \"classnames\": \"^2.3.1\",\n        \"cookie\": \"^0.6.0\",\n        \"cookie-parser\": \"^1.4.6\",\n        \"core-js\": \"^3.6.5\",\n        \"cors\": \"^2.8.5\",\n        \"crypto-js\": \"^4.1.1\",\n        \"d3-array\": \"^3.2.0\",\n        \"dayzed\": \"^3.2.3\",\n        \"decimal.js\": \"^10.4.2\",\n        \"ejs\": \"^3.1.8\",\n        \"express\": \"4.18.2\",\n        \"express-async-errors\": \"^3.1.1\",\n        \"express-jwt\": \"^7.7.7\",\n        \"express-jwt-authz\": \"^2.4.1\",\n        \"express-openid-connect\": \"^2.10.0\",\n        \"framer-motion\": \"^6.5.1\",\n        \"fuzzysearch\": \"^1.0.3\",\n        \"http-errors\": \"^2.0.0\",\n        \"ioredis\": \"^5.2.4\",\n        \"is-ci\": \"^3.0.1\",\n        \"jsonwebtoken\": \"^8.5.1\",\n        \"jwk-to-pem\": \"^2.0.5\",\n        \"jwks-rsa\": \"^3.0.0\",\n        \"jwt-decode\": \"^3.1.2\",\n        \"lodash\": \"^4.17.21\",\n        \"luxon\": \"^3.1.0\",\n        \"mime-types\": \"^2.1.35\",\n        \"morgan\": \"^1.10.0\",\n        \"multer\": \"^1.4.5-lts.1\",\n        \"next\": \"13.1.1\",\n        \"next-auth\": \"^4.24.5\",\n        \"nodemailer\": \"^6.9.8\",\n        \"pg\": \"^8.8.0\",\n        \"postcss\": \"8.4.19\",\n        \"postmark\": \"^3.0.14\",\n        \"prisma\": \"^5.8.0\",\n        \"prosemirror-commands\": \"^1.5.0\",\n        \"prosemirror-dropcursor\": \"^1.6.1\",\n        \"prosemirror-gapcursor\": \"^1.3.1\",\n        \"prosemirror-history\": \"^1.3.0\",\n        \"prosemirror-keymap\": \"^1.2.0\",\n        \"prosemirror-model\": \"^1.18.3\",\n        \"prosemirror-schema-list\": \"^1.2.2\",\n        \"prosemirror-state\": \"^1.4.2\",\n        \"prosemirror-transform\": \"^1.7.0\",\n        \"prosemirror-view\": \"^1.29.1\",\n        \"react\": \"18.2.0\",\n        \"react-animate-height\": \"^3.0.4\",\n        \"react-dom\": \"18.2.0\",\n        \"react-error-boundary\": \"^3.1.4\",\n        \"react-hook-form\": \"^7.33.1\",\n        \"react-hot-toast\": \"^2.3.0\",\n        \"react-icons\": \"^4.4.0\",\n        \"react-infinite-scroller\": \"^1.2.6\",\n        \"react-media-recorder\": \"1.6.5\",\n        \"react-number-format\": \"^5.1.3\",\n        \"react-popper\": \"^2.3.0\",\n        \"react-ranger\": \"^2.1.0\",\n        \"react-responsive\": \"^9.0.0-beta.10\",\n        \"react-script-hook\": \"^1.7.2\",\n        \"regenerator-runtime\": \"0.13.7\",\n        \"sanitize-html\": \"^2.8.1\",\n        \"smooth-scroll-into-view-if-needed\": \"^1.1.33\",\n        \"stripe\": \"^10.17.0\",\n        \"superjson\": \"^1.11.0\",\n        \"tailwindcss\": \"^3.4.1\",\n        \"teller-connect-react\": \"^0.1.0\",\n        \"tslib\": \"^2.3.0\",\n        \"uuid\": \"^9.0.0\",\n        \"winston\": \"^3.8.2\",\n        \"winston-transport\": \"^4.5.0\",\n        \"zod\": \"^3.19.1\"\n    },\n    \"devDependencies\": {\n        \"@babel/core\": \"7.17.5\",\n        \"@babel/preset-react\": \"^7.14.5\",\n        \"@babel/preset-typescript\": \"7.16.7\",\n        \"@faker-js/faker\": \"^8.3.1\",\n        \"@fast-csv/parse\": \"^4.3.6\",\n        \"@next/bundle-analyzer\": \"^13.1.1\",\n        \"@nrwl/cli\": \"15.5.2\",\n        \"@nrwl/cypress\": \"15.5.2\",\n        \"@nrwl/eslint-plugin-nx\": \"15.5.2\",\n        \"@nrwl/express\": \"15.5.2\",\n        \"@nrwl/jest\": \"15.5.2\",\n        \"@nrwl/linter\": \"15.5.2\",\n        \"@nrwl/next\": \"15.5.2\",\n        \"@nrwl/node\": \"15.5.2\",\n        \"@nrwl/react\": \"15.5.2\",\n        \"@nrwl/storybook\": \"15.5.2\",\n        \"@nrwl/web\": \"15.5.2\",\n        \"@nrwl/workspace\": \"15.5.2\",\n        \"@pmmmwh/react-refresh-webpack-plugin\": \"^0.5.7\",\n        \"@sentry/types\": \"^7.98.0\",\n        \"@storybook/addon-essentials\": \"6.5.15\",\n        \"@storybook/addon-postcss\": \"3.0.0-alpha.1\",\n        \"@storybook/builder-webpack5\": \"6.5.15\",\n        \"@storybook/manager-webpack5\": \"6.5.15\",\n        \"@storybook/react\": \"6.5.15\",\n        \"@svgr/webpack\": \"^6.1.2\",\n        \"@tailwindcss/forms\": \"^0.5.7\",\n        \"@tailwindcss/typography\": \"^0.5.10\",\n        \"@testing-library/jest-dom\": \"^5.16.2\",\n        \"@testing-library/react\": \"13.4.0\",\n        \"@testing-library/user-event\": \"^13.2.1\",\n        \"@types/bcrypt\": \"^5.0.2\",\n        \"@types/cors\": \"^2.8.12\",\n        \"@types/crypto-js\": \"^4.1.1\",\n        \"@types/d3-array\": \"^3.0.3\",\n        \"@types/express\": \"4.17.14\",\n        \"@types/is-ci\": \"^3.0.0\",\n        \"@types/jest\": \"28.1.1\",\n        \"@types/jsonwebtoken\": \"^8.5.9\",\n        \"@types/lodash\": \"^4.14.182\",\n        \"@types/luxon\": \"^3.1.0\",\n        \"@types/mime-types\": \"^2.1.1\",\n        \"@types/morgan\": \"^1.9.3\",\n        \"@types/multer\": \"^1.4.7\",\n        \"@types/node\": \"18.11.9\",\n        \"@types/nodemailer\": \"^6.4.14\",\n        \"@types/pg\": \"^8.6.5\",\n        \"@types/react\": \"18.0.25\",\n        \"@types/react-dom\": \"18.0.9\",\n        \"@types/react-infinite-scroller\": \"^1.2.3\",\n        \"@types/react-ranger\": \"^2.0.1\",\n        \"@types/uuid\": \"^8.3.4\",\n        \"@types/zxcvbn\": \"^4.4.1\",\n        \"@typescript-eslint/eslint-plugin\": \"5.43.0\",\n        \"@typescript-eslint/parser\": \"5.43.0\",\n        \"babel-jest\": \"28.1.3\",\n        \"babel-loader\": \"8.2.3\",\n        \"css-loader\": \"^6.4.0\",\n        \"cypress\": \"^12.3.0\",\n        \"dotenv\": \"^16.0.0\",\n        \"eslint\": \"8.15.0\",\n        \"eslint-config-next\": \"13.1.1\",\n        \"eslint-config-prettier\": \"8.5.0\",\n        \"eslint-plugin-cypress\": \"^2.10.3\",\n        \"eslint-plugin-import\": \"2.26.0\",\n        \"eslint-plugin-json\": \"^3.1.0\",\n        \"eslint-plugin-jsx-a11y\": \"6.6.1\",\n        \"eslint-plugin-react\": \"7.31.11\",\n        \"eslint-plugin-react-hooks\": \"4.6.0\",\n        \"husky\": \"^7.0.0\",\n        \"jest\": \"28.1.1\",\n        \"jest-environment-jsdom\": \"28.1.1\",\n        \"jest-mock-extended\": \"^2.0.5\",\n        \"lint-staged\": \"^12.3.7\",\n        \"live-server\": \"^1.2.2\",\n        \"nock\": \"^13.2.9\",\n        \"nx\": \"15.5.2\",\n        \"prettier\": \"2.7.1\",\n        \"react-refresh\": \"^0.10.0\",\n        \"react-test-renderer\": \"18.2.0\",\n        \"style-loader\": \"^3.3.0\",\n        \"stylus\": \"^0.55.0\",\n        \"stylus-loader\": \"^7.1.0\",\n        \"ts-jest\": \"28.0.5\",\n        \"ts-node\": \"10.9.1\",\n        \"ts-toolbelt\": \"^9.6.0\",\n        \"typescript\": \"4.8.4\",\n        \"url-loader\": \"^4.1.1\",\n        \"wait-on\": \"^6.0.1\",\n        \"webpack\": \"^5.75.0\",\n        \"webpack-merge\": \"^5.8.0\"\n    },\n    \"lint-staged\": {\n        \"*\": [\n            \"pnpm nx format:write --uncommitted\",\n            \"pnpm eslint --fix\"\n        ]\n    }\n}\n"
  },
  {
    "path": "prisma/migrations/20211005200319_init/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"account_balance\" (\n    \"id\" SERIAL NOT NULL,\n    \"snapshot_date\" DATE NOT NULL,\n    \"closing_balance\" BIGINT NOT NULL,\n    \"debit_amount\" BIGINT NOT NULL,\n    \"credit_amount\" BIGINT NOT NULL,\n    \"quantity\" BIGINT,\n    \"account_id\" INTEGER NOT NULL,\n\n    CONSTRAINT \"account_balance_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"account_connection\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL,\n    \"name\" VARCHAR(255) NOT NULL,\n    \"type\" VARCHAR(255) NOT NULL,\n    \"plaid_item_id\" VARCHAR(255),\n    \"plaid_access_token\" VARCHAR(255),\n    \"plaid_institution_id\" VARCHAR(255),\n    \"user_id\" INTEGER NOT NULL,\n\n    CONSTRAINT \"account_connection_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"account\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL,\n    \"name\" VARCHAR(255) NOT NULL,\n    \"plaid_account_id\" VARCHAR(255),\n    \"is_active\" BOOLEAN NOT NULL,\n    \"plaid_type\" VARCHAR(255),\n    \"plaid_subtype\" VARCHAR(255),\n    \"type\" VARCHAR(255) NOT NULL,\n    \"subtype\" VARCHAR(255),\n    \"current_balance\" BIGINT,\n    \"available_balance\" BIGINT,\n    \"iso_currency_code\" VARCHAR(3) NOT NULL,\n    \"unofficial_currency_code\" VARCHAR(255),\n    \"account_connection_id\" INTEGER NOT NULL,\n\n    CONSTRAINT \"account_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"transaction\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL,\n    \"name\" VARCHAR(255) NOT NULL,\n    \"amount\" BIGINT NOT NULL,\n    \"type\" VARCHAR(255) NOT NULL,\n    \"pending\" BOOLEAN NOT NULL,\n    \"posted_date\" TIMESTAMPTZ(6) NOT NULL,\n    \"effective_date\" DATE NOT NULL,\n    \"plaid_transaction_id\" VARCHAR(255),\n    \"plaid_category_id\" VARCHAR(255),\n    \"category\" VARCHAR(255) NOT NULL,\n    \"subcategory\" VARCHAR(255),\n    \"iso_currency_code\" VARCHAR(3) NOT NULL,\n    \"unofficial_currency_code\" VARCHAR(255),\n    \"account_id\" INTEGER NOT NULL,\n\n    CONSTRAINT \"transaction_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"user\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL,\n    \"auth0_id\" VARCHAR(255) NOT NULL,\n    \"iso_currency_code\" VARCHAR(3) NOT NULL DEFAULT E'USD',\n\n    CONSTRAINT \"user_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"account_balances_account_id_index\" ON \"account_balance\"(\"account_id\");\n\n-- CreateIndex\nCREATE INDEX \"account_connections_user_id_index\" ON \"account_connection\"(\"user_id\");\n\n-- CreateIndex\nCREATE INDEX \"accounts_account_connection_id_index\" ON \"account\"(\"account_connection_id\");\n\n-- CreateIndex\nCREATE INDEX \"transactions_account_id_index\" ON \"transaction\"(\"account_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"users_auth0_id_unique\" ON \"user\"(\"auth0_id\");\n\n-- AddForeignKey\nALTER TABLE \"account_balance\" ADD CONSTRAINT \"account_balances_account_id_foreign\" FOREIGN KEY (\"account_id\") REFERENCES \"account\"(\"id\") ON DELETE CASCADE ON UPDATE NO ACTION;\n\n-- AddForeignKey\nALTER TABLE \"account_connection\" ADD CONSTRAINT \"account_connections_user_id_foreign\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE CASCADE ON UPDATE NO ACTION;\n\n-- AddForeignKey\nALTER TABLE \"account\" ADD CONSTRAINT \"accounts_account_connection_id_foreign\" FOREIGN KEY (\"account_connection_id\") REFERENCES \"account_connection\"(\"id\") ON DELETE CASCADE ON UPDATE NO ACTION;\n\n-- AddForeignKey\nALTER TABLE \"transaction\" ADD CONSTRAINT \"transactions_account_id_foreign\" FOREIGN KEY (\"account_id\") REFERENCES \"account\"(\"id\") ON DELETE CASCADE ON UPDATE NO ACTION;\n"
  },
  {
    "path": "prisma/migrations/20211019194924_unique_constraint_on_account_balances/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[snapshot_date,account_id]` on the table `account_balance` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- CreateIndex\nCREATE UNIQUE INDEX \"account_balance_snapshot_date_account_id_key\" ON \"account_balance\"(\"snapshot_date\", \"account_id\");\n"
  },
  {
    "path": "prisma/migrations/20211019214200_default_values/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"account\" ALTER COLUMN \"is_active\" SET DEFAULT true;\n\n-- AlterTable\nALTER TABLE \"account_balance\" ALTER COLUMN \"debit_amount\" SET DEFAULT 0,\nALTER COLUMN \"credit_amount\" SET DEFAULT 0;\n\n-- AlterTable\nALTER TABLE \"transaction\" ALTER COLUMN \"pending\" SET DEFAULT false,\nALTER COLUMN \"category\" SET DEFAULT E'Default';\n"
  },
  {
    "path": "prisma/migrations/20211025200206_account_balance_schema_update/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `iso_currency_code` on the `account` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_subtype` on the `account` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_type` on the `account` table. All the data in the column will be lost.\n  - You are about to drop the column `unofficial_currency_code` on the `account` table. All the data in the column will be lost.\n  - You are about to drop the column `closing_balance` on the `account_balance` table. All the data in the column will be lost.\n  - You are about to drop the column `credit_amount` on the `account_balance` table. All the data in the column will be lost.\n  - You are about to drop the column `debit_amount` on the `account_balance` table. All the data in the column will be lost.\n  - You are about to drop the column `quantity` on the `account_balance` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_institution_id` on the `account_connection` table. All the data in the column will be lost.\n  - You are about to drop the column `iso_currency_code` on the `transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_category_id` on the `transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_transaction_id` on the `transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `unofficial_currency_code` on the `transaction` table. All the data in the column will be lost.\n  - Added the required column `currency_code` to the `account` table without a default value. This is not possible if the table is not empty.\n  - Added the required column `balance` to the `account_balance` table without a default value. This is not possible if the table is not empty.\n  - Added the required column `currency_code` to the `transaction` table without a default value. This is not possible if the table is not empty.\n\n*/\n-- DropForeignKey\nALTER TABLE \"account_balance\" DROP CONSTRAINT \"account_balances_account_id_foreign\";\n\n-- DropForeignKey\nALTER TABLE \"transaction\" DROP CONSTRAINT \"transactions_account_id_foreign\";\n\n-- AlterTable\nALTER TABLE \"account\" DROP COLUMN \"iso_currency_code\",\nDROP COLUMN \"plaid_subtype\",\nDROP COLUMN \"plaid_type\",\nDROP COLUMN \"unofficial_currency_code\",\nADD COLUMN     \"currency_code\" VARCHAR(3) NOT NULL,\nADD COLUMN     \"house_meta\" JSONB,\nADD COLUMN     \"vehicle_meta\" JSONB;\n\n-- AlterTable\nALTER TABLE \"account_balance\" DROP COLUMN \"closing_balance\",\nDROP COLUMN \"credit_amount\",\nDROP COLUMN \"debit_amount\",\nDROP COLUMN \"quantity\",\nADD COLUMN     \"balance\" BIGINT NOT NULL,\nADD COLUMN     \"inflows\" BIGINT NOT NULL DEFAULT 0,\nADD COLUMN     \"outflows\" BIGINT NOT NULL DEFAULT 0;\n\n-- AlterTable\nALTER TABLE \"account_connection\" DROP COLUMN \"plaid_institution_id\";\n\n-- AlterTable\nALTER TABLE \"transaction\" DROP COLUMN \"iso_currency_code\",\nDROP COLUMN \"plaid_category_id\",\nDROP COLUMN \"plaid_transaction_id\",\nDROP COLUMN \"unofficial_currency_code\",\nADD COLUMN     \"currency_code\" VARCHAR(3) NOT NULL,\nADD COLUMN     \"plaidTransactionId\" VARCHAR(255),\nADD COLUMN     \"quantity\" BIGINT;\n\n-- AddForeignKey\nALTER TABLE \"account_balance\" ADD CONSTRAINT \"account_balances_account_id_foreign\" FOREIGN KEY (\"account_id\") REFERENCES \"account\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"transaction\" ADD CONSTRAINT \"transactions_account_id_foreign\" FOREIGN KEY (\"account_id\") REFERENCES \"account\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20211026174357_default_text_type/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"account\" ALTER COLUMN \"name\" SET DATA TYPE TEXT,\nALTER COLUMN \"plaid_account_id\" SET DATA TYPE TEXT,\nALTER COLUMN \"type\" SET DATA TYPE TEXT,\nALTER COLUMN \"subtype\" SET DATA TYPE TEXT,\nALTER COLUMN \"currency_code\" SET DATA TYPE TEXT;\n\n-- AlterTable\nALTER TABLE \"account_connection\" ALTER COLUMN \"name\" SET DATA TYPE TEXT,\nALTER COLUMN \"type\" SET DATA TYPE TEXT,\nALTER COLUMN \"plaid_item_id\" SET DATA TYPE TEXT,\nALTER COLUMN \"plaid_access_token\" SET DATA TYPE TEXT;\n\n-- AlterTable\nALTER TABLE \"transaction\" ALTER COLUMN \"name\" SET DATA TYPE TEXT,\nALTER COLUMN \"type\" SET DATA TYPE TEXT,\nALTER COLUMN \"category\" SET DATA TYPE TEXT,\nALTER COLUMN \"subcategory\" SET DATA TYPE TEXT,\nALTER COLUMN \"currency_code\" SET DATA TYPE TEXT,\nALTER COLUMN \"plaidTransactionId\" SET DATA TYPE TEXT;\n\n-- AlterTable\nALTER TABLE \"user\" ALTER COLUMN \"auth0_id\" SET DATA TYPE TEXT,\nALTER COLUMN \"iso_currency_code\" SET DATA TYPE TEXT;\n"
  },
  {
    "path": "prisma/migrations/20211026175641_default_values/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"transaction\" ALTER COLUMN \"category\" SET DEFAULT E'Default';\n\n-- AlterTable\nALTER TABLE \"user\" ALTER COLUMN \"iso_currency_code\" SET DEFAULT E'USD';\n"
  },
  {
    "path": "prisma/migrations/20211102165759_account_status/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `plaidTransactionId` on the `transaction` table. All the data in the column will be lost.\n  - A unique constraint covering the columns `[plaid_item_id]` on the table `account_connection` will be added. If there are existing duplicate values, this will fail.\n  - A unique constraint covering the columns `[plaid_transaction_id]` on the table `transaction` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- CreateEnum\nCREATE TYPE \"AccountConnectionStatus\" AS ENUM ('OK', 'ERROR');\n\n-- AlterTable\nALTER TABLE \"account_connection\" ADD COLUMN     \"plaid_consent_expiration\" TIMESTAMP(3),\nADD COLUMN     \"plaid_error\" JSONB,\nADD COLUMN     \"status\" \"AccountConnectionStatus\" NOT NULL DEFAULT E'OK';\n\n-- AlterTable\nALTER TABLE \"transaction\" RENAME COLUMN \"plaidTransactionId\" TO \"plaid_transaction_id\";\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"account_connection_plaid_item_id_key\" ON \"account_connection\"(\"plaid_item_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"transaction_plaid_transaction_id_key\" ON \"transaction\"(\"plaid_transaction_id\");\n"
  },
  {
    "path": "prisma/migrations/20211102183151_add_account_types_and_subtypes/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"AccountClassification\" AS ENUM ('asset', 'liability');\n\n-- CreateTable\nCREATE TABLE \"account_type\" (\n    \"id\" SERIAL NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"classification\" \"AccountClassification\" NOT NULL,\n    \"plaid_types\" TEXT[],\n\n    CONSTRAINT \"account_type_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"account_subtype\" (\n    \"id\" SERIAL NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"account_type_id\" INTEGER NOT NULL,\n    \"plaid_subtypes\" TEXT[],\n\n    CONSTRAINT \"account_subtype_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- AlterTable\nALTER TABLE \"account\" RENAME COLUMN \"type\" TO \"plaid_type\";\nALTER TABLE \"account\" RENAME COLUMN \"subtype\" TO \"plaid_subtype\";\nALTER TABLE \"account\"\n  ALTER COLUMN \"plaid_type\" DROP NOT NULL,\n  ADD COLUMN    \"subtype_id\" INTEGER,\n  ADD COLUMN    \"type_id\" INTEGER;\n\n-- Add default types\nINSERT INTO \"account_type\" (\"name\", \"classification\", \"plaid_types\") VALUES ('Other Asset', 'asset', '{other}'), ('Other Liability', 'liability', '{}');\n\n-- Set default `type_id`s\nUPDATE \"account\" SET \"type_id\" = CASE WHEN \"plaid_type\" = 'LIABILITY' THEN 2 ELSE 1 END;\n\n-- Make `account`.`type_id` NOT NULL\nALTER TABLE \"account\" ALTER COLUMN \"type_id\" SET NOT NULL;\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"account_type_name_key\" ON \"account_type\"(\"name\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"account_subtype_name_key\" ON \"account_subtype\"(\"name\");\n\n-- AddForeignKey\nALTER TABLE \"account\" ADD CONSTRAINT \"account_type_id_fkey\" FOREIGN KEY (\"type_id\") REFERENCES \"account_type\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"account\" ADD CONSTRAINT \"account_subtype_id_fkey\" FOREIGN KEY (\"subtype_id\") REFERENCES \"account_subtype\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"account_subtype\" ADD CONSTRAINT \"account_subtype_account_type_id_fkey\" FOREIGN KEY (\"account_type_id\") REFERENCES \"account_type\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n\n-- CreateIndex\nCREATE INDEX \"accounts_type_id_index\" ON \"account\"(\"type_id\");\n\n-- CreateIndex\nCREATE INDEX \"accounts_subtype_id_index\" ON \"account\"(\"subtype_id\");"
  },
  {
    "path": "prisma/migrations/20211104155259_account_uniqueness/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[account_connection_id,plaid_account_id]` on the table `account` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- CreateIndex\nCREATE UNIQUE INDEX \"account_account_connection_id_plaid_account_id_key\" ON \"account\"(\"account_connection_id\", \"plaid_account_id\");\n"
  },
  {
    "path": "prisma/migrations/20211105234550_posted_date_type/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"transaction\" ALTER COLUMN \"posted_date\" SET DATA TYPE DATE;\n"
  },
  {
    "path": "prisma/migrations/20211109151750_account_type_seed/migration.sql",
    "content": "INSERT INTO account_type (name, classification, plaid_types)\nVALUES\n\t('Cash', 'asset', '{depository}'),\n\t('Investments', 'asset', '{investment,brokerage}'),\n\t('Loans', 'liability', '{loan}'),\n\t('Credit', 'liability', '{credit}')\nON CONFLICT (name) DO NOTHING;\n\n\nINSERT INTO account_subtype (name, account_type_id, plaid_subtypes)\nVALUES\n\t('Checking', (SELECT id from account_type WHERE name = 'Cash'), '{depository}'),\n\t('Savings', (SELECT id from account_type WHERE name = 'Cash'), '{savings}'),\n\t('Certificate of Deposit', (SELECT id from account_type WHERE name = 'Cash'), '{cd}'),\n\t('Money Market', (SELECT id from account_type WHERE name = 'Cash'), '{\"money market\"}'),\n\t('Retirement', (SELECT id from account_type WHERE name = 'Investments'), '{401a,401k,403B,457b,ira,keogh,lif,lira,lrif,lrsp,pension,prif,retirement,roth,\"roth 401k\",rrif,rrsp,sarsep,\"sep ira\",\"simple ira\",sipp,tfsa}'),\n\t('Brokerage', (SELECT id from account_type WHERE name = 'Investments'), '{brokerage}'),\n\t('Auto', (SELECT id from account_type WHERE name = 'Loans'), '{auto}'),\n\t('Home Equity', (SELECT id from account_type WHERE name = 'Loans'), '{\"home equity\"}'),\n\t('Mortgage', (SELECT id from account_type WHERE name = 'Loans'), '{mortgage}'),\n\t('Student', (SELECT id from account_type WHERE name = 'Loans'), '{student}'),\n\t('Credit Card', (SELECT id from account_type WHERE name = 'Credit'), '{\"credit card\",paypal}')\nON CONFLICT (name) DO NOTHING;\n"
  },
  {
    "path": "prisma/migrations/20211110044559_manual_accounts_rename_fk/migration.sql",
    "content": "-- DropForeignKey\nALTER TABLE \"account\" DROP CONSTRAINT \"accounts_account_connection_id_foreign\";\n\n-- DropForeignKey\nALTER TABLE \"account_connection\" DROP CONSTRAINT \"account_connections_user_id_foreign\";\n\n-- AlterTable\nALTER TABLE \"account\" ADD COLUMN     \"user_id\" INTEGER,\nALTER COLUMN \"account_connection_id\" DROP NOT NULL;\n\n-- CreateTable\nCREATE TABLE \"valuation\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL,\n    \"account_id\" INTEGER NOT NULL,\n    \"source\" TEXT NOT NULL,\n    \"amount\" BIGINT NOT NULL,\n    \"currency_code\" TEXT NOT NULL DEFAULT E'USD',\n\n    CONSTRAINT \"valuation_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"valuation_account_id_idx\" ON \"valuation\"(\"account_id\");\n\n-- RenameForeignKey\nALTER TABLE \"account_balance\" RENAME CONSTRAINT \"account_balances_account_id_foreign\" TO \"account_balance_account_id_fkey\";\n\n-- RenameForeignKey\nALTER TABLE \"transaction\" RENAME CONSTRAINT \"transactions_account_id_foreign\" TO \"transaction_account_id_fkey\";\n\n-- AddForeignKey\nALTER TABLE \"account_connection\" ADD CONSTRAINT \"account_connection_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"account\" ADD CONSTRAINT \"account_account_connection_id_fkey\" FOREIGN KEY (\"account_connection_id\") REFERENCES \"account_connection\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"account\" ADD CONSTRAINT \"account_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"valuation\" ADD CONSTRAINT \"valuation_account_id_fkey\" FOREIGN KEY (\"account_id\") REFERENCES \"account\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- RenameIndex\nALTER INDEX \"accounts_account_connection_id_index\" RENAME TO \"account_account_connection_id_idx\";\n\n-- RenameIndex\nALTER INDEX \"accounts_subtype_id_index\" RENAME TO \"account_subtype_id_idx\";\n\n-- RenameIndex\nALTER INDEX \"accounts_type_id_index\" RENAME TO \"account_type_id_idx\";\n\n-- RenameIndex\nALTER INDEX \"account_balances_account_id_index\" RENAME TO \"account_balance_account_id_idx\";\n\n-- RenameIndex\nALTER INDEX \"account_connections_user_id_index\" RENAME TO \"account_connection_user_id_idx\";\n\n-- RenameIndex\nALTER INDEX \"transactions_account_id_index\" RENAME TO \"transaction_account_id_idx\";\n\n-- RenameIndex\nALTER INDEX \"users_auth0_id_unique\" RENAME TO \"user_auth0_id_key\";\n"
  },
  {
    "path": "prisma/migrations/20211116235652_investment_data/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `quantity` on the `transaction` table. All the data in the column will be lost.\n  - Changed the type of `type` on the `transaction` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.\n\n*/\n-- CreateEnum\nCREATE TYPE \"TransactionType\" AS ENUM ('INFLOW', 'OUTFLOW');\n\n-- Convert transaction amount to a signed value\nUPDATE \"transaction\"\nSET amount = -1 * amount\nWHERE type = 'INFLOW';\n\n-- AlterTable\nALTER TABLE \"transaction\" DROP COLUMN \"quantity\",\nDROP COLUMN \"type\",\nADD COLUMN  \"type\" \"TransactionType\" GENERATED ALWAYS AS (CASE WHEN amount < 0 THEN 'INFLOW'::\"TransactionType\" ELSE 'OUTFLOW'::\"TransactionType\" END) STORED;\n\n-- CreateTable\nCREATE TABLE \"holding\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL,\n    \"account_id\" INTEGER NOT NULL,\n    \"security_id\" INTEGER NOT NULL,\n    \"value\" BIGINT NOT NULL,\n    \"quantity\" DECIMAL(36,18) NOT NULL,\n    \"cost_basis\" BIGINT,\n    \"price\" BIGINT NOT NULL,\n    \"price_as_of\" DATE,\n    \"currency_code\" TEXT NOT NULL DEFAULT E'USD',\n    \"plaid_holding_id\" TEXT,\n\n    CONSTRAINT \"holding_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"investment_transaction\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL,\n    \"account_id\" INTEGER NOT NULL,\n    \"security_id\" INTEGER,\n    \"date\" DATE NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"amount\" BIGINT NOT NULL,\n    \"type\" \"TransactionType\" GENERATED ALWAYS AS (CASE WHEN amount < 0 THEN 'INFLOW'::\"TransactionType\" ELSE 'OUTFLOW'::\"TransactionType\" END) STORED,\n    \"quantity\" DECIMAL(36,18) NOT NULL,\n    \"price\" BIGINT NOT NULL,\n    \"currency_code\" TEXT NOT NULL DEFAULT E'USD',\n    \"plaid_investment_transaction_id\" TEXT,\n    \"plaid_type\" TEXT,\n    \"plaid_subtype\" TEXT,\n\n    CONSTRAINT \"investment_transaction_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"security\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL,\n    \"name\" TEXT,\n    \"symbol\" TEXT,\n    \"cusip\" TEXT,\n    \"isin\" TEXT,\n    \"currency_code\" TEXT NOT NULL DEFAULT E'USD',\n    \"plaid_security_id\" TEXT,\n    \"plaid_type\" TEXT,\n\n    CONSTRAINT \"security_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"holding_plaid_holding_id_key\" ON \"holding\"(\"plaid_holding_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"holding_account_id_security_id_key\" ON \"holding\"(\"account_id\", \"security_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"investment_transaction_plaid_investment_transaction_id_key\" ON \"investment_transaction\"(\"plaid_investment_transaction_id\");\n\n-- CreateIndex\nCREATE INDEX \"investment_transaction_account_id_idx\" ON \"investment_transaction\"(\"account_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"security_plaid_security_id_key\" ON \"security\"(\"plaid_security_id\");\n\n-- AddForeignKey\nALTER TABLE \"holding\" ADD CONSTRAINT \"holding_account_id_fkey\" FOREIGN KEY (\"account_id\") REFERENCES \"account\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"holding\" ADD CONSTRAINT \"holding_security_id_fkey\" FOREIGN KEY (\"security_id\") REFERENCES \"security\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"investment_transaction\" ADD CONSTRAINT \"investment_transaction_account_id_fkey\" FOREIGN KEY (\"account_id\") REFERENCES \"account\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"investment_transaction\" ADD CONSTRAINT \"investment_transaction_security_id_fkey\" FOREIGN KEY (\"security_id\") REFERENCES \"security\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20211117190140_add_manual_account_types/migration.sql",
    "content": "INSERT INTO account_type (name, classification)\nVALUES\n\t('Property', 'asset'),\n\t('Vehicles', 'asset')\nON CONFLICT (name) DO NOTHING;"
  },
  {
    "path": "prisma/migrations/20211117190719_updated_at_default/migration.sql",
    "content": "/*\n  Warnings:\n\n  - Made the column `type` on table `investment_transaction` required. This step will fail if there are existing NULL values in that column.\n  - Made the column `type` on table `transaction` required. This step will fail if there are existing NULL values in that column.\n\n*/\n-- AlterTable\nALTER TABLE \"account\" ALTER COLUMN \"updated_at\" SET DEFAULT CURRENT_TIMESTAMP;\n\n-- AlterTable\nALTER TABLE \"account_connection\" ALTER COLUMN \"updated_at\" SET DEFAULT CURRENT_TIMESTAMP;\n\n-- AlterTable\nALTER TABLE \"holding\" ALTER COLUMN \"updated_at\" SET DEFAULT CURRENT_TIMESTAMP;\n\n-- AlterTable\nALTER TABLE \"investment_transaction\" ALTER COLUMN \"updated_at\" SET DEFAULT CURRENT_TIMESTAMP,\nALTER COLUMN \"type\" SET NOT NULL;\n\n-- AlterTable\nALTER TABLE \"security\" ALTER COLUMN \"updated_at\" SET DEFAULT CURRENT_TIMESTAMP;\n\n-- AlterTable\nALTER TABLE \"transaction\" ALTER COLUMN \"updated_at\" SET DEFAULT CURRENT_TIMESTAMP,\nALTER COLUMN \"type\" SET NOT NULL;\n\n-- AlterTable\nALTER TABLE \"user\" ALTER COLUMN \"updated_at\" SET DEFAULT CURRENT_TIMESTAMP;\n\n-- AlterTable\nALTER TABLE \"valuation\" ALTER COLUMN \"updated_at\" SET DEFAULT CURRENT_TIMESTAMP;\n"
  },
  {
    "path": "prisma/migrations/20211117210112_valuation_date/migration.sql",
    "content": "/*\n  Warnings:\n\n  - Added the required column `date` to the `valuation` table without a default value. This is not possible if the table is not empty.\n\n*/\n-- AlterTable\nALTER TABLE \"valuation\" ADD COLUMN     \"date\" DATE NOT NULL;\n"
  },
  {
    "path": "prisma/migrations/20211117233026_add_date_indices/migration.sql",
    "content": "-- DropIndex\nDROP INDEX \"investment_transaction_account_id_idx\";\n\n-- DropIndex\nDROP INDEX \"transaction_account_id_idx\";\n\n-- DropIndex\nDROP INDEX \"valuation_account_id_idx\";\n\n-- CreateIndex\nCREATE INDEX \"investment_transaction_account_id_date_idx\" ON \"investment_transaction\"(\"account_id\", \"date\");\n\n-- CreateIndex\nCREATE INDEX \"transaction_account_id_effective_date_idx\" ON \"transaction\"(\"account_id\", \"effective_date\");\n\n-- CreateIndex\nCREATE INDEX \"valuation_account_id_date_idx\" ON \"valuation\"(\"account_id\", \"date\");\n"
  },
  {
    "path": "prisma/migrations/20211118160716_account_balance_update/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `snapshot_date` on the `account_balance` table. All the data in the column will be lost.\n  - A unique constraint covering the columns `[account_id,date]` on the table `account_balance` will be added. If there are existing duplicate values, this will fail.\n  - Added the required column `date` to the `account_balance` table without a default value. This is not possible if the table is not empty.\n\n*/\n-- DropIndex\nDROP INDEX \"account_balance_account_id_idx\";\n\n-- DropIndex\nDROP INDEX \"account_balance_snapshot_date_account_id_key\";\n\n-- AlterTable\nALTER TABLE \"account_balance\"\nALTER COLUMN \"inflows\" DROP NOT NULL,\nALTER COLUMN \"outflows\" DROP NOT NULL;\n\nALTER TABLE \"account_balance\" RENAME COLUMN \"snapshot_date\" TO \"date\";\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"account_balance_account_id_date_key\" ON \"account_balance\"(\"account_id\", \"date\");\n"
  },
  {
    "path": "prisma/migrations/20211118191000_account_balance_timestamps/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"account_balance\" ADD COLUMN     \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\nADD COLUMN     \"updated_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP;\n"
  },
  {
    "path": "prisma/migrations/20211118194940_account_functions/migration.sql",
    "content": "-- determines the bookkeeping type of an account\nCREATE OR REPLACE FUNCTION account_value_type(p_account_id int) RETURNS text AS $$\n  SELECT\n    CASE\n      WHEN EXISTS (SELECT 1 FROM investment_transaction WHERE account_id = p_account_id) THEN 'investment_transaction'\n      WHEN EXISTS (SELECT 1 FROM valuation WHERE account_id = p_account_id) THEN 'valuation'\n      ELSE 'transaction'\n    END\n$$ LANGUAGE SQL STABLE;\n\n\n-- returns the start date of the account from a bookkeeping perspective\nCREATE OR REPLACE FUNCTION account_value_start_date(p_account_id int) RETURNS date AS $$\n  SELECT\n    CASE\n      WHEN account_value_type(p_account_id) = 'valuation' THEN (SELECT MIN(date) FROM valuation WHERE account_id = p_account_id)\n      WHEN account_value_type(p_account_id) = 'transaction' THEN (SELECT MIN(effective_date) FROM transaction WHERE account_id = p_account_id)\n      WHEN account_value_type(p_account_id) = 'investment_transaction' THEN (SELECT MIN(date) FROM investment_transaction WHERE account_id = p_account_id)\n    END\n$$ LANGUAGE SQL STABLE;\n\n\n-- calculates balance info for every day of the account's lifetime\nCREATE OR REPLACE FUNCTION calculate_account_balances(p_account_id int) RETURNS TABLE(account_id int, date date, balance bigint, inflows bigint, outflows bigint) AS $$\n  WITH dates AS (\n    SELECT generate_series(account_value_start_date(p_account_id), CURRENT_DATE, '1d')::date AS date\n  )\n  SELECT\n    a.id AS account_id,\n    d.date,\n    CASE\n      WHEN account_value_type(a.id) = 'valuation' THEN (SELECT v.amount FROM valuation v WHERE v.account_id = a.id AND v.date <= d.date ORDER BY v.date DESC LIMIT 1)\n      WHEN account_value_type(a.id) = 'transaction' THEN a.current_balance + ((CASE WHEN at.classification = 'liability' THEN -1 ELSE 1 END) * SUM(COALESCE(SUM(t.amount), 0)) OVER w)\n      WHEN account_value_type(a.id) = 'investment_transaction' THEN a.current_balance + ((CASE WHEN at.classification = 'liability' THEN -1 ELSE 1 END) * SUM(COALESCE(SUM(it.amount), 0)) OVER w)\n    END AS balance,\n    CASE\n      WHEN account_value_type(a.id) = 'valuation' THEN NULL\n      WHEN account_value_type(a.id) = 'transaction' THEN COALESCE(SUM(ABS(t.amount)) FILTER (WHERE t.type = 'INFLOW'), 0)\n      WHEN account_value_type(a.id) = 'investment_transaction' THEN COALESCE(SUM(ABS(it.amount)) FILTER (WHERE it.type = 'INFLOW'), 0)\n    END AS inflows,\n    CASE\n      WHEN account_value_type(a.id) = 'valuation' THEN NULL\n      WHEN account_value_type(a.id) = 'transaction' THEN COALESCE(SUM(ABS(t.amount)) FILTER (WHERE t.type = 'OUTFLOW'), 0)\n      WHEN account_value_type(a.id) = 'investment_transaction' THEN COALESCE(SUM(ABS(it.amount)) FILTER (WHERE it.type = 'OUTFLOW'), 0)\n    END AS outflows\n  FROM\n    account a\n    LEFT JOIN account_type at ON at.id = a.type_id\n    CROSS JOIN dates d\n    LEFT JOIN transaction t ON t.account_id = a.id AND t.effective_date = d.date\n    LEFT JOIN investment_transaction it ON it.account_id = a.id AND it.date = d.date\n  WHERE\n    a.id = p_account_id\n  GROUP BY\n    a.id, at.classification, d.date\n  WINDOW \n    w AS (ORDER BY d.date DESC)\n$$ LANGUAGE SQL STABLE;\n"
  },
  {
    "path": "prisma/migrations/20211118214727_txn_date_naming/migration.sql",
    "content": "-- AlterTable\n\nALTER TABLE \"transaction\" RENAME COLUMN \"posted_date\" TO \"date\";\n\n"
  },
  {
    "path": "prisma/migrations/20211129155121_connection_status_codes/migration.sql",
    "content": "-- AlterEnum\n-- This migration adds more than one value to an enum.\n-- With PostgreSQL versions 11 and earlier, this is not possible\n-- in a single migration. This can be worked around by creating\n-- multiple migrations, each migration adding only one value to\n-- the enum.\n\n\nALTER TYPE \"AccountConnectionStatus\" ADD VALUE 'SYNCING';\nALTER TYPE \"AccountConnectionStatus\" ADD VALUE 'DISCONNECTED';\n"
  },
  {
    "path": "prisma/migrations/20211130184227_new_accounts_available_flag/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"account_connection\" ADD COLUMN     \"plaid_new_accounts_available\" BOOLEAN NOT NULL DEFAULT false;\n"
  },
  {
    "path": "prisma/migrations/20211201023540_account_single_table_inheritance/migration.sql",
    "content": "-- DropForeignKey\nALTER TABLE \"account\" DROP CONSTRAINT \"account_subtype_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"account\" DROP CONSTRAINT \"account_type_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"account_subtype\" DROP CONSTRAINT \"account_subtype_account_type_id_fkey\";\n\n-- DropIndex\nDROP INDEX \"account_subtype_id_idx\";\n\n-- DropIndex\nDROP INDEX \"account_type_id_idx\";\n\n-- First, add the new columns\nALTER TABLE account\nADD COLUMN classification \"AccountClassification\",\nADD COLUMN \"type\" TEXT,\nADD COLUMN \"valuation_type\" TEXT,\nADD COLUMN \"subcategory_override\" TEXT;\n\nALTER TABLE account\nRENAME COLUMN \"house_meta\" TO \"property_meta\";\n\n-- Update all `Account.type` fields\nUPDATE \"account\" SET \"type\" = 'plaid' WHERE plaid_account_id IS NOT NULL;\nUPDATE \"account\" SET \"type\" = 'property' WHERE property_meta IS NOT NULL;\n\n-- Update all `Account.classification` fields\nUPDATE \"account\" a\nSET classification = at.classification\nFROM \"account_type\" at\nWHERE a.type_id = at.id;\n\n-- Update all `Account.valuation_type` fields\nUPDATE \"account\"\nSET \"valuation_type\" = 'plaid-investment-transaction'\nWHERE \"plaid_type\" = 'investment';\n\nUPDATE \"account\"\nSET \"valuation_type\" = 'plaid-transaction'\nWHERE \"plaid_type\" <> 'investment' AND \"plaid_type\" IS NOT NULL;\n\nUPDATE \"account\"\nSET \"valuation_type\" = 'zillow-valuation'\nWHERE \"plaid_type\" IS NULL;\n\n-- Add computed column for category\nALTER TABLE \"account\"\nADD COLUMN  \"category\" TEXT GENERATED ALWAYS AS (\n\tCASE \n\t\tWHEN \"type\" = 'plaid' AND \"plaid_type\" IN ('depository') THEN 'cash'\n\t\tWHEN \"type\" = 'plaid' AND \"plaid_type\" IN ('investment' ,'brokerage') THEN 'investment'\n\t\tWHEN \"type\" = 'plaid' AND \"plaid_type\" IN ('loan') THEN 'loan'\n\t\tWHEN \"type\" = 'plaid' AND \"plaid_type\" IN ('credit') THEN 'credit'\n\t\tWHEN \"type\" = 'property' THEN 'property'\n\t\tWHEN \"type\" = 'vehicle' THEN 'vehicle'\n\t\tWHEN \"type\" = 'other' THEN 'other'\n\tEND\n) STORED;\n\n-- Add computed column for subcategory\nALTER TABLE \"account\"\nADD COLUMN  \"subcategory\" TEXT GENERATED ALWAYS AS (\n\tCASE \n\t\tWHEN \"subcategory_override\" IS NOT NULL THEN \"subcategory_override\"\n\t\tWHEN \"type\" = 'plaid' THEN \"plaid_subtype\"\n\t\tWHEN \"type\" = 'property' THEN 'property'\n\t\tWHEN \"type\" = 'vehicle' THEN 'vehicle'\n\t\tELSE 'other'\n\tEND\n) STORED;\n\n\nALTER TABLE \"account\"\nALTER COLUMN \"type\" SET NOT NULL,\nALTER COLUMN \"classification\" SET NOT NULL,\nALTER COLUMN \"valuation_type\" SET NOT NULL,\nALTER COLUMN \"category\" SET NOT NULL,\nALTER COLUMN \"subcategory\" SET NOT NULL;\n\nALTER TABLE \"account\"\nDROP COLUMN \"subtype_id\",\nDROP COLUMN \"type_id\";\n\n-- DropTable\nDROP TABLE \"account_subtype\";\n\n-- DropTable\nDROP TABLE \"account_type\";"
  },
  {
    "path": "prisma/migrations/20211203180216_security_pricing/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"security\" ADD COLUMN     \"pricing_last_synced_at\" TIMESTAMPTZ(6);\n\n-- Enable timescale\nCREATE EXTENSION IF NOT EXISTS timescaledb;\n\n-- CreateTable\nCREATE TABLE \"security_pricing\" (\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"security_id\" INTEGER NOT NULL,\n    \"date\" DATE NOT NULL,\n    \"price_close\" DECIMAL(18,10) NOT NULL,\n\n    CONSTRAINT \"security_pricing_pkey\" PRIMARY KEY (\"security_id\",\"date\")\n);\n\n-- AddForeignKey\nALTER TABLE \"security_pricing\" ADD CONSTRAINT \"security_pricing_security_id_fkey\" FOREIGN KEY (\"security_id\") REFERENCES \"security\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- Convert security_pricing to hypertable\nSELECT create_hypertable('security_pricing', 'date', if_not_exists => true, migrate_data => true);"
  },
  {
    "path": "prisma/migrations/20211204053810_account_balance_hypertable/migration.sql",
    "content": "/*\n  Warnings:\n\n  - The primary key for the `account_balance` table will be changed. If it partially fails, the table could be left without primary key constraint.\n  - You are about to drop the column `id` on the `account_balance` table. All the data in the column will be lost.\n\n*/\n-- DropIndex\nDROP INDEX \"account_balance_account_id_date_key\";\n\n-- AlterTable\nALTER TABLE \"account_balance\" DROP CONSTRAINT \"account_balance_pkey\",\nDROP COLUMN \"id\",\nADD CONSTRAINT \"account_balance_pkey\" PRIMARY KEY (\"account_id\", \"date\");\n\n-- Convert account_balance to hypertable\nSELECT create_hypertable('account_balance', 'date', if_not_exists => true, migrate_data => true);"
  },
  {
    "path": "prisma/migrations/20211207192726_add_valuation_generated_cols/migration.sql",
    "content": "\n-- CreateEnum\nCREATE TYPE \"ValuationType\" AS ENUM ('PLAID_TRANSACTION', 'PLAID_INVESTMENT_TRANSACTION', 'KBB_VALUATION', 'ZILLOW_VALUATION', 'USER_VALUATION');\n\n-- Integrate new enum\nALTER TABLE \"account\"\nALTER \"valuation_type\" DROP NOT NULL;\n\nUPDATE \"account\"\nSET \"valuation_type\" = NULL;\n\nALTER TABLE \"account\"\nDROP COLUMN \"valuation_type\",\nADD COLUMN \"valuation_type\" \"ValuationType\";\n\n-- Populate enums\nUPDATE \"account\"\nSET \"valuation_type\" = 'PLAID_TRANSACTION'\nWHERE \"type\" = 'plaid' AND \"plaid_type\" <> 'investment';\n\nUPDATE \"account\"\nSET \"valuation_type\" = 'PLAID_INVESTMENT_TRANSACTION'\nWHERE \"type\" = 'plaid' AND \"plaid_type\" = 'investment';\n\nUPDATE \"account\"\nSET \"valuation_type\" = 'USER_VALUATION'\nWHERE \"type\" <> 'plaid';\n\n-- Add generated columns\nALTER TABLE \"account\"\nADD COLUMN  \"valuation_source\" TEXT GENERATED ALWAYS AS (\n\tCASE \n\t\tWHEN \"valuation_type\" = 'PLAID_TRANSACTION' OR \"valuation_type\" = 'PLAID_INVESTMENT_TRANSACTION' THEN 'plaid'\n    WHEN \"valuation_type\" = 'USER_VALUATION' THEN 'user'\n    WHEN \"valuation_type\" = 'KBB_VALUATION' THEN 'kbb'\n    WHEN \"valuation_type\" = 'ZILLOW_VALUATION' THEN 'zillow'\n\t\tELSE 'other'\n\tEND\n) STORED;\n\nALTER TABLE \"account\"\nADD COLUMN  \"valuation_method\" TEXT GENERATED ALWAYS AS (\n\tCASE \n\t\tWHEN \"valuation_type\" = 'PLAID_TRANSACTION' THEN 'transaction'\n    WHEN \"valuation_type\" = 'PLAID_INVESTMENT_TRANSACTION' THEN 'investment-transaction'\n    ELSE 'valuation'\n\tEND\n) STORED;\n\n-- Add non-null constraints\nALTER TABLE \"account\"\nALTER COLUMN \"valuation_method\" SET NOT NULL,\nALTER COLUMN \"valuation_source\" SET NOT NULL,\nALTER COLUMN \"valuation_type\" SET NOT NULL;\n"
  },
  {
    "path": "prisma/migrations/20211208162929_transaction_date/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `effective_date` on the `transaction` table. All the data in the column will be lost.\n\n*/\n-- DropIndex\nDROP INDEX \"transaction_account_id_effective_date_idx\";\n\n-- AlterTable\nALTER TABLE \"transaction\" DROP COLUMN \"effective_date\";\n\n-- CreateIndex\nCREATE INDEX \"transaction_account_id_date_idx\" ON \"transaction\"(\"account_id\", \"date\");\n"
  },
  {
    "path": "prisma/migrations/20211209041710_remove_initial_txn/migration.sql",
    "content": "-- now that we are using the new balance calculation, remove the old initial transactions\nDELETE FROM transaction WHERE name = 'INITIAL_TRANSACTION';\nDELETE FROM investment_transaction WHERE name = 'INITIAL_TRANSACTION';"
  },
  {
    "path": "prisma/migrations/20211209050532_update_fns/migration.sql",
    "content": "-- no longer used\nDROP FUNCTION IF EXISTS account_value_type;\n\n-- default to today's date if we don't have any transactions/valuations for the account so it plays nicely with time_bucket_gapfill\nCREATE OR REPLACE FUNCTION account_value_start_date(p_account_id integer) RETURNS date STABLE AS $$\n  SELECT\n    COALESCE(\n      CASE\n        WHEN a.valuation_method = 'valuation' THEN (SELECT MIN(date) FROM valuation WHERE account_id = p_account_id)\n        WHEN a.valuation_method = 'transaction' THEN (SELECT MIN(date) FROM transaction WHERE account_id = p_account_id)\n        WHEN a.valuation_method = 'investment-transaction' THEN (SELECT MIN(date) FROM investment_transaction WHERE account_id = p_account_id)\n        ELSE NULL\n      END, now())\n  FROM\n    account a\n  WHERE\n    a.id = p_account_id\n$$ LANGUAGE SQL;"
  },
  {
    "path": "prisma/migrations/20211211140103_add_institution_id_to_connection/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"account_connection\" ADD COLUMN     \"plaid_institution_id\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20211213211517_account_user_index/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX \"account_user_id_idx\" ON \"account\"(\"user_id\");\n"
  },
  {
    "path": "prisma/migrations/20211214162659_security_pricing_source/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"security_pricing\" ADD COLUMN     \"source\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20211215195518_add_account_start_date/migration.sql",
    "content": "ALTER TABLE \"account\" ADD COLUMN \"start_date\" DATE;\n"
  },
  {
    "path": "prisma/migrations/20211230035441_account_sync_status/migration.sql",
    "content": "/*\n  Warnings:\n\n  - The values [SYNCING] on the enum `AccountConnectionStatus` will be removed. If these variants are still used in the database, this will fail.\n\n*/\n-- CreateEnum\nCREATE TYPE \"AccountSyncStatus\" AS ENUM ('IDLE', 'PENDING', 'SYNCING');\n\n-- AlterEnum\nBEGIN;\nCREATE TYPE \"AccountConnectionStatus_new\" AS ENUM ('OK', 'ERROR', 'DISCONNECTED');\nALTER TABLE \"account_connection\" ALTER COLUMN \"status\" DROP DEFAULT;\nALTER TABLE \"account_connection\" ALTER COLUMN \"status\" TYPE \"AccountConnectionStatus_new\" USING (\"status\"::text::\"AccountConnectionStatus_new\");\nALTER TYPE \"AccountConnectionStatus\" RENAME TO \"AccountConnectionStatus_old\";\nALTER TYPE \"AccountConnectionStatus_new\" RENAME TO \"AccountConnectionStatus\";\nDROP TYPE \"AccountConnectionStatus_old\";\nALTER TABLE \"account_connection\" ALTER COLUMN \"status\" SET DEFAULT 'OK';\nCOMMIT;\n\n-- AlterTable\nALTER TABLE \"account\" ADD COLUMN     \"sync_status\" \"AccountSyncStatus\" NOT NULL DEFAULT E'IDLE';\n\n-- AlterTable\nALTER TABLE \"account_connection\" ADD COLUMN     \"sync_status\" \"AccountSyncStatus\" NOT NULL DEFAULT E'IDLE';\n"
  },
  {
    "path": "prisma/migrations/20220106215040_add_mask_to_account/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"account\" ADD COLUMN     \"mask\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20220107170334_hypertable_chunk_size_tuning/migration.sql",
    "content": "-- Tune timescale chunk sizes\n\n-- account_balance -> 30d\nCREATE TEMP TABLE account_balance_data ON COMMIT DROP AS (\n  SELECT * FROM account_balance\n);\n\nSELECT drop_chunks('account_balance', interval '0 days');\nTRUNCATE account_balance;\n\nSELECT set_chunk_time_interval('account_balance', interval '30 days');\n\nINSERT INTO account_balance\nSELECT * FROM account_balance_data;\n\n\n-- security_pricing -> 30d\nCREATE TEMP TABLE security_pricing_data ON COMMIT DROP AS (\n  SELECT * FROM security_pricing\n);\n\nSELECT drop_chunks('security_pricing', interval '0 days');\nTRUNCATE security_pricing;\n\nSELECT set_chunk_time_interval('security_pricing', interval '30 days');\n\nINSERT INTO security_pricing\nSELECT * FROM security_pricing_data;"
  },
  {
    "path": "prisma/migrations/20220112171128_update_fn/migration.sql",
    "content": "-- update the account_value_start_date to be the date prior to the first transaction/investment_transaction\n\nCREATE OR REPLACE FUNCTION account_value_start_date(p_account_id integer) RETURNS date STABLE AS $$\n  SELECT\n    COALESCE(\n      CASE\n        WHEN a.valuation_method = 'valuation' THEN (SELECT MIN(date) FROM valuation WHERE account_id = p_account_id)\n        WHEN a.valuation_method = 'transaction' THEN (SELECT MIN(date) - 1 FROM transaction WHERE account_id = p_account_id)\n        WHEN a.valuation_method = 'investment-transaction' THEN (SELECT MIN(date) - 1 FROM investment_transaction WHERE account_id = p_account_id)\n        ELSE NULL\n      END, now())\n  FROM\n    account a\n  WHERE\n    a.id = p_account_id\n$$ LANGUAGE SQL;"
  },
  {
    "path": "prisma/migrations/20220121175453_account_liability_json/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"account\" ADD COLUMN     \"plaid_liability\" JSONB;\n"
  },
  {
    "path": "prisma/migrations/20220124193549_add_plaid_valuation_valuation_type/migration.sql",
    "content": "-- AlterEnum\nALTER TYPE \"ValuationType\" ADD VALUE 'PLAID_VALUATION';\n\n"
  },
  {
    "path": "prisma/migrations/20220124211317_update_valuation_types_and_sources/migration.sql",
    "content": "-- Update generated column\n\nALTER TABLE \"account\" DROP COLUMN \"valuation_source\";\n\nALTER TABLE \"account\"\nADD COLUMN  \"valuation_source\" TEXT GENERATED ALWAYS AS (\n\tCASE \n    WHEN \"valuation_type\" = 'PLAID_TRANSACTION'\n        OR \"valuation_type\" = 'PLAID_INVESTMENT_TRANSACTION'\n        OR \"valuation_type\" = 'PLAID_VALUATION'\n        THEN 'plaid'\n    WHEN \"valuation_type\" = 'USER_VALUATION' THEN 'user'\n    WHEN \"valuation_type\" = 'KBB_VALUATION' THEN 'kbb'\n    WHEN \"valuation_type\" = 'ZILLOW_VALUATION' THEN 'zillow'\n\t\tELSE 'other'\n\tEND\n) STORED;\n\nALTER TABLE \"account\" ALTER COLUMN \"valuation_source\" SET NOT NULL;"
  },
  {
    "path": "prisma/migrations/20220125211038_add_unique_constraint_to_valuations/migration.sql",
    "content": "-- Remove duplicates\nDELETE FROM valuation a\nWHERE EXISTS (\n\t\tSELECT\n\t\t\t1\n\t\tFROM\n\t\t\tvaluation b\n\t\tWHERE\n\t\t\tb.account_id = a.account_id\n\t\t\tAND b.source = a.source\n\t\t\tAND b.date = a.date\n\t\t\tAND b.id < a.id);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"valuation_account_id_source_date_key\" ON \"valuation\"(\"account_id\", \"source\", \"date\");\n"
  },
  {
    "path": "prisma/migrations/20220202184342_account_balances_gapfilled_fn/migration.sql",
    "content": "-- returns a table of balances for each account in the date series\n-- this is the foundation for all balance/net-worth calculations\n\nCREATE OR REPLACE FUNCTION account_balances_gapfilled(p_start date, p_end date, p_interval interval, p_account_ids integer[]) RETURNS TABLE(account_id integer, date date, balance bigint) LANGUAGE SQL STABLE AS $$\n  WITH account_balances_gapfilled AS (\n    -- fill in balance for start of range\n    (\n      SELECT\n        ab.account_id,\n        p_start::date AS date,\n        last(ab.balance, ab.date) AS balance\n      FROM\n        account_balance ab\n      WHERE\n        ab.account_id = ANY(p_account_ids)\n        AND ab.date <= p_start\n      GROUP BY\n        ab.account_id\n    )\n    UNION\n    -- fill in balance for end of range\n    (\n      SELECT\n        ab.account_id,\n        p_end::date AS date,\n        last(ab.balance, ab.date) AS balance\n      FROM\n        account_balance ab\n      WHERE\n        ab.account_id = ANY(p_account_ids)\n        AND ab.date <= p_end\n      GROUP BY\n        ab.account_id\n    )\n    UNION\n    -- this gapfill covers accounts who have at least 1 balance record in the range\n    (\n      SELECT\n        ab.account_id,\n        time_bucket_gapfill(p_interval, ab.date) AS date,\n        locf(\n          first(ab.balance, ab.date),\n          COALESCE(\n            (SELECT balance FROM account_balance WHERE date < p_start AND account_id = ab.account_id ORDER BY date DESC LIMIT 1),\n            (SELECT balance FROM account_balance WHERE account_id = ab.account_id ORDER BY date ASC LIMIT 1)\n          )\n        ) AS balance\n      FROM\n        account_balance ab\n      WHERE\n        ab.account_id = ANY(p_account_ids)\n        AND ab.date BETWEEN p_start AND p_end\n      GROUP BY\n        1, 2\n    )\n    UNION\n    -- this gapfill covers accounts that either (a) have balance records outside range OR (b) don't have any balance records\n    (\n      SELECT\n        ab.account_id,\n        time_bucket_gapfill(p_interval, ab.date, p_start, (p_end::date + interval '1d')::date) AS date,\n        locf(first(ab.balance, ab.date)) AS balance\n      FROM (\n        SELECT\n          fb.account_id,\n          p_start::date AS date,\n          fb.balance\n        FROM (\n          SELECT\n            a.id AS account_id,\n            COALESCE(\n              (SELECT balance FROM account_balance WHERE account_id = a.id AND date < p_start ORDER BY date DESC LIMIT 1),\n              (SELECT balance FROM account_balance WHERE account_id = a.id AND date > p_end ORDER BY date ASC LIMIT 1),\n              a.current_balance\n            ) AS balance\n          FROM\n            account a\n          WHERE\n            a.id = ANY(p_account_ids)\n            AND a.id NOT IN (SELECT DISTINCT account_id FROM account_balance WHERE date BETWEEN p_start AND p_end)\n        ) fb\n      ) ab\n      GROUP BY\n        1, 2\n    )\n  )\n  SELECT DISTINCT ON (abg.account_id, abg.date)\n    abg.account_id,\n    abg.date,\n    CASE\n      WHEN a.start_date IS NOT NULL AND abg.date < a.start_date THEN 0\n      ELSE COALESCE(abg.balance, 0)\n    END AS balance\n  FROM\n    account_balances_gapfilled abg\n    INNER JOIN account a ON a.id = abg.account_id\n$$;"
  },
  {
    "path": "prisma/migrations/20220203234737_update_fn/migration.sql",
    "content": "CREATE OR REPLACE FUNCTION account_balances_gapfilled(p_start date, p_end date, p_interval interval, p_account_ids integer[]) RETURNS TABLE(account_id integer, date date, balance bigint) LANGUAGE SQL STABLE AS $$\n  WITH account_balances_gapfilled AS (\n    -- fill in balance for start of range\n    (\n      SELECT\n        ab.account_id,\n        p_start::date AS date,\n        last(ab.balance, ab.date) AS balance\n      FROM\n        account_balance ab\n      WHERE\n        ab.account_id = ANY(p_account_ids)\n        AND ab.date <= p_start\n      GROUP BY\n        ab.account_id\n    )\n    UNION\n    -- fill in balance for end of range\n    (\n      SELECT\n        ab.account_id,\n        p_end::date AS date,\n        last(ab.balance, ab.date) AS balance\n      FROM\n        account_balance ab\n      WHERE\n        ab.account_id = ANY(p_account_ids)\n        AND ab.date <= p_end\n      GROUP BY\n        ab.account_id\n    )\n    UNION\n    -- this gapfill covers accounts who have at least 1 balance record in the range\n    (\n      SELECT\n        ab.account_id,\n        time_bucket_gapfill(p_interval, ab.date) AS date,\n        locf(\n          first(ab.balance, ab.date),\n          COALESCE(\n            (SELECT balance FROM account_balance WHERE date < p_start AND account_id = ab.account_id ORDER BY date DESC LIMIT 1),\n            (SELECT balance FROM account_balance WHERE account_id = ab.account_id ORDER BY date ASC LIMIT 1)\n          )\n        ) AS balance\n      FROM\n        account_balance ab\n      WHERE\n        ab.account_id = ANY(p_account_ids)\n        AND ab.date BETWEEN p_start AND p_end\n      GROUP BY\n        1, 2\n    )\n    UNION\n    -- this gapfill covers accounts that either (a) have balance records outside range OR (b) don't have any balance records\n    (\n      SELECT\n        ab.account_id,\n        time_bucket_gapfill(p_interval, ab.date, p_start, (p_end::date + interval '1d')::date) AS date,\n        locf(first(ab.balance, ab.date)) AS balance\n      FROM (\n        SELECT\n          fb.account_id,\n          p_start::date AS date,\n          fb.balance\n        FROM (\n          SELECT\n            a.id AS account_id,\n            COALESCE(\n              (SELECT balance FROM account_balance WHERE account_id = a.id AND date < p_start ORDER BY date DESC LIMIT 1),\n              (SELECT balance FROM account_balance WHERE account_id = a.id AND date > p_end ORDER BY date ASC LIMIT 1),\n              a.current_balance\n            ) AS balance\n          FROM\n            account a\n          WHERE\n            a.id = ANY(p_account_ids)\n            AND a.id NOT IN (SELECT DISTINCT account_id FROM account_balance WHERE date BETWEEN p_start AND p_end)\n        ) fb\n      ) ab\n      GROUP BY\n        1, 2\n    )\n  )\n  SELECT DISTINCT ON (abg.account_id, abg.date)\n    abg.account_id,\n    abg.date,\n    CASE\n      WHEN a.start_date IS NOT NULL AND abg.date < a.start_date THEN 0\n      ELSE COALESCE(abg.balance, 0)\n    END AS balance\n  FROM\n    account_balances_gapfilled abg\n    INNER JOIN account a ON a.id = abg.account_id\n  WHERE\n    abg.date BETWEEN p_start AND p_end\n$$;"
  },
  {
    "path": "prisma/migrations/20220214175713_narrow_transaction_category/migration.sql",
    "content": "/*\n  Warnings:\n\n  - The `category` column on the `transaction` table would be dropped and recreated. This will lead to data loss if there is data in the column.\n\n*/\n-- CreateEnum\nCREATE TYPE \"TransactionCategory\" AS ENUM ('INCOME', 'EXPENSE', 'TRANSFER', 'PAYMENT');\n\n-- AlterTable\nALTER TABLE \"transaction\" DROP COLUMN \"category\",\nADD COLUMN     \"category\" \"TransactionCategory\";\n"
  },
  {
    "path": "prisma/migrations/20220215201534_transaction_remove_subcategory_add_plaid_category/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `subcategory` on the `transaction` table. All the data in the column will be lost.\n\n*/\n-- AlterTable\nALTER TABLE \"transaction\" DROP COLUMN \"subcategory\",\nADD COLUMN     \"plaid_category\" TEXT[];\n"
  },
  {
    "path": "prisma/migrations/20220215212216_add_transaction_indexes/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX \"transaction_amount_idx\" ON \"transaction\"(\"amount\");\n\n-- CreateIndex\nCREATE INDEX \"transaction_category_idx\" ON \"transaction\"(\"category\");\n"
  },
  {
    "path": "prisma/migrations/20220217040807_add_merchant_name_to_transactions/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"transaction\" ADD COLUMN     \"merchant_name\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20220228233043_change_money_type/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"account\" ALTER COLUMN \"current_balance\" SET DATA TYPE DECIMAL(19,4) USING current_balance::numeric / 100;\nALTER TABLE \"account\" ALTER COLUMN \"available_balance\" SET DATA TYPE DECIMAL(19,4) USING available_balance::numeric / 100;\n\n-- AlterTable\nALTER TABLE \"account_balance\" ALTER COLUMN \"balance\" SET DATA TYPE DECIMAL(19,4) USING balance::numeric / 100;\nALTER TABLE \"account_balance\" ALTER COLUMN \"inflows\" SET DEFAULT 0;\nALTER TABLE \"account_balance\" ALTER COLUMN \"inflows\" SET DATA TYPE DECIMAL(19,4) USING inflows::numeric / 100;\nALTER TABLE \"account_balance\" ALTER COLUMN \"outflows\" SET DEFAULT 0;\nALTER TABLE \"account_balance\" ALTER COLUMN \"outflows\" SET DATA TYPE DECIMAL(19,4) USING outflows::numeric / 100;\n\n-- AlterTable\nALTER TABLE \"holding\" ALTER COLUMN \"value\" SET DATA TYPE DECIMAL(19,4) USING value::numeric / 100;\nALTER TABLE \"holding\" ALTER COLUMN \"cost_basis\" SET DATA TYPE DECIMAL(23,8) USING cost_basis::numeric / 100;\nALTER TABLE \"holding\" ALTER COLUMN \"price\" SET DATA TYPE DECIMAL(23,8) USING price::numeric / 100;\n\n-- AlterTable\nALTER TABLE \"transaction\" DROP COLUMN \"type\";\nALTER TABLE \"transaction\" ALTER COLUMN \"amount\" SET DATA TYPE DECIMAL(19,4) USING amount::numeric / 100;\nALTER TABLE \"transaction\" ADD COLUMN \"type\" \"TransactionType\" NOT NULL GENERATED ALWAYS AS (CASE WHEN amount < 0 THEN 'INFLOW'::\"TransactionType\" ELSE 'OUTFLOW'::\"TransactionType\" END) STORED;\n\n-- AlterTable\nALTER TABLE \"investment_transaction\" DROP COLUMN \"type\";\nALTER TABLE \"investment_transaction\" ALTER COLUMN \"amount\" SET DATA TYPE DECIMAL(19,4) USING amount::numeric / 100;\nALTER TABLE \"investment_transaction\" ALTER COLUMN \"price\" SET DATA TYPE DECIMAL(23,8) USING price::numeric / 100;\nALTER TABLE \"investment_transaction\" ADD COLUMN \"type\" \"TransactionType\" NOT NULL GENERATED ALWAYS AS (CASE WHEN amount < 0 THEN 'INFLOW'::\"TransactionType\" ELSE 'OUTFLOW'::\"TransactionType\" END) STORED;\n\n-- AlterTable\nALTER TABLE \"security_pricing\" ALTER COLUMN \"price_close\" SET DATA TYPE DECIMAL(23,8);\n\n-- AlterTable\nALTER TABLE \"valuation\" ALTER COLUMN \"amount\" SET DATA TYPE DECIMAL(19,4) USING amount::numeric / 100;\n\n-- Update functions\nDROP FUNCTION IF EXISTS calculate_account_balances;\nDROP FUNCTION IF EXISTS account_balances_gapfilled(date,date,interval,integer[]);\nCREATE OR REPLACE FUNCTION public.account_balances_gapfilled(p_start date, p_end date, p_interval interval, p_account_ids integer[])\n RETURNS TABLE(account_id integer, date date, balance numeric)\n LANGUAGE sql\n STABLE\nAS $$\n  WITH account_balances_gapfilled AS (\n    -- fill in balance for start of range\n    (\n      SELECT\n        ab.account_id,\n        p_start::date AS date,\n        last(ab.balance, ab.date) AS balance\n      FROM\n        account_balance ab\n      WHERE\n        ab.account_id = ANY(p_account_ids)\n        AND ab.date <= p_start\n      GROUP BY\n        ab.account_id\n    )\n    UNION\n    -- fill in balance for end of range\n    (\n      SELECT\n        ab.account_id,\n        p_end::date AS date,\n        last(ab.balance, ab.date) AS balance\n      FROM\n        account_balance ab\n      WHERE\n        ab.account_id = ANY(p_account_ids)\n        AND ab.date <= p_end\n      GROUP BY\n        ab.account_id\n    )\n    UNION\n    -- this gapfill covers accounts who have at least 1 balance record in the range\n    (\n      SELECT\n        ab.account_id,\n        time_bucket_gapfill(p_interval, ab.date) AS date,\n        locf(\n          first(ab.balance, ab.date),\n          COALESCE(\n            (SELECT balance FROM account_balance WHERE date < p_start AND account_id = ab.account_id ORDER BY date DESC LIMIT 1),\n            (SELECT balance FROM account_balance WHERE account_id = ab.account_id ORDER BY date ASC LIMIT 1)\n          )\n        ) AS balance\n      FROM\n        account_balance ab\n      WHERE\n        ab.account_id = ANY(p_account_ids)\n        AND ab.date BETWEEN p_start AND p_end\n      GROUP BY\n        1, 2\n    )\n    UNION\n    -- this gapfill covers accounts that either (a) have balance records outside range OR (b) don't have any balance records\n    (\n      SELECT\n        ab.account_id,\n        time_bucket_gapfill(p_interval, ab.date, p_start, (p_end::date + interval '1d')::date) AS date,\n        locf(first(ab.balance, ab.date)) AS balance\n      FROM (\n        SELECT\n          fb.account_id,\n          p_start::date AS date,\n          fb.balance\n        FROM (\n          SELECT\n            a.id AS account_id,\n            COALESCE(\n              (SELECT balance FROM account_balance WHERE account_id = a.id AND date < p_start ORDER BY date DESC LIMIT 1),\n              (SELECT balance FROM account_balance WHERE account_id = a.id AND date > p_end ORDER BY date ASC LIMIT 1),\n              a.current_balance\n            ) AS balance\n          FROM\n            account a\n          WHERE\n            a.id = ANY(p_account_ids)\n            AND a.id NOT IN (SELECT DISTINCT account_id FROM account_balance WHERE date BETWEEN p_start AND p_end)\n        ) fb\n      ) ab\n      GROUP BY\n        1, 2\n    )\n  )\n  SELECT DISTINCT ON (abg.account_id, abg.date)\n    abg.account_id,\n    abg.date,\n    CASE\n      WHEN a.start_date IS NOT NULL AND abg.date < a.start_date THEN 0\n      ELSE COALESCE(abg.balance, 0)\n    END AS balance\n  FROM\n    account_balances_gapfilled abg\n    INNER JOIN account a ON a.id = abg.account_id\n  WHERE\n    abg.date BETWEEN p_start AND p_end\n$$;"
  },
  {
    "path": "prisma/migrations/20220302181536_add_price_as_of_to_security_pricing/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"security_pricing\" ADD COLUMN     \"price_as_of\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP;\n"
  },
  {
    "path": "prisma/migrations/20220307200633_remove_price_from_holding/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `price` on the `holding` table. All the data in the column will be lost.\n  - You are about to drop the column `price_as_of` on the `holding` table. All the data in the column will be lost.\n\n*/\n-- AlterTable\nALTER TABLE \"holding\" DROP COLUMN \"price\",\nDROP COLUMN \"price_as_of\";\n"
  },
  {
    "path": "prisma/migrations/20220307211701_valuation_trigger/migration.sql",
    "content": "-- the valuation_changed trigger is used to keep `account.start_date` and `account.current_balance` in sync with the account's valuations\nCREATE OR REPLACE FUNCTION valuation_changed() RETURNS TRIGGER LANGUAGE plpgsql AS $$\n  BEGIN\n    UPDATE account AS a\n    SET \n      start_date = account_value_start_date(a.id),\n      current_balance = (SELECT v.amount FROM valuation v WHERE v.account_id = a.id ORDER BY v.date DESC LIMIT 1)\n    WHERE \n      (a.id = NEW.account_id OR a.id = OLD.account_id) \n      AND a.valuation_method = 'valuation';\n    RETURN NULL;\n  END;\n$$;\n\nCREATE OR REPLACE TRIGGER valuation_changed\n  AFTER INSERT OR UPDATE OF account_id, amount, date OR DELETE\n  ON valuation\n  FOR EACH ROW\n  EXECUTE FUNCTION valuation_changed();"
  },
  {
    "path": "prisma/migrations/20220311165323_add_shares_per_contract_to_security/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"security\" ADD COLUMN     \"shares_per_contract\" DECIMAL(36,18);\n\n-- Remove incorrect derivative pricing from Plaid\nDELETE FROM \"security_pricing\" sp USING \"security\" s\nWHERE s.id = sp.security_id\n\tAND s. \"plaid_type\" = 'derivative'\n\tAND sp. \"source\" = 'plaid';"
  },
  {
    "path": "prisma/migrations/20220315172110_institution/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"Provider\" AS ENUM ('PLAID', 'FINICITY');\n\n-- CreateTable\nCREATE TABLE \"institution\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"provider\" \"Provider\" NOT NULL,\n    \"provider_id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"logo\" TEXT,\n    \"primary_color\" TEXT,\n    \"data\" JSONB NOT NULL,\n\n    CONSTRAINT \"institution_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"institution_provider_provider_id_key\" ON \"institution\"(\"provider\", \"provider_id\");\n"
  },
  {
    "path": "prisma/migrations/20220316200652_reset_plaid_derivative_prices/migration.sql",
    "content": "-- Remove incorrect derivative pricing from Plaid\nDELETE FROM \"security_pricing\" sp USING \"security\" s\nWHERE s.id = sp.security_id\n\tAND s. \"plaid_type\" = 'derivative'\n\tAND sp. \"source\" = 'plaid';\n\n-- Set `pricing_last_synced_at` to null so that derivatives re-sync from Plaid\nUPDATE security SET pricing_last_synced_at = NULL WHERE plaid_type = 'derivative';"
  },
  {
    "path": "prisma/migrations/20220317191949_reset_plaid_derivative_prices_again/migration.sql",
    "content": "-- Remove incorrect derivative pricing from Plaid\nDELETE FROM \"security_pricing\" sp USING \"security\" s\nWHERE s.id = sp.security_id\n\tAND s. \"plaid_type\" = 'derivative'\n\tAND sp. \"source\" = 'plaid';\n\n-- Set `pricing_last_synced_at` to null so that derivatives re-sync from Plaid\nUPDATE security SET pricing_last_synced_at = NULL WHERE plaid_type = 'derivative';"
  },
  {
    "path": "prisma/migrations/20220323203441_multi_provider_updates/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[account_connection_id,finicity_account_id]` on the table `account` will be added. If there are existing duplicate values, this will fail.\n  - A unique constraint covering the columns `[finicity_holding_id]` on the table `holding` will be added. If there are existing duplicate values, this will fail.\n  - A unique constraint covering the columns `[finicity_transaction_id]` on the table `investment_transaction` will be added. If there are existing duplicate values, this will fail.\n  - A unique constraint covering the columns `[finicity_transaction_id]` on the table `transaction` will be added. If there are existing duplicate values, this will fail.\n  - Changed the type of `type` on the `account` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.\n  - Changed the type of `valuation_method` on the `account` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.\n  - Changed the type of `valuation_source` on the `account` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.\n  - Changed the type of `type` on the `account_connection` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.\n\n*/\n-- CreateEnum\nCREATE TYPE \"AccountConnectionType\" AS ENUM ('plaid', 'finicity');\n\n-- CreateEnum\nCREATE TYPE \"AccountType\" AS ENUM ('plaid', 'finicity', 'property', 'vehicle', 'other');\n\n-- CreateEnum\nCREATE TYPE \"ValuationSource\" AS ENUM ('PLAID', 'FINICITY', 'KBB', 'ZILLOW', 'USER');\n\n-- CreateEnum\nCREATE TYPE \"ValuationMethod\" AS ENUM ('TRANSACTION', 'INVESTMENT_TRANSACTION', 'VALUATION');\n\n-- AlterTable\nALTER TABLE \"account\"\nADD COLUMN \"finicity_account_id\" TEXT,\nADD COLUMN \"finicity_detail\" JSONB,\nADD COLUMN \"finicity_type\" TEXT,\nDROP COLUMN \"valuation_method\",\nDROP COLUMN \"valuation_source\",\nDROP COLUMN \"category\",\nDROP COLUMN \"subcategory\";\n\nALTER TABLE \"account\" ALTER COLUMN \"type\" TYPE \"AccountType\" USING \"type\"::\"AccountType\";\n\n-- Due to a limitation in postgres, we must recreate the enum if we're going to use the new values in this transaction (instead of `ALTER TYPE <enum_name> ADD VALUE <new_value>`).\nALTER TYPE \"ValuationType\" RENAME TO \"ValuationType_old\";\nCREATE TYPE \"ValuationType\" AS ENUM ('PLAID_TRANSACTION', 'PLAID_INVESTMENT_TRANSACTION', 'PLAID_VALUATION', 'FINICITY_TRANSACTION', 'FINICITY_INVESTMENT_TRANSACTION', 'KBB_VALUATION', 'ZILLOW_VALUATION', 'USER_VALUATION');\nALTER TABLE \"account\" ALTER COLUMN \"valuation_type\" TYPE \"ValuationType\" USING \"valuation_type\"::text::\"ValuationType\";\nDROP TYPE \"ValuationType_old\";\n\n-- update valuation_method\nALTER TABLE \"account\"\nADD COLUMN  \"valuation_method\" \"ValuationMethod\" GENERATED ALWAYS AS (\n\tCASE \n\t\tWHEN \"valuation_type\" IN ('PLAID_TRANSACTION', 'FINICITY_TRANSACTION') THEN 'TRANSACTION'::\"ValuationMethod\"\n    WHEN \"valuation_type\" IN ('PLAID_INVESTMENT_TRANSACTION', 'FINICITY_INVESTMENT_TRANSACTION') THEN 'INVESTMENT_TRANSACTION'::\"ValuationMethod\"\n    WHEN \"valuation_type\" IN ('PLAID_VALUATION', 'KBB_VALUATION', 'ZILLOW_VALUATION', 'USER_VALUATION') THEN 'VALUATION'::\"ValuationMethod\"\n\tEND\n) STORED;\nALTER TABLE \"account\" ALTER COLUMN \"valuation_method\" SET NOT NULL;\n\n-- update valuation_source\nALTER TABLE \"account\"\nADD COLUMN  \"valuation_source\" \"ValuationSource\" GENERATED ALWAYS AS (\n\tCASE \n    WHEN \"valuation_type\" IN ('PLAID_TRANSACTION', 'PLAID_INVESTMENT_TRANSACTION', 'PLAID_VALUATION') THEN 'PLAID'::\"ValuationSource\"\n    WHEN \"valuation_type\" IN ('FINICITY_TRANSACTION', 'FINICITY_INVESTMENT_TRANSACTION') THEN 'FINICITY'::\"ValuationSource\"\n    WHEN \"valuation_type\" = 'KBB_VALUATION' THEN 'KBB'::\"ValuationSource\"\n    WHEN \"valuation_type\" = 'ZILLOW_VALUATION' THEN 'ZILLOW'::\"ValuationSource\"\n    WHEN \"valuation_type\" = 'USER_VALUATION' THEN 'USER'::\"ValuationSource\"\n\tEND\n) STORED;\nALTER TABLE \"account\" ALTER COLUMN \"valuation_source\" SET NOT NULL;\n\n-- update category\nALTER TABLE \"account\"\nADD COLUMN  \"category\" TEXT GENERATED ALWAYS AS (\n\tCASE \n\t\tWHEN \"type\" = 'plaid' AND \"plaid_type\" IN ('depository') THEN 'cash'\n\t\tWHEN \"type\" = 'plaid' AND \"plaid_type\" IN ('investment' ,'brokerage') THEN 'investment'\n\t\tWHEN \"type\" = 'plaid' AND \"plaid_type\" IN ('loan') THEN 'loan'\n\t\tWHEN \"type\" = 'plaid' AND \"plaid_type\" IN ('credit') THEN 'credit'\n\t\tWHEN \"type\" = 'property' THEN 'property'\n\t\tWHEN \"type\" = 'vehicle' THEN 'vehicle'\n\t\tELSE 'other'\n\tEND\n) STORED;\nALTER TABLE \"account\" ALTER COLUMN \"category\" SET NOT NULL;\n\n-- update subcategory\nALTER TABLE \"account\"\nADD COLUMN  \"subcategory\" TEXT GENERATED ALWAYS AS (\n\tCASE \n\t\tWHEN \"subcategory_override\" IS NOT NULL THEN \"subcategory_override\"\n\t\tWHEN \"type\" = 'plaid' THEN \"plaid_subtype\"\n\t\tWHEN \"type\" = 'property' THEN 'property'\n\t\tWHEN \"type\" = 'vehicle' THEN 'vehicle'\n\t\tELSE 'other'\n\tEND\n) STORED;\nALTER TABLE \"account\" ALTER COLUMN \"subcategory\" SET NOT NULL;\n\n-- AlterTable\nALTER TABLE \"account_connection\" \nADD COLUMN \"finicity_institution_id\" TEXT,\nADD COLUMN \"finicity_institution_login_id\" TEXT;\n\nALTER TABLE \"account_connection\" ALTER COLUMN \"type\" TYPE \"AccountConnectionType\" USING \"type\"::\"AccountConnectionType\";\n\n-- AlterTable\nALTER TABLE \"holding\" ADD COLUMN \"finicity_holding_id\" TEXT;\n\n-- AlterTable\nALTER TABLE \"investment_transaction\" \nADD COLUMN \"finicity_investment_transaction_type\" TEXT,\nADD COLUMN \"finicity_transaction_id\" TEXT;\n\n-- AlterTable\nALTER TABLE \"transaction\" \nADD COLUMN \"finicity_categorization\" JSONB,\nADD COLUMN \"finicity_transaction_id\" TEXT,\nADD COLUMN \"finicity_type\" TEXT;\n\n-- AlterTable\nALTER TABLE \"user\" ADD COLUMN \"finicity_customer_id\" TEXT;\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"account_account_connection_id_finicity_account_id_key\" ON \"account\"(\"account_connection_id\", \"finicity_account_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"holding_finicity_holding_id_key\" ON \"holding\"(\"finicity_holding_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"investment_transaction_finicity_transaction_id_key\" ON \"investment_transaction\"(\"finicity_transaction_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"transaction_finicity_transaction_id_key\" ON \"transaction\"(\"finicity_transaction_id\");\n"
  },
  {
    "path": "prisma/migrations/20220323212807_fix_function/migration.sql",
    "content": "CREATE OR REPLACE FUNCTION public.account_value_start_date(p_account_id integer) RETURNS date LANGUAGE sql STABLE AS $$\n  SELECT\n    COALESCE(\n      CASE\n        WHEN a.valuation_method = 'VALUATION' THEN (SELECT MIN(date) FROM valuation WHERE account_id = p_account_id)\n        WHEN a.valuation_method = 'TRANSACTION' THEN (SELECT MIN(date) - 1 FROM transaction WHERE account_id = p_account_id)\n        WHEN a.valuation_method = 'INVESTMENT_TRANSACTION' THEN (SELECT MIN(date) - 1 FROM investment_transaction WHERE account_id = p_account_id)\n        ELSE NULL\n      END, now())\n  FROM\n    account a\n  WHERE\n    a.id = p_account_id\n$$;\n\n\nCREATE OR REPLACE FUNCTION valuation_changed() RETURNS TRIGGER LANGUAGE plpgsql AS $$\n  BEGIN\n    UPDATE account AS a\n    SET\n      start_date = account_value_start_date(a.id),\n      current_balance = (SELECT v.amount FROM valuation v WHERE v.account_id = a.id ORDER BY v.date DESC LIMIT 1)\n    WHERE\n      (a.id = NEW.account_id OR a.id = OLD.account_id)\n      AND a.valuation_method = 'VALUATION';\n    RETURN NULL;\n  END;\n$$;"
  },
  {
    "path": "prisma/migrations/20220411193518_stop_generating_and_enumize_account_category/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"AccountCategory\" AS ENUM ('cash', 'investment', 'crypto', 'property', 'vehicle', 'valuable', 'loan', 'mortgage', 'credit', 'other');\n\n-- Add new column with temporary suffix\nALTER TABLE \"account\" ADD COLUMN \"category_tmp\" \"AccountCategory\" NOT NULL DEFAULT E'other';\n\n-- Populate new column with enum values\nUPDATE \"account\" SET \"category_tmp\" = \"category\"::\"AccountCategory\";\n\n-- Drop old column\nALTER TABLE \"account\" DROP COLUMN \"category\";\n\n-- Rename new column to take its place\nALTER TABLE \"account\" RENAME COLUMN \"category_tmp\" TO \"category\";"
  },
  {
    "path": "prisma/migrations/20220426190758_add_url_and_logo_url_to_institution/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"institution\" ADD COLUMN     \"logo_url\" TEXT,\nADD COLUMN     \"url\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20220504231954_finicity_updates/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `finicity_holding_id` on the `holding` table. All the data in the column will be lost.\n  - A unique constraint covering the columns `[finicity_position_id]` on the table `holding` will be added. If there are existing duplicate values, this will fail.\n  - A unique constraint covering the columns `[finicity_security_id,finicity_security_id_type]` on the table `security` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- DropIndex\nDROP INDEX \"holding_finicity_holding_id_key\";\n\n-- AlterTable\nALTER TABLE \"holding\" RENAME COLUMN \"finicity_holding_id\" TO \"finicity_position_id\";\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"holding_finicity_position_id_key\" ON \"holding\"(\"finicity_position_id\");\n\n-- AlterTable\nALTER TABLE \"security\" ADD COLUMN     \"finicity_security_id\" TEXT,\nADD COLUMN     \"finicity_security_id_type\" TEXT;\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"security_finicity_security_id_finicity_security_id_type_key\" ON \"security\"(\"finicity_security_id\", \"finicity_security_id_type\");\n"
  },
  {
    "path": "prisma/migrations/20220518005502_finicity_customer_id_uniqueness/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[finicity_customer_id]` on the table `user` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- CreateIndex\nCREATE UNIQUE INDEX \"user_finicity_customer_id_key\" ON \"user\"(\"finicity_customer_id\");\n"
  },
  {
    "path": "prisma/migrations/20220519192445_institution_refactor/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `data` on the `institution` table. All the data in the column will be lost.\n  - You are about to drop the column `provider` on the `institution` table. All the data in the column will be lost.\n  - You are about to drop the column `provider_id` on the `institution` table. All the data in the column will be lost.\n\n*/\n-- CreateTable\nCREATE TABLE \"provider_institution\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"provider\" \"Provider\" NOT NULL,\n    \"provider_id\" TEXT NOT NULL,\n    \"institution_id\" INTEGER,\n    \"rank\" INTEGER NOT NULL DEFAULT 0,\n    \"name\" TEXT NOT NULL,\n    \"url\" TEXT,\n    \"logo\" TEXT,\n    \"logo_url\" TEXT,\n    \"primary_color\" TEXT,\n    \"data\" JSONB,\n\n    CONSTRAINT \"provider_institution_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"provider_institution_provider_provider_id_key\" ON \"provider_institution\"(\"provider\", \"provider_id\");\n\n-- migrate institution data into provider_institution\nINSERT INTO provider_institution (provider, provider_id, name, url, logo, logo_url, primary_color, data)\nSELECT\n  provider,\n  provider_id,\n  name,\n  url,\n  logo,\n  logo_url,\n  primary_color,\n  data\nFROM\n  institution;\n\n-- delete data from institution table\nTRUNCATE TABLE institution RESTART IDENTITY;\n\n-- AddForeignKey\nALTER TABLE \"provider_institution\" ADD CONSTRAINT \"provider_institution_institution_id_fkey\" FOREIGN KEY (\"institution_id\") REFERENCES \"institution\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- DropIndex\nDROP INDEX \"institution_provider_provider_id_key\";\n\n-- AlterTable\nALTER TABLE \"institution\" DROP COLUMN \"data\",\nDROP COLUMN \"provider\",\nDROP COLUMN \"provider_id\";"
  },
  {
    "path": "prisma/migrations/20220520161223_institution_search_algo/migration.sql",
    "content": "CREATE OR REPLACE FUNCTION edge_ngram_tsvector(text text, config regconfig DEFAULT 'simple') RETURNS tsvector LANGUAGE SQL IMMUTABLE AS $$\n  SELECT\n    array_to_tsvector((\n      SELECT \n        array_agg(DISTINCT substring(lexeme FOR len)) \n      FROM \n        unnest(to_tsvector(config, text)), \n        generate_series(1, length(lexeme)) len\n    ))\n$$;\n\nCREATE INDEX institution_name_ngram_idx ON institution USING GIN (edge_ngram_tsvector(name));\nCREATE INDEX provider_institution_name_ngram_idx ON provider_institution USING GIN (edge_ngram_tsvector(name));\n"
  },
  {
    "path": "prisma/migrations/20220606160203_add_finicity_username_to_user/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[finicity_username]` on the table `user` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"finicity_username\" TEXT;\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"user_finicity_username_key\" ON \"user\"(\"finicity_username\");\n\n-- Populate new field for users that already have a finicity_customer_id\nUPDATE \"user\" SET \"finicity_username\" = LPAD(\"user\".\"id\"::text, 6, '0') WHERE \"finicity_customer_id\" IS NOT NULL; "
  },
  {
    "path": "prisma/migrations/20220607162542_add_crisp_session_token_to_user/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"crisp_session_token\" TEXT NOT NULL DEFAULT gen_random_uuid();\n"
  },
  {
    "path": "prisma/migrations/20220608171009_add_success_rate_and_oauth_to_provider_institutions/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"provider_institution\" ADD COLUMN     \"oauth\" BOOLEAN NOT NULL DEFAULT false,\nADD COLUMN     \"success_rate\" DECIMAL(65,30);\n"
  },
  {
    "path": "prisma/migrations/20220608190342_add_unique_constraint_to_institution/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[name,url]` on the table `institution` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- CreateIndex\nCREATE UNIQUE INDEX \"institution_name_url_key\" ON \"institution\"(\"name\", \"url\");\n"
  },
  {
    "path": "prisma/migrations/20220608202739_add_success_rate_updated_to_provider_institution/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"provider_institution\" ADD COLUMN     \"success_rate_updated\" TIMESTAMPTZ(6);\n"
  },
  {
    "path": "prisma/migrations/20220609195136_remove_success_rate_from_provider_institution/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `success_rate` on the `provider_institution` table. All the data in the column will be lost.\n  - You are about to drop the column `success_rate_updated` on the `provider_institution` table. All the data in the column will be lost.\n\n*/\n-- AlterTable\nALTER TABLE \"provider_institution\" DROP COLUMN \"success_rate\",\nDROP COLUMN \"success_rate_updated\";\n"
  },
  {
    "path": "prisma/migrations/20220622160129_add_finicity_error/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"account_connection\" ADD COLUMN     \"finicity_error\" JSONB;\n"
  },
  {
    "path": "prisma/migrations/20220623171212_remove_holding_unique_constraint/migration.sql",
    "content": "-- DropIndex\nDROP INDEX \"holding_account_id_security_id_key\";\n"
  },
  {
    "path": "prisma/migrations/20220630005107_category_overrides/migration.sql",
    "content": "-- account\nALTER TABLE \"account\" RENAME COLUMN \"category\" TO \"category_provider\";\nALTER TABLE \"account\" ALTER COLUMN \"category_provider\" DROP NOT NULL;\nALTER TABLE \"account\" ADD COLUMN \"category_user\" \"AccountCategory\";\nALTER TABLE \"account\" ADD CONSTRAINT category_present CHECK (num_nonnulls(category_user, category_provider) > 0);\nALTER TABLE \"account\" ADD COLUMN \"category\" \"AccountCategory\" NOT NULL GENERATED ALWAYS AS (\n  COALESCE(category_user, category_provider)\n) STORED;\n\nALTER TABLE \"account\" RENAME COLUMN \"subcategory\" TO \"subcategory_provider\";\nALTER TABLE \"account\" RENAME COLUMN \"subcategory_override\" TO \"subcategory_user\";\nALTER TABLE \"account\" ADD COLUMN \"subcategory\" TEXT NOT NULL GENERATED ALWAYS AS (\n  COALESCE(\n    subcategory_user,\n    CASE \n      WHEN \"type\" = 'plaid' THEN \"plaid_subtype\"\n      WHEN \"type\" = 'property' THEN 'property'\n      WHEN \"type\" = 'vehicle' THEN 'vehicle'\n\t\t  ELSE 'other'\n\t  END\n  )\n) STORED;\n\n-- transaction\nALTER TABLE \"transaction\" RENAME COLUMN \"category\" TO \"category_provider\";\nALTER TABLE \"transaction\" ADD COLUMN \"category_user\" \"TransactionCategory\";\nALTER TABLE \"transaction\" ADD COLUMN \"category\" \"TransactionCategory\" GENERATED ALWAYS AS (\n  COALESCE(category_user, category_provider)\n) STORED;\n"
  },
  {
    "path": "prisma/migrations/20220701013813_merge_updates/migration.sql",
    "content": "-- DropIndex\nDROP INDEX \"transaction_category_idx\";\n\n-- AlterTable\nALTER TABLE \"account\" ALTER COLUMN \"category_provider\" DROP DEFAULT;\n\n-- CreateIndex\nCREATE INDEX \"transaction_category_idx\" ON \"transaction\"(\"category\");\n"
  },
  {
    "path": "prisma/migrations/20220707195013_user_overrides/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"monthly_debt_user\" DECIMAL(19,4),\nADD COLUMN     \"monthly_expenses_user\" DECIMAL(19,4),\nADD COLUMN     \"monthly_income_user\" DECIMAL(19,4);\n"
  },
  {
    "path": "prisma/migrations/20220708191740_txn_excluded_flag/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"transaction\" ADD COLUMN     \"excluded\" BOOLEAN NOT NULL DEFAULT false;\n"
  },
  {
    "path": "prisma/migrations/20220713134742_add_provider_field/migration.sql",
    "content": "CREATE TYPE \"AccountProvider\" AS ENUM ('user', 'plaid', 'finicity');\n\nALTER TABLE \"account\" ADD COLUMN \"provider\" \"AccountProvider\";\n\nUPDATE \"account\" set \"provider\" = LOWER(\"valuation_source\"::text)::\"AccountProvider\";\n\nALTER TABLE \"account\"\n  ALTER COLUMN \"provider\" SET NOT NULL,\n  DROP COLUMN \"valuation_source\";\n  \nDROP TYPE \"ValuationSource\";"
  },
  {
    "path": "prisma/migrations/20220714180514_update_account_start_date_fn/migration.sql",
    "content": "CREATE OR REPLACE FUNCTION public.account_value_start_date(p_account_id integer) RETURNS date LANGUAGE sql STABLE AS $$\n  SELECT\n    LEAST(\n      (SELECT MIN(date) FROM \"transaction\" where \"account_id\" = p_account_id),\n\t    (SELECT MIN(date) FROM \"valuation\" where \"account_id\" = p_account_id),\n\t    (SELECT MIN(date) FROM \"investment_transaction\" where \"account_id\" = p_account_id),\n\t    now()\n    )\n  FROM\n    account a\n  WHERE\n    a.id = p_account_id\n$$;\n\n\nCREATE OR REPLACE FUNCTION valuation_changed() RETURNS TRIGGER LANGUAGE plpgsql AS $$\n  BEGIN\n    UPDATE account AS a\n    SET\n      start_date = account_value_start_date(a.id),\n      current_balance = (SELECT v.amount FROM valuation v WHERE v.account_id = a.id ORDER BY v.date DESC LIMIT 1)\n    WHERE a.id = NEW.account_id OR a.id = OLD.account_id;\n    RETURN NULL;\n  END;\n$$;"
  },
  {
    "path": "prisma/migrations/20220714180819_account_category_consolidation/migration.sql",
    "content": "BEGIN;\n\nALTER TABLE \"account\" RENAME COLUMN \"category\" TO \"category_old\";\nALTER TABLE \"account\" RENAME COLUMN \"category_provider\" TO \"category_provider_old\";\nALTER TABLE \"account\" RENAME COLUMN \"category_user\" TO \"category_user_old\";\nALTER TYPE \"AccountCategory\" RENAME TO \"AccountCategory_old\";\n\nCREATE TYPE \"AccountCategory\" AS ENUM ('cash', 'investment', 'crypto', 'property', 'vehicle', 'valuable', 'loan', 'credit', 'other');\nALTER TABLE \"account\" ADD COLUMN \"category_provider\" \"AccountCategory\";\nALTER TABLE \"account\" ADD COLUMN \"category_user\" \"AccountCategory\";\n\nUPDATE \"account\"\nSET \"category_provider\" = CASE \n  WHEN \"category_provider_old\" = 'mortgage' THEN 'loan'\n  ELSE \"category_provider_old\"::TEXT::\"AccountCategory\"\nEND;\n\nUPDATE \"account\"\nSET \"category_user\" = CASE \n  WHEN \"category_user_old\" = 'mortgage' THEN 'loan'\n  ELSE \"category_user_old\"::TEXT::\"AccountCategory\"\nEND;\n\nALTER TABLE \"account\" ADD COLUMN \"category\" \"AccountCategory\" NOT NULL GENERATED ALWAYS AS (\n  COALESCE(category_user, category_provider, 'other')\n) STORED;\n\nALTER TABLE \"account\" DROP COLUMN \"category_old\";\nALTER TABLE \"account\" DROP COLUMN \"category_provider_old\";\nALTER TABLE \"account\" DROP COLUMN \"category_user_old\";\nDROP TYPE \"AccountCategory_old\";\n\nCOMMIT;"
  },
  {
    "path": "prisma/migrations/20220714181018_update_account_type_model/migration.sql",
    "content": "BEGIN;\n\n-- Make the provider subcategory field optional rather than generated\nALTER TABLE \"account\" ADD COLUMN \"subcategory_provider_new\" TEXT;\nALTER TABLE \"account\" RENAME COLUMN \"subcategory_provider\" TO \"subcategory_provider_old\";\nUPDATE \"account\" SET \"subcategory_provider_new\" = \"subcategory_provider_old\";\nALTER TABLE \"account\" RENAME COLUMN \"subcategory_provider_new\" TO \"subcategory_provider\";\nALTER TABLE \"account\" DROP COLUMN \"subcategory_provider_old\";\n\nCREATE TYPE \"AccountType_new\" AS ENUM ('INVESTMENT', 'DEPOSITORY', 'CREDIT', 'LOAN', 'PROPERTY', 'VEHICLE', 'OTHER_ASSET', 'OTHER_LIABILITY');\nALTER TABLE \"account\" ADD COLUMN \"type_new\" \"AccountType_new\";\n\nUPDATE \"account\" \nSET \"type_new\" = CASE \n  WHEN \"type\" IN ('vehicle', 'property') THEN UPPER(\"type\"::text)::\"AccountType_new\"\n\tWHEN \"valuation_method\" = 'TRANSACTION' AND \"classification\" = 'asset' THEN 'DEPOSITORY'\n\tWHEN \"valuation_method\" = 'INVESTMENT_TRANSACTION' THEN 'INVESTMENT'\n\tWHEN \"type\" = 'finicity' AND \"finicity_type\" = 'creditCard' THEN 'CREDIT'\n\tWHEN \"type\" = 'finicity' AND \"finicity_type\" <> 'creditCard' THEN 'LOAN'\n\tWHEN \"type\" = 'plaid' AND \"plaid_liability\" -> 'credit' IS NOT NULL THEN 'CREDIT'\n\tWHEN \"type\" = 'plaid' AND \"plaid_liability\" -> 'mortgage' IS NOT NULL THEN 'LOAN'\n\tWHEN \"type\" = 'plaid' AND \"plaid_liability\" -> 'student' IS NOT NULL THEN 'LOAN'\n\tWHEN \"type\" = 'plaid' AND \"plaid_type\" = 'credit' THEN 'CREDIT'\n\tWHEN \"type\" = 'plaid' AND \"plaid_type\" = 'loan' THEN 'LOAN'\n  WHEN \"type\" = 'other' AND \"category\" = 'loan' THEN 'LOAN'\n\tWHEN \"type\" = 'other' AND \"category\" = 'credit' THEN 'CREDIT'\n\tWHEN \"type\" = 'other' AND \"classification\" = 'asset' THEN 'OTHER_ASSET'\n\tWHEN \"type\" = 'other' AND \"classification\" = 'liability' THEN 'OTHER_LIABILITY'\nEND;\n\nALTER TABLE \"account\" ALTER COLUMN \"type_new\" SET NOT NULL;\nALTER TABLE \"account\" DROP COLUMN \"valuation_method\";\nALTER TABLE \"account\" DROP COLUMN \"valuation_type\";\nDROP TYPE \"ValuationMethod\";\nDROP TYPE \"ValuationType\";\n\n-- Recreate subcategory\nALTER TABLE \"account\" ADD COLUMN \"subcategory_new\" TEXT NOT NULL GENERATED ALWAYS AS (\n  COALESCE(subcategory_user, subcategory_provider, 'other')\n) STORED;\nALTER TABLE \"account\" RENAME COLUMN \"subcategory\" TO \"subcategory_old\";\nALTER TABLE \"account\" RENAME COLUMN \"subcategory_new\" TO \"subcategory\";\nALTER TABLE \"account\" DROP COLUMN \"subcategory_old\";\n\n-- Restore type\nALTER TABLE \"account\" RENAME COLUMN \"type\" TO \"type_old\";\nALTER TABLE \"account\" RENAME COLUMN \"type_new\" TO \"type\";\nALTER TABLE \"account\" DROP COLUMN \"type_old\";\nALTER TYPE \"AccountType\" RENAME TO \"AccountType_old\";\nALTER TYPE \"AccountType_new\" RENAME TO \"AccountType\";\nDROP TYPE \"AccountType_old\";\n\n-- Make our classification column auto-generated\nALTER TABLE \"account\"\nADD COLUMN \"classification_new\" \"AccountClassification\" GENERATED ALWAYS AS (\n  CASE\n    WHEN \"type\" IN ('INVESTMENT', 'DEPOSITORY', 'PROPERTY', 'VEHICLE', 'OTHER_ASSET') THEN 'asset'::\"AccountClassification\"\n    WHEN \"type\" IN ('CREDIT', 'LOAN', 'OTHER_LIABILITY') THEN 'liability'::\"AccountClassification\"\n  END\n) STORED;\n\nALTER TABLE \"account\" RENAME COLUMN \"classification\" TO \"classification_old\";\nALTER TABLE \"account\" RENAME COLUMN \"classification_new\" TO \"classification\";\nALTER TABLE \"account\" ALTER COLUMN \"classification\" SET NOT NULL;\nALTER TABLE \"account\" DROP COLUMN \"classification_old\";\n\nCOMMIT;"
  },
  {
    "path": "prisma/migrations/20220715191415_add_liability_fields/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"account\" ADD COLUMN     \"credit_provider\" JSONB,\nADD COLUMN     \"credit_user\" JSONB,\nADD COLUMN     \"loan_provider\" JSONB,\nADD COLUMN     \"loan_user\" JSONB;\n"
  },
  {
    "path": "prisma/migrations/20220719200317_plaid_txn_category/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"transaction\" ADD COLUMN     \"plaid_category_id\" TEXT,\nADD COLUMN     \"plaid_personal_finance_category\" JSONB;\n"
  },
  {
    "path": "prisma/migrations/20220720191551_generated_loan_credit_fields/migration.sql",
    "content": "ALTER TABLE \"account\" \nADD COLUMN \"credit\" JSONB \nGENERATED ALWAYS AS (\n    COALESCE(credit_provider, '{}'::JSONB) || COALESCE(credit_user, '{}'::JSONB)\n) STORED;\n\nALTER TABLE \"account\" \nADD COLUMN \"loan\" JSONB \nGENERATED ALWAYS AS (\n    COALESCE(loan_provider, '{}'::JSONB) || COALESCE(loan_user, '{}'::JSONB)\n) STORED;\n\nALTER TABLE \"account\"\nALTER COLUMN \"credit\" SET NOT NULL,\nALTER COLUMN \"loan\" SET NOT NULL;"
  },
  {
    "path": "prisma/migrations/20220725143246_map_credit_loan_data/migration.sql",
    "content": "-- update plaid mortgage accounts\nUPDATE \"account\"\nSET\n  \"loan_provider\" = json_build_object(\n    'originationDate', plaid_liability->'mortgage'->>'origination_date',\n    'originationPrincipal', plaid_liability->'mortgage'->'origination_principal_amount',\n    'maturityDate', plaid_liability->'mortgage'->>'maturity_date',\n    'interestRate', (\n      CASE plaid_liability->'mortgage'->'interest_rate'->>'type'\n        WHEN 'fixed' THEN json_build_object(\n          'type', 'fixed',\n          'rate', plaid_liability->'mortgage'->'interest_rate'->'percentage'\n        )\n        ELSE json_build_object(\n          'type', 'variable'\n        )\n      END\n    ),\n    'loanDetail', json_build_object(\n      'type', 'mortgage'\n    )\n  )\nWHERE\n  \"loan_provider\" IS NULL AND \"plaid_liability\"->>'mortgage' IS NOT NULL;\n\n-- update plaid student loan accounts\nUPDATE \"account\"\nSET\n  \"loan_provider\" = json_build_object(\n    'originationDate', plaid_liability->'student'->>'origination_date',\n    'originationPrincipal', plaid_liability->'student'->'origination_principal_amount',\n    'maturityDate', plaid_liability->'student'->>'maturity_date',\n    'interestRate', json_build_object(\n      'type', 'fixed',\n      'rate', plaid_liability->'student'->'interest_rate_percentage'\n    ),\n    'loanDetail', json_build_object(\n      'type', 'student'\n    )\n  )\nWHERE\n  \"loan_provider\" IS NULL AND \"plaid_liability\"->>'student' IS NOT NULL;\n\n-- update plaid credit accounts\nUPDATE \"account\"\nSET\n  \"credit_provider\" = json_build_object(\n    'isOverdue', plaid_liability->'credit'->>'is_overdue',\n    'lastPaymentAmount', plaid_liability->'credit'->'last_payment_amount',\n    'lastPaymentDate', plaid_liability->'credit'->>'last_payment_date',\n    'lastStatementAmount', plaid_liability->'credit'->'last_statement_balance',\n    'lastStatementDate', plaid_liability->'credit'->>'last_statement_issue_date',\n    'minimumPayment', plaid_liability->'credit'->'minimum_payment_amount'\n  )\nWHERE\n  \"credit_provider\" IS NULL AND \"plaid_liability\"->>'credit' IS NOT NULL;\n"
  },
  {
    "path": "prisma/migrations/20220726003918_reset_loan_account_balances/migration.sql",
    "content": "-- remove stale account balance records now that new calculation is in place\nDELETE FROM account_balance\nWHERE account_id IN (SELECT id FROM account WHERE type = 'LOAN' AND (loan_provider IS NOT NULL OR loan_user IS NOT NULL));\n\n-- remove stale valuations for accounts that have proper loan data\nDELETE FROM valuation\nWHERE account_id IN (SELECT id FROM account WHERE type = 'LOAN' AND (loan_provider IS NOT NULL OR loan_user IS NOT NULL));\n\n-- update function to use loan origination date\nCREATE OR REPLACE FUNCTION public.account_value_start_date(p_account_id integer) RETURNS date LANGUAGE sql STABLE AS $$\n  SELECT\n    LEAST(\n      (a.loan->>'originationDate')::date,     \n      (SELECT MIN(date) FROM \"transaction\" where \"account_id\" = a.id),\n\t    (SELECT MIN(date) FROM \"valuation\" where \"account_id\" = a.id),\n\t    (SELECT MIN(date) FROM \"investment_transaction\" where \"account_id\" = a.id),\n\t    now()\n    )\n  FROM\n    account a\n  WHERE\n    a.id = p_account_id\n$$;"
  },
  {
    "path": "prisma/migrations/20220727145316_loan_credit_json_nullable/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"account\" DROP COLUMN \"credit\", DROP COLUMN \"loan\";\n\nALTER TABLE \"account\"\nADD COLUMN \"credit\" JSONB\nGENERATED ALWAYS AS (\n    CASE WHEN num_nonnulls(credit_provider, credit_user) = 0 THEN NULL ELSE COALESCE(credit_provider, '{}'::JSONB) || COALESCE(credit_user, '{}'::JSONB) END\n) STORED;\n\nALTER TABLE \"account\"\nADD COLUMN \"loan\" JSONB\nGENERATED ALWAYS AS (\n    CASE WHEN num_nonnulls(loan_provider, loan_user) = 0 THEN NULL ELSE COALESCE(loan_provider, '{}'::JSONB) || COALESCE(loan_user, '{}'::JSONB) END\n) STORED;\n"
  },
  {
    "path": "prisma/migrations/20220727202956_loan_account_start_date/migration.sql",
    "content": "UPDATE account\nSET\n  start_date = COALESCE((loan->>'originationDate')::date, start_date)\nWHERE\n  type = 'LOAN' AND loan IS NOT NULL;\n"
  },
  {
    "path": "prisma/migrations/20220729012630_security_fields/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"security\" ADD COLUMN     \"finicity_asset_class\" TEXT,\nADD COLUMN     \"finicity_fi_asset_class\" TEXT,\nADD COLUMN     \"finicity_type\" TEXT,\nADD COLUMN     \"plaid_is_cash_equivalent\" BOOLEAN;\n"
  },
  {
    "path": "prisma/migrations/20220729202323_txn_updates/migration.sql",
    "content": "-- rename type -> flow\nALTER TYPE \"TransactionType\" RENAME TO \"TransactionFlow\";\n\nALTER TABLE \"transaction\" DROP COLUMN \"type\";\nALTER TABLE \"transaction\" ADD COLUMN \"flow\" \"TransactionFlow\" NOT NULL GENERATED ALWAYS AS (CASE WHEN amount < 0 THEN 'INFLOW'::\"TransactionFlow\" ELSE 'OUTFLOW'::\"TransactionFlow\" END) STORED;\n\nALTER TABLE \"investment_transaction\" DROP COLUMN \"type\";\nALTER TABLE \"investment_transaction\" ADD COLUMN \"flow\" \"TransactionFlow\" NOT NULL GENERATED ALWAYS AS (CASE WHEN amount < 0 THEN 'INFLOW'::\"TransactionFlow\" ELSE 'OUTFLOW'::\"TransactionFlow\" END) STORED;\n\n-- create type enum (this is only used in queries for the time being)\nCREATE TYPE \"TransactionType\" AS ENUM ('INCOME', 'EXPENSE', 'PAYMENT', 'TRANSFER');\n\n-- add defaults for currency_code\nALTER TABLE \"account\" ALTER COLUMN \"currency_code\" SET DEFAULT E'USD';\nALTER TABLE \"transaction\" ALTER COLUMN \"currency_code\" SET DEFAULT E'USD';\n"
  },
  {
    "path": "prisma/migrations/20220804180126_holdings_view/migration.sql",
    "content": "-- update cost basis columns\nALTER TABLE \"holding\" RENAME COLUMN \"cost_basis\" TO \"cost_basis_provider\";\nALTER TABLE \"holding\" ADD COLUMN \"cost_basis_user\" DECIMAL(23,8);\nALTER TABLE \"holding\" ADD COLUMN \"cost_basis\" DECIMAL(23,8) GENERATED ALWAYS AS (\n  COALESCE(cost_basis_user, cost_basis_provider)\n) STORED;\n\n-- create holdings view\nCREATE OR REPLACE VIEW holdings_enriched AS (\n  SELECT\n    h.id,\n    h.account_id,\n    h.security_id,\n    h.quantity,\n    COALESCE(pricing_latest.price_close * h.quantity * COALESCE(s.shares_per_contract, 1), h.value) AS \"value\",\n    COALESCE(h.cost_basis, tcb.cost_basis * h.quantity) AS \"cost_basis\",\n    COALESCE(h.cost_basis / h.quantity / COALESCE(s.shares_per_contract, 1), tcb.cost_basis) AS \"cost_basis_per_share\",\n    pricing_latest.price_close AS \"price\",\n    pricing_prev.price_close AS \"price_prev\"\n  FROM\n    holding h\n    INNER JOIN security s ON s.id = h.security_id\n    -- latest security pricing\n    LEFT JOIN LATERAL (\n      SELECT\n        price_close\n      FROM\n        security_pricing\n      WHERE\n        security_id = h.security_id\n      ORDER BY\n        date DESC\n      LIMIT 1\n    ) pricing_latest ON true\n    -- previous security pricing (for computing daily ∆)\n    LEFT JOIN LATERAL (\n      SELECT\n        price_close\n      FROM\n        security_pricing\n      WHERE\n        security_id = h.security_id\n      ORDER BY\n        date DESC\n      LIMIT 1\n      OFFSET 1\n    ) pricing_prev ON true\n    -- calculate cost basis from transactions\n    LEFT JOIN (\n      SELECT\n        it.account_id,\n        it.security_id,\n        SUM(it.quantity * it.price) / SUM(it.quantity) AS cost_basis\n      FROM\n        investment_transaction it\n      WHERE\n        (it.plaid_type = 'buy' OR it.finicity_investment_transaction_type = 'purchased')\n        AND it.quantity > 0\n      GROUP BY\n        it.account_id,\n        it.security_id\n    ) tcb ON tcb.account_id = h.account_id AND tcb.security_id = s.id\n);"
  },
  {
    "path": "prisma/migrations/20220804191558_add_excluded_to_holding/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"holding\" ADD COLUMN     \"excluded\" BOOLEAN NOT NULL DEFAULT false;\n"
  },
  {
    "path": "prisma/migrations/20220808171116_investment_txn_fees/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"investment_transaction\" ADD COLUMN     \"fees\" DECIMAL(19,4);\n"
  },
  {
    "path": "prisma/migrations/20220808174032_update_holdings_view/migration.sql",
    "content": "-- create holdings view\nCREATE OR REPLACE VIEW holdings_enriched AS (\n  SELECT\n    h.id,\n    h.account_id,\n    h.security_id,\n    h.quantity,\n    COALESCE(pricing_latest.price_close * h.quantity * COALESCE(s.shares_per_contract, 1), h.value) AS \"value\",\n    COALESCE(h.cost_basis, tcb.cost_basis * h.quantity) AS \"cost_basis\",\n    COALESCE(h.cost_basis / h.quantity / COALESCE(s.shares_per_contract, 1), tcb.cost_basis) AS \"cost_basis_per_share\",\n    pricing_latest.price_close AS \"price\",\n    pricing_prev.price_close AS \"price_prev\",\n    h.excluded\n  FROM\n    holding h\n    INNER JOIN security s ON s.id = h.security_id\n    -- latest security pricing\n    LEFT JOIN LATERAL (\n      SELECT\n        price_close\n      FROM\n        security_pricing\n      WHERE\n        security_id = h.security_id\n      ORDER BY\n        date DESC\n      LIMIT 1\n    ) pricing_latest ON true\n    -- previous security pricing (for computing daily ∆)\n    LEFT JOIN LATERAL (\n      SELECT\n        price_close\n      FROM\n        security_pricing\n      WHERE\n        security_id = h.security_id\n      ORDER BY\n        date DESC\n      LIMIT 1\n      OFFSET 1\n    ) pricing_prev ON true\n    -- calculate cost basis from transactions\n    LEFT JOIN (\n      SELECT\n        it.account_id,\n        it.security_id,\n        SUM(it.quantity * it.price) / SUM(it.quantity) AS cost_basis\n      FROM\n        investment_transaction it\n      WHERE\n        (it.plaid_type = 'buy' OR it.finicity_investment_transaction_type = 'purchased')\n        AND it.quantity > 0\n      GROUP BY\n        it.account_id,\n        it.security_id\n    ) tcb ON tcb.account_id = h.account_id AND tcb.security_id = s.id\n);"
  },
  {
    "path": "prisma/migrations/20220810190306_transaction_category_update/migration.sql",
    "content": "BEGIN;\n\nDROP INDEX \"transaction_category_idx\";\n\nALTER TABLE \"transaction\" \nDROP COLUMN \"category\",\nDROP COLUMN \"category_provider\",\nDROP COLUMN \"category_user\",\nADD COLUMN \"category_user\" TEXT,\nADD COLUMN \"category\" TEXT NOT NULL GENERATED ALWAYS AS (\n  COALESCE(\n    category_user,\n    CASE\n      WHEN plaid_personal_finance_category->>'primary' = 'INCOME' THEN 'Income'\n      WHEN plaid_personal_finance_category->>'detailed' IN ('LOAN_PAYMENTS_MORTGAGE_PAYMENT', 'RENT_AND_UTILITIES_RENT') THEN 'Housing Payments'\n      WHEN plaid_personal_finance_category->>'detailed' = 'LOAN_PAYMENTS_CAR_PAYMENT' THEN 'Vehicle Payments'\n      WHEN plaid_personal_finance_category->>'primary' = 'LOAN_PAYMENTS' THEN 'Other Payments'\n      WHEN plaid_personal_finance_category->>'primary' = 'HOME_IMPROVEMENT' THEN 'Home Improvement' \n      WHEN plaid_personal_finance_category->>'primary' = 'GENERAL_MERCHANDISE' THEN 'Shopping'\n      WHEN \n        (plaid_personal_finance_category->>'primary' = 'RENT_AND_UTILITIES' AND \n        plaid_personal_finance_category->>'detailed' <> 'RENT_AND_UTILITIES_RENT') THEN 'Utilities'\n      WHEN plaid_personal_finance_category->>'primary' IN ('FOOD_AND_DRINK') THEN 'Food and Drink'\n      WHEN plaid_personal_finance_category->>'primary' = 'TRANSPORTATION' THEN 'Transportation'\n      WHEN plaid_personal_finance_category->>'primary' IN ('TRAVEL') THEN 'Travel'\n      WHEN (plaid_personal_finance_category->>'primary' IN ('PERSONAL_CARE', 'MEDICAL') AND \n        plaid_personal_finance_category->>'detailed' <> 'MEDICAL_VETERINARY_SERVICES') THEN 'Health'\n      WHEN finicity_categorization->>'category' IN ('Income', 'Paycheck') THEN 'Income'\n      WHEN finicity_categorization->>'category' = 'Mortgage & Rent' THEN 'Housing Payments'\n      WHEN finicity_categorization->>'category' IN ('Furnishings', 'Home Services', 'Home Improvement', 'Lawn and Garden') THEN 'Home Improvement'\n      WHEN finicity_categorization->>'category' IN ('Streaming Services', 'Home Phone', 'Television', 'Bills & Utilities', 'Utilities', 'Internet / Broadband Charges', 'Mobile Phone') THEN 'Utilities'\n      WHEN finicity_categorization->>'category' IN ('Fast Food', 'Food & Dining', 'Restaurants', 'Coffee Shops', 'Alcohol & Bars', 'Groceries') THEN 'Food and Drink'\n      WHEN finicity_categorization->>'category' IN ('Auto & Transport', 'Gas & Fuel', 'Auto Insurance') THEN 'Transportation'\n      WHEN finicity_categorization->>'category' IN ('Hotel', 'Travel', 'Rental Car & Taxi') THEN 'Travel'\n      WHEN finicity_categorization->>'category' IN ('Health Insurance', 'Doctor', 'Pharmacy', 'Eyecare', 'Health & Fitness', 'Personal Care') THEN 'Health'\n      ELSE 'Other'\n    END\n  )\n) STORED;\n\n-- DropEnum\nDROP TYPE \"TransactionCategory\";\n\nCOMMIT;\n"
  },
  {
    "path": "prisma/migrations/20220817180833_dietz/migration.sql",
    "content": "CREATE OR REPLACE FUNCTION calculate_return_dietz(p_account_id account.id%type, p_start date, p_end date, out percentage numeric, out amount numeric) AS $$\n  DECLARE\n    v_start date := GREATEST(p_start, (SELECT MIN(date) FROM account_balance WHERE account_id = p_account_id));\n    v_end date := p_end;\n    v_days int := v_end - v_start;\n  BEGIN\n    SELECT\n      ROUND((b1.balance - b0.balance - flows.net) / (b0.balance + flows.weighted), 4) AS \"percentage\",\n      b1.balance - b0.balance - flows.net AS \"amount\"\n    INTO\n      percentage, amount\n    FROM\n      account a\n      LEFT JOIN LATERAL (\n        SELECT\n          COALESCE(SUM(-fw.flow), 0) AS \"net\",\n          COALESCE(SUM(-fw.flow * fw.weight), 0) AS \"weighted\"\n        FROM (\n          SELECT\n            SUM(it.amount) AS flow,\n            (v_days - (it.date - v_start))::numeric / v_days AS weight\n          FROM\n            investment_transaction it\n          WHERE\n            it.account_id = a.id\n            AND it.date BETWEEN v_start AND v_end\n            -- filter for investment_transactions that represent external flows\n            AND (\n              (it.plaid_type = 'cash' AND it.plaid_subtype IN ('contribution', 'deposit', 'withdrawal'))\n              OR (it.plaid_type = 'transfer' AND it.plaid_subtype IN ('transfer', 'send', 'request'))\n              OR (it.plaid_type = 'buy' AND it.plaid_subtype IN ('contribution'))\n              OR (it.finicity_transaction_id IS NOT NULL AND it.finicity_investment_transaction_type IN ('contribution', 'deposit', 'transfer'))\n            )\n          GROUP BY\n            it.date\n        ) fw\n      ) flows ON TRUE\n      LEFT JOIN LATERAL (\n        SELECT\n          ab.balance AS \"balance\"\n        FROM\n          account_balance ab\n        WHERE\n          ab.account_id = a.id AND ab.date <= v_start\n        ORDER BY\n          ab.date DESC\n        LIMIT 1\n      ) b0 ON TRUE\n      LEFT JOIN LATERAL (\n        SELECT\n          COALESCE(ab.balance, a.current_balance) AS \"balance\"\n        FROM\n          account_balance ab\n        WHERE\n          ab.account_id = a.id AND ab.date <= v_end\n        ORDER BY\n          ab.date DESC\n        LIMIT 1\n      ) b1 ON TRUE\n    WHERE\n      a.id = p_account_id;\n  END;\n$$ LANGUAGE plpgsql STABLE;\n"
  },
  {
    "path": "prisma/migrations/20220819151658_add_investment_transaction_category/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"InvestmentTransactionCategory\" AS ENUM ('buy', 'sell', 'dividend', 'transfer', 'tax', 'fee', 'cancel', 'other');\n\n-- AlterTable\nALTER TABLE \"investment_transaction\" ADD COLUMN     \"category\" \"InvestmentTransactionCategory\" NOT NULL GENERATED ALWAYS AS (\n  CASE\n      WHEN \"plaid_type\" = 'buy' THEN 'buy'::\"InvestmentTransactionCategory\"\n      WHEN \"plaid_type\" = 'sell' THEN 'sell'::\"InvestmentTransactionCategory\"\n      WHEN \"plaid_subtype\" IN ('dividend', 'qualified dividend', 'non-qualified dividend') THEN 'dividend'::\"InvestmentTransactionCategory\"\n      WHEN \"plaid_subtype\" IN ('non-resident tax', 'tax', 'tax withheld') THEN 'tax'::\"InvestmentTransactionCategory\"\n      WHEN \"plaid_type\" = 'fee' OR \"plaid_subtype\" IN ('account fee', 'legal fee', 'management fee', 'margin expense', 'transfer fee', 'trust fee') THEN 'fee'::\"InvestmentTransactionCategory\"\n      WHEN \"plaid_type\" = 'cash' THEN 'transfer'::\"InvestmentTransactionCategory\"\n      WHEN \"plaid_type\" = 'cancel' THEN 'cancel'::\"InvestmentTransactionCategory\"\n\n      WHEN \"finicity_investment_transaction_type\" IN ('purchased', 'purchaseToClose', 'purchaseToCover', 'dividendReinvest', 'reinvestOfIncome') THEN 'buy'::\"InvestmentTransactionCategory\"\n      WHEN \"finicity_investment_transaction_type\" IN ('sold', 'soldToClose', 'soldToOpen') THEN 'sell'::\"InvestmentTransactionCategory\"\n      WHEN \"finicity_investment_transaction_type\" = 'dividend' THEN 'dividend'::\"InvestmentTransactionCategory\"\n      WHEN \"finicity_investment_transaction_type\" = 'tax' THEN 'tax'::\"InvestmentTransactionCategory\"\n      WHEN \"finicity_investment_transaction_type\" = 'fee' THEN 'fee'::\"InvestmentTransactionCategory\"\n      WHEN \"finicity_investment_transaction_type\" IN ('transfer', 'contribution', 'deposit', 'income', 'interest') THEN 'transfer'::\"InvestmentTransactionCategory\"\n      WHEN \"finicity_investment_transaction_type\" = 'cancel' THEN 'cancel'::\"InvestmentTransactionCategory\"\n\n      ELSE 'other'::\"InvestmentTransactionCategory\"\n    END\n) STORED;\n"
  },
  {
    "path": "prisma/migrations/20220915200544_add_plans/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"plan\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"user_id\" INTEGER NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"date_of_birth\" DATE NOT NULL,\n    \"life_expectancy\" INTEGER NOT NULL DEFAULT 85,\n    \"events\" JSONB NOT NULL DEFAULT '[]',\n    \"milestones\" JSONB NOT NULL DEFAULT '[]',\n\n    CONSTRAINT \"plan_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- AddForeignKey\nALTER TABLE \"plan\" ADD CONSTRAINT \"plan_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20220919203059_make_dob_optional/migration.sql",
    "content": "ALTER TABLE \"plan\" ALTER COLUMN \"date_of_birth\" DROP NOT NULL;\n"
  },
  {
    "path": "prisma/migrations/20220929161359_remove_crisp_session_token/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `crisp_session_token` on the `user` table. All the data in the column will be lost.\n\n*/\n-- AlterTable\nALTER TABLE \"user\" DROP COLUMN \"crisp_session_token\";"
  },
  {
    "path": "prisma/migrations/20221004193621_security_brokerage_cash_flag/migration.sql",
    "content": "ALTER TABLE \"security\" ADD COLUMN \"is_brokerage_cash\" BOOLEAN NOT NULL DEFAULT false;\n"
  },
  {
    "path": "prisma/migrations/20221007143103_dietz_div0_fix/migration.sql",
    "content": "CREATE OR REPLACE FUNCTION calculate_return_dietz(p_account_id account.id%type, p_start date, p_end date, out percentage numeric, out amount numeric) AS $$\n  DECLARE\n    v_start date := GREATEST(p_start, (SELECT MIN(date) FROM account_balance WHERE account_id = p_account_id));\n    v_end date := p_end;\n    v_days int := v_end - v_start;\n  BEGIN\n    SELECT\n      ROUND((b1.balance - b0.balance - flows.net) / NULLIF(b0.balance + flows.weighted, 0), 4) AS \"percentage\",\n      b1.balance - b0.balance - flows.net AS \"amount\"\n    INTO\n      percentage, amount\n    FROM\n      account a\n      LEFT JOIN LATERAL (\n        SELECT\n          COALESCE(SUM(-fw.flow), 0) AS \"net\",\n          COALESCE(SUM(-fw.flow * fw.weight), 0) AS \"weighted\"\n        FROM (\n          SELECT\n            SUM(it.amount) AS flow,\n            (v_days - (it.date - v_start))::numeric / v_days AS weight\n          FROM\n            investment_transaction it\n          WHERE\n            it.account_id = a.id\n            AND it.date BETWEEN v_start AND v_end\n            -- filter for investment_transactions that represent external flows\n            AND (\n              (it.plaid_type = 'cash' AND it.plaid_subtype IN ('contribution', 'deposit', 'withdrawal'))\n              OR (it.plaid_type = 'transfer' AND it.plaid_subtype IN ('transfer', 'send', 'request'))\n              OR (it.plaid_type = 'buy' AND it.plaid_subtype IN ('contribution'))\n              OR (it.finicity_transaction_id IS NOT NULL AND it.finicity_investment_transaction_type IN ('contribution', 'deposit', 'transfer'))\n            )\n          GROUP BY\n            it.date\n        ) fw\n      ) flows ON TRUE\n      LEFT JOIN LATERAL (\n        SELECT\n          ab.balance AS \"balance\"\n        FROM\n          account_balance ab\n        WHERE\n          ab.account_id = a.id AND ab.date <= v_start\n        ORDER BY\n          ab.date DESC\n        LIMIT 1\n      ) b0 ON TRUE\n      LEFT JOIN LATERAL (\n        SELECT\n          COALESCE(ab.balance, a.current_balance) AS \"balance\"\n        FROM\n          account_balance ab\n        WHERE\n          ab.account_id = a.id AND ab.date <= v_end\n        ORDER BY\n          ab.date DESC\n        LIMIT 1\n      ) b1 ON TRUE\n    WHERE\n      a.id = p_account_id;\n  END;\n$$ LANGUAGE plpgsql STABLE;\n\n"
  },
  {
    "path": "prisma/migrations/20221017145454_plan_events_milestones/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `events` on the `plan` table. All the data in the column will be lost.\n  - You are about to drop the column `milestones` on the `plan` table. All the data in the column will be lost.\n\n*/\n-- CreateEnum\nCREATE TYPE \"PlanEventFrequency\" AS ENUM ('monthly', 'yearly');\n\n-- CreateEnum\nCREATE TYPE \"PlanMilestoneType\" AS ENUM ('year', 'net_worth');\n\n-- CreateTable\nCREATE TABLE \"plan_event\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"plan_id\" INTEGER NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"start_year\" INTEGER CHECK (\"start_year\" > 0),\n    \"start_milestone_id\" INTEGER,\n    \"end_year\" INTEGER CHECK (\"end_year\" > 0),\n    \"end_milestone_id\" INTEGER,\n    \"frequency\" \"PlanEventFrequency\" NOT NULL DEFAULT 'yearly',\n    \"initial_value\" DECIMAL(19,4),\n    \"initial_value_ref\" TEXT,\n    \"rate\" DECIMAL(6,4) NOT NULL DEFAULT 0,\n\n    CONSTRAINT \"plan_event_pkey\" PRIMARY KEY (\"id\"),\n\n    CONSTRAINT \"start_check\" CHECK (num_nonnulls(\"start_year\", \"start_milestone_id\") <= 1),\n    CONSTRAINT \"end_check\" CHECK (num_nonnulls(\"end_year\", \"end_milestone_id\") <= 1),\n    CONSTRAINT \"initial_value_check\" CHECK (num_nonnulls(\"initial_value\", \"initial_value_ref\") = 1)\n);\n\n-- CreateTable\nCREATE TABLE \"plan_milestone\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"plan_id\" INTEGER NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"type\" \"PlanMilestoneType\" NOT NULL,\n    \"year\" INTEGER CHECK (\"year\" > 0),\n    \"expense_multiple\" DOUBLE PRECISION CHECK (\"expense_multiple\" >= 0),\n    \"expense_years\" INTEGER CHECK (\"expense_years\" >= 0),\n\n    CONSTRAINT \"plan_milestone_pkey\" PRIMARY KEY (\"id\"),\n\n    -- constraints for validating discriminated union\n    CONSTRAINT \"type_year_check\" CHECK (\"type\" <> 'year' OR (\"year\" IS NOT NULL)),\n    CONSTRAINT \"type_net_worth_check\" CHECK (\"type\" <> 'net_worth' OR (\"expense_multiple\" IS NOT NULL AND \"expense_years\" IS NOT NULL))\n);\n\n-- AddForeignKey\nALTER TABLE \"plan_event\" ADD CONSTRAINT \"plan_event_plan_id_fkey\" FOREIGN KEY (\"plan_id\") REFERENCES \"plan\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"plan_event\" ADD CONSTRAINT \"plan_event_start_milestone_id_fkey\" FOREIGN KEY (\"start_milestone_id\") REFERENCES \"plan_milestone\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"plan_event\" ADD CONSTRAINT \"plan_event_end_milestone_id_fkey\" FOREIGN KEY (\"end_milestone_id\") REFERENCES \"plan_milestone\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"plan_milestone\" ADD CONSTRAINT \"plan_milestone_plan_id_fkey\" FOREIGN KEY (\"plan_id\") REFERENCES \"plan\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- migrate milestones\nINSERT INTO plan_milestone (plan_id, name, type, year, expense_multiple, expense_years)\nSELECT\n  p.id,\n  m.name,\n  m.type::\"PlanMilestoneType\",\n  m.year,\n  m.expense_multiple,\n  m.expense_years\nFROM\n  plan p,\n  jsonb_to_recordset(p.milestones) AS m(\"name\" text, \"type\" text, \"year\" int, \"expense_multiple\" float, \"expense_years\" int);\n\n-- migrate plans\nINSERT INTO plan_event (plan_id, name, start_year, end_year, frequency, initial_value, initial_value_ref, rate)\nSELECT\n  p.id,\n  e.name,\n  e.start,\n  e.end,\n  e.frequency::\"PlanEventFrequency\",\n  CASE WHEN v.initial_value IN ('income', 'expenses') THEN NULL ELSE v.initial_value::decimal END AS \"initial_value\",\n  CASE WHEN v.initial_value IN ('income', 'expenses') THEN v.initial_value ELSE NULL END AS \"initial_value_ref\",\n  v.rate\nFROM\n  plan p,\n  jsonb_to_recordset(p.events) AS e(\"name\" text, \"start\" int, \"end\" int, \"frequency\" text, \"value\" jsonb)\n  LEFT JOIN LATERAL (\n    SELECT \n      COALESCE(e.value->>'initialValue', e.value->>'value') AS \"initial_value\",\n      COALESCE((e.value->>'rate')::decimal, 0) AS \"rate\"\n  ) v ON true;\n\n-- drop plan/milestone json columns\nALTER TABLE \"plan\" DROP COLUMN \"events\", DROP COLUMN \"milestones\";"
  },
  {
    "path": "prisma/migrations/20221021162836_remove_dob_from_plan/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `date_of_birth` on the `plan` table. All the data in the column will be lost.\n\n*/\n\n-- AlterTable\nALTER TABLE \"plan\" DROP COLUMN \"date_of_birth\";"
  },
  {
    "path": "prisma/migrations/20221024203133_plan_event_milestone_category/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"plan_event\" ADD COLUMN \"category\" TEXT;\n\n-- AlterTable\nALTER TABLE \"plan_milestone\" ADD COLUMN \"category\" TEXT NOT NULL DEFAULT 'retirement';\n"
  },
  {
    "path": "prisma/migrations/20221027180912_cascade_plan_milestone_deletion/migration.sql",
    "content": "-- DropForeignKey\nALTER TABLE \"plan_event\" DROP CONSTRAINT \"plan_event_end_milestone_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"plan_event\" DROP CONSTRAINT \"plan_event_start_milestone_id_fkey\";\n\n-- AddForeignKey\nALTER TABLE \"plan_event\" ADD CONSTRAINT \"plan_event_start_milestone_id_fkey\" FOREIGN KEY (\"start_milestone_id\") REFERENCES \"plan_milestone\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"plan_event\" ADD CONSTRAINT \"plan_event_end_milestone_id_fkey\" FOREIGN KEY (\"end_milestone_id\") REFERENCES \"plan_milestone\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20221109192536_add_stripe_fields/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"stripe_cancel_at\" TIMESTAMPTZ(6),\nADD COLUMN     \"stripe_current_period_end\" TIMESTAMPTZ(6),\nADD COLUMN     \"stripe_customer_id\" TEXT,\nADD COLUMN     \"stripe_price_id\" TEXT,\nADD COLUMN     \"stripe_subscription_id\" TEXT,\nADD COLUMN     \"stripe_trial_end\" TIMESTAMPTZ(6);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"user_stripe_customer_id_key\" ON \"user\"(\"stripe_customer_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"user_stripe_subscription_id_key\" ON \"user\"(\"stripe_subscription_id\");\n"
  },
  {
    "path": "prisma/migrations/20221111192223_ata/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"ConversationStatus\" AS ENUM ('open', 'closed');\n\n-- CreateEnum\nCREATE TYPE \"MessageType\" AS ENUM ('text', 'audio', 'video');\n\n-- CreateTable\nCREATE TABLE \"advisor\" (\n    \"id\" INTEGER NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"user_id\" INTEGER NOT NULL,\n    \"title\" TEXT NOT NULL,\n    \"bio\" TEXT NOT NULL,\n\n    CONSTRAINT \"advisor_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"conversation\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"status\" \"ConversationStatus\" NOT NULL DEFAULT 'open',\n    \"title\" TEXT NOT NULL,\n    \"user_id\" INTEGER,\n\n    CONSTRAINT \"conversation_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"conversation_advisor\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"conversation_id\" INTEGER NOT NULL,\n    \"advisor_id\" INTEGER NOT NULL,\n\n    CONSTRAINT \"conversation_advisor_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"message\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"conversation_id\" INTEGER NOT NULL,\n    \"user_id\" INTEGER,\n    \"type\" \"MessageType\" NOT NULL,\n    \"body\" TEXT,\n    \"media_url\" TEXT,\n\n    CONSTRAINT \"message_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"advisor_user_id_key\" ON \"advisor\"(\"user_id\");\n\n-- AddForeignKey\nALTER TABLE \"advisor\" ADD CONSTRAINT \"advisor_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"conversation\" ADD CONSTRAINT \"conversation_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"conversation_advisor\" ADD CONSTRAINT \"conversation_advisor_conversation_id_fkey\" FOREIGN KEY (\"conversation_id\") REFERENCES \"conversation\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"conversation_advisor\" ADD CONSTRAINT \"conversation_advisor_advisor_id_fkey\" FOREIGN KEY (\"advisor_id\") REFERENCES \"advisor\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"message\" ADD CONSTRAINT \"message_conversation_id_fkey\" FOREIGN KEY (\"conversation_id\") REFERENCES \"conversation\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"message\" ADD CONSTRAINT \"message_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20221115201138_advisor_approval_status/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"ApprovalStatus\" AS ENUM ('pending', 'approved', 'rejected');\n\n-- AlterTable\nALTER TABLE \"advisor\" ADD COLUMN \"approval_status\" \"ApprovalStatus\" NOT NULL DEFAULT 'pending';\n"
  },
  {
    "path": "prisma/migrations/20221117150434_update_advisor_profile/migration.sql",
    "content": "\n-- DropForeignKey\nALTER TABLE \"advisor\" DROP CONSTRAINT \"advisor_user_id_fkey\";\n\n-- AlterTable\nCREATE SEQUENCE \"advisor_id_seq\";\nALTER TABLE \"advisor\" ADD COLUMN     \"avatar_src\" TEXT NOT NULL,\nADD COLUMN     \"full_name\" TEXT NOT NULL,\nALTER COLUMN \"id\" SET DEFAULT nextval('advisor_id_seq');\nALTER SEQUENCE \"advisor_id_seq\" OWNED BY \"advisor\".\"id\";\n\n-- AlterTable\nALTER TABLE \"message\" DROP COLUMN \"media_url\",\nADD COLUMN     \"media_src\" TEXT;\n\n-- AddForeignKey\nALTER TABLE \"advisor\" ADD CONSTRAINT \"advisor_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20221117213140_add_stripe_trial_reminder_sent/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"stripe_trial_reminder_sent\" TIMESTAMPTZ(6);"
  },
  {
    "path": "prisma/migrations/20221121214349_add_user_goals/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"UserGoal\" AS ENUM ('retire', 'debt', 'save', 'invest');\n\n-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"goals\" \"UserGoal\"[],\nADD COLUMN     \"goals_description\" TEXT,\nADD COLUMN     \"risk_tolerance\" SMALLINT;\n\n"
  },
  {
    "path": "prisma/migrations/20221129201601_conversation_advisor_unique_key/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[conversation_id,advisor_id]` on the table `conversation_advisor` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- CreateIndex\nCREATE UNIQUE INDEX \"conversation_advisor_conversation_id_advisor_id_key\" ON \"conversation_advisor\"(\"conversation_id\", \"advisor_id\");\n"
  },
  {
    "path": "prisma/migrations/20221202213727_notification_preferences/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"ata_all\" BOOLEAN NOT NULL DEFAULT true,\nADD COLUMN     \"ata_closed\" BOOLEAN NOT NULL DEFAULT true,\nADD COLUMN     \"ata_expire\" BOOLEAN NOT NULL DEFAULT true,\nADD COLUMN     \"ata_review\" BOOLEAN NOT NULL DEFAULT true,\nADD COLUMN     \"ata_submitted\" BOOLEAN NOT NULL DEFAULT true,\nADD COLUMN     \"ata_update\" BOOLEAN NOT NULL DEFAULT true,\nADD COLUMN     \"convert_kit_id\" INTEGER;\n"
  },
  {
    "path": "prisma/migrations/20221206153642_conversation_user_required/migration.sql",
    "content": "/*\n  Warnings:\n\n  - Made the column `user_id` on table `conversation` required. This step will fail if there are existing NULL values in that column.\n\n*/\n-- DropForeignKey\nALTER TABLE \"conversation\" DROP CONSTRAINT \"conversation_user_id_fkey\";\n\n-- AlterTable\nALTER TABLE \"conversation\" ALTER COLUMN \"user_id\" SET NOT NULL;\n\n-- AddForeignKey\nALTER TABLE \"conversation\" ADD CONSTRAINT \"conversation_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20221207235557_expiry_email_sent/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"conversation\" ADD COLUMN     \"expiry_email_sent\" TIMESTAMPTZ(6);\n"
  },
  {
    "path": "prisma/migrations/20221209041210_user_advisor_notes/migration.sql",
    "content": "-- AlterTable\n\nALTER TABLE \"user\" ADD COLUMN \"advisor_notes\" TEXT;\n\n"
  },
  {
    "path": "prisma/migrations/20221212164355_update_risk_data_type/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ALTER COLUMN \"risk_tolerance\" SET DATA TYPE DOUBLE PRECISION;\n"
  },
  {
    "path": "prisma/migrations/20221214145140_add_audit_table_and_trigger/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"AuditEventType\" AS ENUM ('insert', 'update', 'delete');\n\n-- CreateTable\nCREATE TABLE \"audit_event\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"type\" \"AuditEventType\" NOT NULL,\n    \"model_type\" TEXT NOT NULL,\n    \"model_id\" INTEGER NOT NULL,\n    \"data\" JSONB NOT NULL,\n    \"user_id\" INTEGER,\n\n    CONSTRAINT \"audit_event_pkey\" PRIMARY KEY (\"id\")\n);\n\nCREATE OR REPLACE FUNCTION log_conversation_messages()\n  RETURNS TRIGGER\n  LANGUAGE PLPGSQL\n  AS\n$$\nBEGIN\n  \n    -- Handles INSERT,UPDATE,DELETE events on changes to the message table\n  INSERT INTO audit_event(\"type\",\"model_type\",\"model_id\",\"data\",\"user_id\")\n  VALUES(\n    LOWER(TG_OP)::\"AuditEventType\",\n    'Message',\n    COALESCE(NEW.\"id\",OLD.\"id\"),\n    to_json(COALESCE(NEW,OLD)),\n      COALESCE(NEW.\"user_id\",OLD.\"user_id\")\n  );\n    \n  RETURN COALESCE(NEW,OLD);\nEND;\n$$;\n\nCREATE OR REPLACE TRIGGER message_audit_event\n  AFTER INSERT OR UPDATE OR DELETE\n  ON message\n  FOR EACH ROW\n  EXECUTE PROCEDURE log_conversation_messages();\n  "
  },
  {
    "path": "prisma/migrations/20221222200240_add_onboarding_profile_fields/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"Household\" AS ENUM ('single', 'singleWithDependents', 'dual', 'dualWithDependents', 'retired');\n\n-- CreateEnum\nCREATE TYPE \"MaybeGoal\" AS ENUM ('aggregate', 'advice', 'plan');\n\n-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"household\" \"Household\",\nADD COLUMN     \"maybe_goals\" \"MaybeGoal\"[],\nADD COLUMN     \"maybe_goals_description\" TEXT;"
  },
  {
    "path": "prisma/migrations/20230105203751_add_maybe_and_title/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"maybe\" TEXT,\nADD COLUMN     \"title\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20230105210810_add_member_number/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ADD COLUMN \"member_number\" INT;\n\nCREATE SEQUENCE user_member_number_seq;\n\nUPDATE \"user\"\n  SET \"member_number\" = \"u\".\"number\"\n  FROM (\n    SELECT \"id\", nextval('user_member_number_seq') AS \"number\"\n    FROM \"user\"\n    ORDER BY \"id\" ASC\n  ) \"u\"\n  WHERE \"user\".\"id\" = \"u\".\"id\";\n\nALTER TABLE \"user\" ALTER COLUMN \"member_number\" SET DEFAULT nextval('user_member_number_seq');\nALTER TABLE \"user\" ALTER COLUMN \"member_number\" SET NOT NULL;\n\nALTER SEQUENCE user_member_number_seq OWNED BY \"user\".\"member_number\";\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"user_member_number_key\" ON \"user\"(\"member_number\");\n"
  },
  {
    "path": "prisma/migrations/20230105221446_user_audit/migration.sql",
    "content": "-- DropForeignKey\nALTER TABLE \"advisor\" DROP CONSTRAINT \"advisor_user_id_fkey\";\n\n-- AddForeignKey\nALTER TABLE \"advisor\" ADD CONSTRAINT \"advisor_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- audit user creation/deletion\nCREATE OR REPLACE FUNCTION log_user() RETURNS TRIGGER AS $$\n  BEGIN\n    INSERT INTO audit_event (\"type\", \"model_type\", \"model_id\", \"data\")\n    VALUES (\n      LOWER(TG_OP)::\"AuditEventType\",\n      'User',\n      COALESCE(NEW.\"id\", OLD.\"id\"),\n      to_json(COALESCE(NEW, OLD))\n    );\n    RETURN NULL; -- result is ignored since this is an AFTER trigger\n  END;\n$$ LANGUAGE plpgsql;\n\nCREATE OR REPLACE TRIGGER user_audit_event\n  AFTER INSERT OR DELETE\n  ON \"user\"\n  FOR EACH ROW\n  EXECUTE FUNCTION log_user();\n"
  },
  {
    "path": "prisma/migrations/20230106172727_add_user_residence/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"residence\" TEXT NULL;\n"
  },
  {
    "path": "prisma/migrations/20230106221847_user_profile/migration.sql",
    "content": "CREATE EXTENSION IF NOT EXISTS citext;\n\nALTER TABLE \"user\"\nADD COLUMN \"agreements_revision\" TEXT,\nADD COLUMN \"dob\" DATE,\nADD COLUMN \"email\" CITEXT,\nADD COLUMN \"first_name\" TEXT,\nADD COLUMN \"last_name\" TEXT,\nADD COLUMN \"onboarded\" TIMESTAMPTZ(6),\nADD COLUMN \"picture\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20230110173017_add_user_member_id/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"member_id\" TEXT NOT NULL DEFAULT gen_random_uuid();\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"user_member_id_key\" ON \"user\"(\"member_id\");\n"
  },
  {
    "path": "prisma/migrations/20230112163100_add_agreements_table/migration.sql",
    "content": "\n-- CreateEnum\nCREATE TYPE \"AgreementType\" AS ENUM ('fee', 'form_adv', 'form_crs', 'privacy_policy');\n\n-- AlterTable\nALTER TABLE \"user\" DROP COLUMN \"agreements_revision\";\n\n-- CreateTable\nCREATE TABLE \"agreement\" (\n    \"id\" SERIAL NOT NULL,\n    \"type\" \"AgreementType\" NOT NULL,\n    \"revision\" DATE NOT NULL,\n    \"src\" TEXT NOT NULL,\n    \"active\" BOOLEAN NOT NULL DEFAULT false,\n\n    CONSTRAINT \"agreement_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"signed_agreement\" (\n    \"signed_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"user_id\" INTEGER NOT NULL,\n    \"agreement_id\" INTEGER NOT NULL,\n    \"src\" TEXT,\n\n    CONSTRAINT \"signed_agreement_pkey\" PRIMARY KEY (\"user_id\",\"agreement_id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"agreement_src_key\" ON \"agreement\"(\"src\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"agreement_type_revision_key\" ON \"agreement\"(\"type\", \"revision\");\n\n-- AddForeignKey\nALTER TABLE \"signed_agreement\" ADD CONSTRAINT \"signed_agreement_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"signed_agreement\" ADD CONSTRAINT \"signed_agreement_agreement_id_fkey\" FOREIGN KEY (\"agreement_id\") REFERENCES \"agreement\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20230113230312_user_email_required/migration.sql",
    "content": "-- delete users who no longer exist in Auth0 (determined by lack of `email` which was populated during the migration)\nDELETE FROM \"user\" WHERE \"email\" IS NULL;\nALTER TABLE \"user\" ALTER COLUMN \"email\" SET NOT NULL;\n"
  },
  {
    "path": "prisma/migrations/20230117131125_update_ama_onboarding/migration.sql",
    "content": "ALTER TABLE \"user\" \nDROP COLUMN \"goals_description\",\nDROP COLUMN \"risk_tolerance\",\nADD COLUMN  \"risk_answers\" JSONB NOT NULL DEFAULT '[]',\nADD COLUMN  \"user_notes\" TEXT,\nADD COLUMN  \"goals_new\" TEXT[];\n\nUPDATE \"user\"\nSET \"goals_new\" = \"user\".\"goals\";\n\nALTER TABLE \"user\"\nDROP COLUMN \"goals\";\n\nDROP TYPE \"UserGoal\";\n\nALTER TABLE \"user\"\nRENAME COLUMN \"goals_new\" TO \"goals\";"
  },
  {
    "path": "prisma/migrations/20230117150048_user_name/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ADD COLUMN \"name\" TEXT GENERATED ALWAYS AS (\n  CASE\n    WHEN first_name IS NULL THEN last_name\n    WHEN last_name IS NULL THEN first_name\n    ELSE first_name || ' ' || last_name\n  END\n) STORED;\n"
  },
  {
    "path": "prisma/migrations/20230117192734_update_agreement_types/migration.sql",
    "content": "-- Clear agreements\nDELETE FROM \"agreement\";\n\n-- AlterEnum\nBEGIN;\nCREATE TYPE \"AgreementType_new\" AS ENUM ('fee', 'form_adv_2a', 'form_adv_2b', 'form_crs', 'privacy_policy');\nALTER TABLE \"agreement\" ALTER COLUMN \"type\" TYPE \"AgreementType_new\" USING (\"type\"::text::\"AgreementType_new\");\nALTER TYPE \"AgreementType\" RENAME TO \"AgreementType_old\";\nALTER TYPE \"AgreementType_new\" RENAME TO \"AgreementType\";\nDROP TYPE \"AgreementType_old\";\nCOMMIT;\n\n-- DropIndex\nDROP INDEX \"agreement_type_revision_key\";\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"agreement_type_revision_active_key\" ON \"agreement\"(\"type\", \"revision\", \"active\");\n\n-- Insert initial agreements \nINSERT INTO \n\tagreement(\"type\", \"revision\", \"src\", \"active\") \nVALUES \n\t('fee', '2023-01-11', 'agreements/limited-scope-advisory-agreement-2023-01-11.pdf', true),\n\t('form_adv_2a', '2022-09-07', 'agreements/form-ADV-2A-2022-09-07.pdf', true),\n\t('form_adv_2b', '2022-11-04', 'agreements/form-ADV-2B-2022-11-04.pdf', true),\n\t('form_crs', '2022-09-20', 'agreements/form-CRS-2022-09-20.pdf', true),\n\t('privacy_policy', '2023-01-11', 'agreements/advisor-privacy-policy-2023-01-11.pdf', true);\n"
  },
  {
    "path": "prisma/migrations/20230119114411_add_onboarding_steps/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"onboarding_steps\" JSONB[];\n"
  },
  {
    "path": "prisma/migrations/20230123121401_separate_onboarding_flows/migration.sql",
    "content": "ALTER TABLE \"user\" \nDROP COLUMN \"onboarded\",\nADD COLUMN  \"onboarding\" JSONB;\n\nUPDATE \"user\"\nSET \"onboarding\" = \n  CASE \n    WHEN \"onboarding_steps\" IS NULL THEN NULL \n    ELSE json_build_object(\n      'main', json_build_object(\n        'markedComplete', false,\n        'steps', \"onboarding_steps\"\n      ),\n      'sidebar', json_build_object(\n        'markedComplete', false,\n        'steps', \"onboarding_steps\"\n      )\n    )\n  END;\n\nALTER TABLE \"user\"\nDROP COLUMN \"onboarding_steps\";\n"
  },
  {
    "path": "prisma/migrations/20230123192138_user_country_state/migration.sql",
    "content": "ALTER TABLE \"user\" ADD COLUMN \"country\" TEXT, ADD COLUMN \"state\" TEXT;\n\nUPDATE \"user\"\nSET\n  \"country\" = (\n    CASE\n      WHEN \"residence\" IS NOT NULL THEN 'US'\n      ELSE NULL\n    END\n  ),\n  \"state\" = (\n    CASE \"residence\"\n      WHEN 'Alabama' THEN 'AL'\n      WHEN 'California' THEN 'CA'\n      WHEN 'Colorado' THEN 'CO'\n      WHEN 'Delaware' THEN 'DE'\n      WHEN 'District Of Columbia' THEN 'DC'\n      WHEN 'Florida' THEN 'FL'\n      WHEN 'Illinois' THEN 'IL'\n      WHEN 'Indiana' THEN 'IN'\n      WHEN 'Massachusetts' THEN 'MA'\n      WHEN 'Nevada' THEN 'NV'\n      WHEN 'New Jersey' THEN 'NJ'\n      WHEN 'New York' THEN 'NY'\n      WHEN 'Ohio' THEN 'OH'\n      WHEN 'Oregon' THEN 'OR'\n      WHEN 'Pennsylvania' THEN 'PA'\n      WHEN 'South Carolina' THEN 'SC'\n      WHEN 'Texas' THEN 'TX'\n      WHEN 'Virginia' THEN 'VA'\n      ELSE NULL\n    END\n  );\n\nALTER TABLE \"user\" DROP COLUMN \"residence\";\n"
  },
  {
    "path": "prisma/migrations/20230126230520_user_deletion/migration.sql",
    "content": "-- DropForeignKey\nALTER TABLE \"signed_agreement\" DROP CONSTRAINT \"signed_agreement_agreement_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"signed_agreement\" DROP CONSTRAINT \"signed_agreement_user_id_fkey\";\n\n-- AddForeignKey\nALTER TABLE \"signed_agreement\" ADD CONSTRAINT \"signed_agreement_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"signed_agreement\" ADD CONSTRAINT \"signed_agreement_agreement_id_fkey\" FOREIGN KEY (\"agreement_id\") REFERENCES \"agreement\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20230127003359_store_link_tokens/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"plaid_link_token\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20230130161915_account_value_start_date/migration.sql",
    "content": "-- update fn to fallback to the account creation date rather than `now()`\nCREATE OR REPLACE FUNCTION public.account_value_start_date(p_account_id integer) RETURNS date LANGUAGE sql STABLE AS $$\n  SELECT\n    COALESCE(\n      LEAST(\n        (a.loan->>'originationDate')::date,\n        (SELECT MIN(date) FROM \"transaction\" where \"account_id\" = a.id),\n        (SELECT MIN(date) FROM \"valuation\" where \"account_id\" = a.id),\n        (SELECT MIN(date) FROM \"investment_transaction\" where \"account_id\" = a.id)\n      ),\n      a.created_at::date -- fallback to using the date the account was added\n    )\n  FROM\n    account a\n  WHERE\n    a.id = p_account_id\n$$;\n"
  },
  {
    "path": "prisma/migrations/20230207111117_user_account_linking/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" \nADD COLUMN \"link_account_dismissed_at\" TIMESTAMPTZ(6);\n"
  },
  {
    "path": "prisma/migrations/20230207181233_add_conversation_relations/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"conversation\" ADD COLUMN     \"account_id\" INTEGER,\nADD COLUMN     \"plan_id\" INTEGER;\n\n-- AddForeignKey\nALTER TABLE \"conversation\" ADD CONSTRAINT \"conversation_account_id_fkey\" FOREIGN KEY (\"account_id\") REFERENCES \"account\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"conversation\" ADD CONSTRAINT \"conversation_plan_id_fkey\" FOREIGN KEY (\"plan_id\") REFERENCES \"plan\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20230207230108_account_balance_strategy/migration.sql",
    "content": "CREATE TYPE \"AccountBalanceStrategy\" AS ENUM ('current', 'available', 'sum', 'difference');\n\nALTER TABLE \"account\"\nADD COLUMN \"available_balance_strategy\" \"AccountBalanceStrategy\" NOT NULL DEFAULT 'available',\nADD COLUMN \"current_balance_provider\" DECIMAL(19,4),\nADD COLUMN \"current_balance_strategy\" \"AccountBalanceStrategy\" NOT NULL DEFAULT 'current';\n\n-- attempt to undo the Plaid `current = current + available` logic we currently have in place\nUPDATE \"account\"\nSET\n  \"current_balance_provider\" = (\n    CASE\n      WHEN \"plaid_type\" = 'investment' AND \"current_balance\" IS NOT NULL AND \"available_balance\" IS NOT NULL AND \"current_balance\" > \"available_balance\" THEN \"current_balance\" - \"available_balance\"\n      ELSE \"current_balance\"\n    END\n  ),\n  \"current_balance_strategy\" = (\n    CASE\n      WHEN \"plaid_type\" = 'investment' AND \"current_balance\" IS NOT NULL AND \"available_balance\" IS NOT NULL AND \"current_balance\" > \"available_balance\" THEN 'sum'\n      ELSE 'current'\n    END\n  )::\"AccountBalanceStrategy\";\n\nALTER TABLE \"account\" DROP COLUMN \"current_balance\";\n\nALTER TABLE \"account\" RENAME COLUMN \"available_balance\" TO \"available_balance_provider\";\nALTER TABLE \"account\" ADD COLUMN \"available_balance\" DECIMAL(19,4) GENERATED ALWAYS AS (\n\tCASE \"available_balance_strategy\"\n    WHEN 'current' THEN \"current_balance_provider\"\n    WHEN 'available' THEN \"available_balance_provider\"\n    WHEN 'sum' THEN \"available_balance_provider\" + \"current_balance_provider\"\n    WHEN 'difference' THEN ABS(\"available_balance_provider\" - \"current_balance_provider\")\n\tEND\n) STORED;\n\nALTER TABLE \"account\" ADD COLUMN \"current_balance\" DECIMAL(19,4) GENERATED ALWAYS AS (\n\tCASE \"current_balance_strategy\"\n    WHEN 'current' THEN \"current_balance_provider\"\n    WHEN 'available' THEN \"available_balance_provider\"\n    WHEN 'sum' THEN \"current_balance_provider\" + \"available_balance_provider\"\n    WHEN 'difference' THEN ABS(\"current_balance_provider\" - \"available_balance_provider\")\n\tEND\n) STORED;\n\nCREATE OR REPLACE FUNCTION valuation_changed() RETURNS TRIGGER LANGUAGE plpgsql AS $$\n  BEGIN\n    UPDATE account AS a\n    SET\n      start_date = account_value_start_date(a.id),\n      current_balance_provider = (SELECT v.amount FROM valuation v WHERE v.account_id = a.id ORDER BY v.date DESC LIMIT 1)\n    WHERE a.id = NEW.account_id OR a.id = OLD.account_id;\n    RETURN NULL;\n  END;\n$$;\n"
  },
  {
    "path": "prisma/migrations/20230210163006_add_user_trial_end/migration.sql",
    "content": "ALTER TABLE \"user\" ADD COLUMN     \"trial_end\" TIMESTAMPTZ(6) DEFAULT NOW() + interval '14 days';\n\n-- Users that are already subscribed or on a Stripe-based trial don't need a trial_end\nUPDATE \"user\" SET \"trial_end\" = NULL WHERE \"stripe_price_id\" IS NOT NULL;"
  },
  {
    "path": "prisma/migrations/20230211134603_advisor_crm/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"TaxStatus\" AS ENUM ('single', 'married_joint', 'married_separate', 'head_of_household', 'qualifying_widow');\n\n-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"dependents\" INTEGER,\nADD COLUMN     \"gross_income\" INTEGER,\nADD COLUMN     \"income_type\" TEXT,\nADD COLUMN     \"tax_status\" \"TaxStatus\";\n\n-- CreateTable\nCREATE TABLE \"conversation_note\" (\n    \"id\" SERIAL NOT NULL,\n    \"created_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updated_at\" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"user_id\" INTEGER NOT NULL,\n    \"conversation_id\" INTEGER NOT NULL,\n    \"body\" TEXT NOT NULL,\n\n    CONSTRAINT \"conversation_note_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"conversation_note_user_id_conversation_id_key\" ON \"conversation_note\"(\"user_id\", \"conversation_id\");\n\n-- AddForeignKey\nALTER TABLE \"conversation_note\" ADD CONSTRAINT \"conversation_note_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"conversation_note\" ADD CONSTRAINT \"conversation_note_conversation_id_fkey\" FOREIGN KEY (\"conversation_id\") REFERENCES \"conversation\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20230220194746_remove_stripe_trials/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"user\" DROP COLUMN \"stripe_trial_end\",\nDROP COLUMN \"stripe_trial_reminder_sent\",\nADD COLUMN     \"trial_reminder_sent\" TIMESTAMPTZ(6),\nALTER COLUMN \"trial_end\" SET DEFAULT NOW() + interval '14 days';\n"
  },
  {
    "path": "prisma/migrations/20230223020847_txn_view/migration.sql",
    "content": " -- AlterTable\nALTER TABLE \"transaction\"\nADD COLUMN \"match_id\" INTEGER,\nADD COLUMN \"type_user\" \"TransactionType\";\n\n-- AddForeignKey\nALTER TABLE \"transaction\" ADD CONSTRAINT \"transaction_match_id_fkey\" FOREIGN KEY (\"match_id\") REFERENCES \"transaction\" (\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\nCREATE OR REPLACE VIEW transactions_enriched AS (\n     SELECT\n        t.id,\n        t.created_at as \"createdAt\",\n        t.updated_at as \"updatedAt\",\n        t.name,\n        t.account_id as \"accountId\",\n        t.date,\n        t.flow,\n        COALESCE(\n            t.type_user,\n            CASE\n                -- no matching transaction\n                WHEN t.match_id IS NULL THEN (\n                    CASE t.flow\n                        WHEN 'INFLOW' THEN (\n                            CASE a.classification\n                                WHEN 'asset' THEN 'INCOME'::\"TransactionType\"\n                                WHEN 'liability' THEN 'PAYMENT'::\"TransactionType\"\n                            END\n                        )\n                        WHEN 'OUTFLOW' THEN 'EXPENSE'::\"TransactionType\"\n                    END\n                )\n                -- has matching transaction\n                ELSE (\n                    CASE a.classification\n                        WHEN 'asset' THEN 'TRANSFER'::\"TransactionType\"\n                        WHEN 'liability' THEN 'PAYMENT'::\"TransactionType\"\n                    END\n                )\n            END\n        ) AS \"type\",\n        t.type_user as \"typeUser\",\n        t.amount,\n        t.currency_code as \"currencyCode\",\n        t.pending,\n        t.merchant_name as \"merchantName\",\n        t.category,\n        t.category_user as \"categoryUser\",\n        t.excluded,\n        t.match_id as \"matchId\",\n        COALESCE(ac.user_id, a.user_id) as \"userId\",\n        a.classification as \"accountClassification\",\n        a.type as \"accountType\"\n    FROM\n        transaction t\n        inner join account a on a.id = t.account_id\n        left join account_connection ac on a.account_connection_id = ac.id\n);\n"
  },
  {
    "path": "prisma/migrations/20240111031553_remove_advisor_and_related_data/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `advisor_notes` on the `user` table. All the data in the column will be lost.\n  - You are about to drop the column `ata_all` on the `user` table. All the data in the column will be lost.\n  - You are about to drop the column `ata_closed` on the `user` table. All the data in the column will be lost.\n  - You are about to drop the column `ata_expire` on the `user` table. All the data in the column will be lost.\n  - You are about to drop the column `ata_review` on the `user` table. All the data in the column will be lost.\n  - You are about to drop the column `ata_submitted` on the `user` table. All the data in the column will be lost.\n  - You are about to drop the column `ata_update` on the `user` table. All the data in the column will be lost.\n  - You are about to drop the column `goals` on the `user` table. All the data in the column will be lost.\n  - You are about to drop the column `risk_answers` on the `user` table. All the data in the column will be lost.\n  - You are about to drop the column `user_notes` on the `user` table. All the data in the column will be lost.\n  - You are about to drop the `advisor` table. If the table is not empty, all the data it contains will be lost.\n  - You are about to drop the `conversation` table. If the table is not empty, all the data it contains will be lost.\n  - You are about to drop the `conversation_advisor` table. If the table is not empty, all the data it contains will be lost.\n  - You are about to drop the `conversation_note` table. If the table is not empty, all the data it contains will be lost.\n  - You are about to drop the `message` table. If the table is not empty, all the data it contains will be lost.\n\n*/\n-- DropForeignKey\nALTER TABLE \"advisor\" DROP CONSTRAINT \"advisor_user_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"conversation\" DROP CONSTRAINT \"conversation_account_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"conversation\" DROP CONSTRAINT \"conversation_plan_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"conversation\" DROP CONSTRAINT \"conversation_user_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"conversation_advisor\" DROP CONSTRAINT \"conversation_advisor_advisor_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"conversation_advisor\" DROP CONSTRAINT \"conversation_advisor_conversation_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"conversation_note\" DROP CONSTRAINT \"conversation_note_conversation_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"conversation_note\" DROP CONSTRAINT \"conversation_note_user_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"message\" DROP CONSTRAINT \"message_conversation_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"message\" DROP CONSTRAINT \"message_user_id_fkey\";\n\n-- DropIndex\nDROP INDEX \"account_balance_date_idx\";\n\n-- DropIndex\nDROP INDEX \"security_pricing_date_idx\";\n\n-- AlterTable\nALTER TABLE \"user\" DROP COLUMN \"advisor_notes\",\nDROP COLUMN \"ata_all\",\nDROP COLUMN \"ata_closed\",\nDROP COLUMN \"ata_expire\",\nDROP COLUMN \"ata_review\",\nDROP COLUMN \"ata_submitted\",\nDROP COLUMN \"ata_update\",\nDROP COLUMN \"goals\",\nDROP COLUMN \"risk_answers\",\nDROP COLUMN \"user_notes\";\n\n-- DropTable\nDROP TABLE \"advisor\";\n\n-- DropTable\nDROP TABLE \"conversation\";\n\n-- DropTable\nDROP TABLE \"conversation_advisor\";\n\n-- DropTable\nDROP TABLE \"conversation_note\";\n\n-- DropTable\nDROP TABLE \"message\";\n\n-- DropEnum\nDROP TYPE \"ConversationStatus\";\n\n-- DropEnum\nDROP TYPE \"MessageType\";\n\n-- CreateIndex\nCREATE INDEX \"account_balance_date_idx\" ON \"account_balance\"(\"date\");\n\n-- CreateIndex\nCREATE INDEX \"security_pricing_date_idx\" ON \"security_pricing\"(\"date\");\n"
  },
  {
    "path": "prisma/migrations/20240111213125_next_auth_models/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[auth_id]` on the table `user` will be added. If there are existing duplicate values, this will fail.\n  - Added the required column `auth_id` to the `user` table without a default value. This is not possible if the table is not empty.\n\n*/\n-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"auth_id\" TEXT NOT NULL;\n\n-- CreateTable\nCREATE TABLE \"auth_account\" (\n    \"id\" TEXT NOT NULL,\n    \"user_id\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"provider\" TEXT NOT NULL,\n    \"provider_account_id\" TEXT NOT NULL,\n    \"refresh_token\" TEXT,\n    \"access_token\" TEXT,\n    \"expires_at\" INTEGER,\n    \"token_type\" TEXT,\n    \"scope\" TEXT,\n    \"id_token\" TEXT,\n    \"session_state\" TEXT,\n\n    CONSTRAINT \"auth_account_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"auth_user\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT,\n    \"email\" TEXT,\n    \"email_verified\" TIMESTAMP(3),\n    \"image\" TEXT,\n\n    CONSTRAINT \"auth_user_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"auth_session\" (\n    \"id\" TEXT NOT NULL,\n    \"session_token\" TEXT NOT NULL,\n    \"user_id\" TEXT NOT NULL,\n    \"expires\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"auth_session_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"auth_verification_token\" (\n    \"identifier\" TEXT NOT NULL,\n    \"token\" TEXT NOT NULL,\n    \"expires\" TIMESTAMP(3) NOT NULL\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"auth_account_provider_provider_account_id_key\" ON \"auth_account\"(\"provider\", \"provider_account_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"auth_user_email_key\" ON \"auth_user\"(\"email\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"auth_session_session_token_key\" ON \"auth_session\"(\"session_token\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"auth_verification_token_token_key\" ON \"auth_verification_token\"(\"token\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"auth_verification_token_identifier_token_key\" ON \"auth_verification_token\"(\"identifier\", \"token\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"user_auth_id_key\" ON \"user\"(\"auth_id\");\n\n-- AddForeignKey\nALTER TABLE \"auth_account\" ADD CONSTRAINT \"auth_account_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"auth_user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"auth_session\" ADD CONSTRAINT \"auth_session_user_id_fkey\" FOREIGN KEY (\"user_id\") REFERENCES \"auth_user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20240111213725_add_password_to_auth_user/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"auth_user\" ADD COLUMN     \"password\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20240112000538_remove_agreement_code/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the `agreement` table. If the table is not empty, all the data it contains will be lost.\n  - You are about to drop the `signed_agreement` table. If the table is not empty, all the data it contains will be lost.\n\n*/\n-- DropForeignKey\nALTER TABLE \"signed_agreement\" DROP CONSTRAINT \"signed_agreement_agreement_id_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"signed_agreement\" DROP CONSTRAINT \"signed_agreement_user_id_fkey\";\n\n-- DropTable\nDROP TABLE \"agreement\";\n\n-- DropTable\nDROP TABLE \"signed_agreement\";\n\n-- DropEnum\nDROP TYPE \"AgreementType\";\n"
  },
  {
    "path": "prisma/migrations/20240112001215_remove_convert_kit_usage/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `convert_kit_id` on the `user` table. All the data in the column will be lost.\n\n*/\n-- AlterTable\nALTER TABLE \"user\" DROP COLUMN \"convert_kit_id\";\n"
  },
  {
    "path": "prisma/migrations/20240112201750_remove_auth0id_from_user/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `auth0_id` on the `user` table. All the data in the column will be lost.\n\n*/\n-- DropIndex\nDROP INDEX \"user_auth0_id_key\";\n\n-- AlterTable\nALTER TABLE \"user\" DROP COLUMN \"auth0_id\";\n"
  },
  {
    "path": "prisma/migrations/20240112204004_add_first_last_to_authuser/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"auth_user\" ADD COLUMN     \"first_name\" TEXT,\nADD COLUMN     \"last_name\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20240115222631_add_fields_for_teller/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[account_connection_id,teller_account_id]` on the table `account` will be added. If there are existing duplicate values, this will fail.\n  - A unique constraint covering the columns `[teller_transaction_id]` on the table `transaction` will be added. If there are existing duplicate values, this will fail.\n  - A unique constraint covering the columns `[teller_user_id]` on the table `user` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- AlterEnum\nALTER TYPE \"AccountConnectionType\" ADD VALUE 'teller';\n\n-- AlterTable\nALTER TABLE \"account\" ADD COLUMN     \"teller_account_id\" TEXT,\nADD COLUMN     \"teller_type\" TEXT;\n\n-- AlterTable\nALTER TABLE \"account_connection\" ADD COLUMN     \"teller_access_token\" TEXT,\nADD COLUMN     \"teller_account_id\" TEXT,\nADD COLUMN     \"teller_error\" JSONB,\nADD COLUMN     \"teller_institution_id\" TEXT;\n\n-- AlterTable\nALTER TABLE \"transaction\" ADD COLUMN     \"teller_category\" TEXT,\nADD COLUMN     \"teller_transaction_id\" TEXT,\nADD COLUMN     \"teller_type\" TEXT;\n\n-- AlterTable\nALTER TABLE \"user\" ADD COLUMN     \"teller_user_id\" TEXT;\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"account_account_connection_id_teller_account_id_key\" ON \"account\"(\"account_connection_id\", \"teller_account_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"transaction_teller_transaction_id_key\" ON \"transaction\"(\"teller_transaction_id\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"user_teller_user_id_key\" ON \"user\"(\"teller_user_id\");\n"
  },
  {
    "path": "prisma/migrations/20240116023100_add_additional_teller_fields/migration.sql",
    "content": "-- AlterEnum\nALTER TYPE \"AccountProvider\" ADD VALUE 'teller';\n\n-- AlterTable\nALTER TABLE \"account\" ADD COLUMN     \"teller_subtype\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20240116185600_add_teller_provider/migration.sql",
    "content": "-- AlterEnum\nALTER TYPE \"Provider\" ADD VALUE 'TELLER';\n"
  },
  {
    "path": "prisma/migrations/20240116224800_add_enrollment_id_for_teller/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `teller_account_id` on the `account_connection` table. All the data in the column will be lost.\n\n*/\n-- AlterTable\nALTER TABLE \"account_connection\" DROP COLUMN \"teller_account_id\",\nADD COLUMN     \"teller_enrollment_id\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20240117191553_categories_for_teller/migration.sql",
    "content": "-- AlterTable\nBEGIN;\n\nALTER TABLE\n  \"transaction\" RENAME COLUMN \"category\" TO \"category_old\";\n\nALTER TABLE\n  \"transaction\" RENAME COLUMN \"category_user\" TO \"category_user_old\";\n\nDROP VIEW IF EXISTS transactions_enriched;\n\nALTER TABLE\n  \"transaction\"\nADD\n  COLUMN \"category_user\" TEXT;\n\nALTER TABLE\n  \"transaction\"\nADD\n  COLUMN \"category\" TEXT NOT NULL GENERATED ALWAYS AS(\n    COALESCE(\n      category_user,\n      CASE\n        WHEN (\n          (\n            plaid_personal_finance_category ->> 'primary' :: text\n          ) = 'INCOME' :: text\n        ) THEN 'Income' :: text\n        WHEN (\n          (\n            plaid_personal_finance_category ->> 'detailed' :: text\n          ) = ANY (\n            ARRAY ['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text]\n          )\n        ) THEN 'Housing Payments' :: text\n        WHEN (\n          (\n            plaid_personal_finance_category ->> 'detailed' :: text\n          ) = 'LOAN_PAYMENTS_CAR_PAYMENT' :: text\n        ) THEN 'Vehicle Payments' :: text\n        WHEN (\n          (\n            plaid_personal_finance_category ->> 'primary' :: text\n          ) = 'LOAN_PAYMENTS' :: text\n        ) THEN 'Other Payments' :: text\n        WHEN (\n          (\n            plaid_personal_finance_category ->> 'primary' :: text\n          ) = 'HOME_IMPROVEMENT' :: text\n        ) THEN 'Home Improvement' :: text\n        WHEN (\n          (\n            plaid_personal_finance_category ->> 'primary' :: text\n          ) = 'GENERAL_MERCHANDISE' :: text\n        ) THEN 'Shopping' :: text\n        WHEN (\n          (\n            (\n              plaid_personal_finance_category ->> 'primary' :: text\n            ) = 'RENT_AND_UTILITIES' :: text\n          )\n          AND (\n            (\n              plaid_personal_finance_category ->> 'detailed' :: text\n            ) <> 'RENT_AND_UTILITIES_RENT' :: text\n          )\n        ) THEN 'Utilities' :: text\n        WHEN (\n          (\n            plaid_personal_finance_category ->> 'primary' :: text\n          ) = 'FOOD_AND_DRINK' :: text\n        ) THEN 'Food and Drink' :: text\n        WHEN (\n          (\n            plaid_personal_finance_category ->> 'primary' :: text\n          ) = 'TRANSPORTATION' :: text\n        ) THEN 'Transportation' :: text\n        WHEN (\n          (\n            plaid_personal_finance_category ->> 'primary' :: text\n          ) = 'TRAVEL' :: text\n        ) THEN 'Travel' :: text\n        WHEN (\n          (\n            (\n              plaid_personal_finance_category ->> 'primary' :: text\n            ) = ANY (ARRAY ['PERSONAL_CARE'::text, 'MEDICAL'::text])\n          )\n          AND (\n            (\n              plaid_personal_finance_category ->> 'detailed' :: text\n            ) <> 'MEDICAL_VETERINARY_SERVICES' :: text\n          )\n        ) THEN 'Health' :: text\n        WHEN (\n          (finicity_categorization ->> 'category' :: text) = ANY (ARRAY ['Income'::text, 'Paycheck'::text])\n        ) THEN 'Income' :: text\n        WHEN (\n          (finicity_categorization ->> 'category' :: text) = 'Mortgage & Rent' :: text\n        ) THEN 'Housing Payments' :: text\n        WHEN (\n          (finicity_categorization ->> 'category' :: text) = ANY (\n            ARRAY ['Furnishings'::text, 'Home Services'::text, 'Home Improvement'::text, 'Lawn and Garden'::text]\n          )\n        ) THEN 'Home Improvement' :: text\n        WHEN (\n          (finicity_categorization ->> 'category' :: text) = ANY (\n            ARRAY ['Streaming Services'::text, 'Home Phone'::text, 'Television'::text, 'Bills & Utilities'::text, 'Utilities'::text, 'Internet / Broadband Charges'::text, 'Mobile Phone'::text]\n          )\n        ) THEN 'Utilities' :: text\n        WHEN (\n          (finicity_categorization ->> 'category' :: text) = ANY (\n            ARRAY ['Fast Food'::text, 'Food & Dining'::text, 'Restaurants'::text, 'Coffee Shops'::text, 'Alcohol & Bars'::text, 'Groceries'::text]\n          )\n        ) THEN 'Food and Drink' :: text\n        WHEN (\n          (finicity_categorization ->> 'category' :: text) = ANY (\n            ARRAY ['Auto & Transport'::text, 'Gas & Fuel'::text, 'Auto Insurance'::text]\n          )\n        ) THEN 'Transportation' :: text\n        WHEN (\n          (finicity_categorization ->> 'category' :: text) = ANY (\n            ARRAY ['Hotel'::text, 'Travel'::text, 'Rental Car & Taxi'::text]\n          )\n        ) THEN 'Travel' :: text\n        WHEN (\n          (finicity_categorization ->> 'category' :: text) = ANY (\n            ARRAY ['Health Insurance'::text, 'Doctor'::text, 'Pharmacy'::text, 'Eyecare'::text, 'Health & Fitness'::text, 'Personal Care'::text]\n          )\n        ) THEN 'Health' :: text\n        WHEN (teller_category = 'income' :: text) THEN 'Income' :: text\n        WHEN (teller_category = 'home' :: text) THEN 'Home Improvement' :: text\n        WHEN (\n          teller_category = ANY (ARRAY ['phone'::text, 'utilities'::text])\n        ) THEN 'Utilities' :: text\n        WHEN (\n          teller_category = ANY (\n            ARRAY ['dining'::text, 'bar'::text, 'groceries'::text]\n          )\n        ) THEN 'Food and Drink' :: text\n        WHEN (\n          teller_category = ANY (\n            ARRAY ['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text]\n          )\n        ) THEN 'Shopping' :: text\n        WHEN (\n          teller_category = ANY (ARRAY ['transportation'::text, 'fuel'::text])\n        ) THEN 'Transportation' :: text\n        WHEN (\n          teller_category = ANY (ARRAY ['accommodation'::text, 'transport'::text])\n        ) THEN 'Travel' :: text\n        WHEN (teller_category = 'health' :: text) THEN 'Health' :: text\n        WHEN (\n          teller_category = ANY (\n            ARRAY ['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text]\n          )\n        ) THEN 'Other Payments' :: text\n        ELSE 'Other' :: text\n      END\n    )\n  ) STORED;\n\nCREATE\nOR REPLACE VIEW transactions_enriched AS (\n  SELECT\n    t.id,\n    t.created_at as \"createdAt\",\n    t.updated_at as \"updatedAt\",\n    t.name,\n    t.account_id as \"accountId\",\n    t.date,\n    t.flow,\n    COALESCE(\n      t.type_user,\n      CASE\n        -- no matching transaction\n        WHEN t.match_id IS NULL THEN (\n          CASE\n            t.flow\n            WHEN 'INFLOW' THEN (\n              CASE\n                a.classification\n                WHEN 'asset' THEN 'INCOME' :: \"TransactionType\"\n                WHEN 'liability' THEN 'PAYMENT' :: \"TransactionType\"\n              END\n            )\n            WHEN 'OUTFLOW' THEN 'EXPENSE' :: \"TransactionType\"\n          END\n        ) -- has matching transaction\n        ELSE (\n          CASE\n            a.classification\n            WHEN 'asset' THEN 'TRANSFER' :: \"TransactionType\"\n            WHEN 'liability' THEN 'PAYMENT' :: \"TransactionType\"\n          END\n        )\n      END\n    ) AS \"type\",\n    t.type_user as \"typeUser\",\n    t.amount,\n    t.currency_code as \"currencyCode\",\n    t.pending,\n    t.merchant_name as \"merchantName\",\n    t.category,\n    t.category_user as \"categoryUser\",\n    t.excluded,\n    t.match_id as \"matchId\",\n    COALESCE(ac.user_id, a.user_id) as \"userId\",\n    a.classification as \"accountClassification\",\n    a.type as \"accountType\"\n  FROM\n    transaction t\n    inner join account a on a.id = t.account_id\n    left join account_connection ac on a.account_connection_id = ac.id\n);\n\nALTER TABLE\n  \"transaction\" DROP COLUMN \"category_old\";\n\nALTER TABLE\n  \"transaction\" DROP COLUMN \"category_user_old\";\n\nCOMMIT;"
  },
  {
    "path": "prisma/migrations/20240118234302_remove_finicity_investment_transaction_categories/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"investment_transaction\"\n    RENAME COLUMN \"category\" TO \"category_old\";\n\nDROP VIEW IF EXISTS holdings_enriched;\n\nALTER TABLE \"investment_transaction\"\nADD COLUMN \"category\" \"InvestmentTransactionCategory\" NOT NULL GENERATED ALWAYS AS (\n  CASE\n    WHEN \"plaid_type\" = 'buy' THEN 'buy'::\"InvestmentTransactionCategory\"\n    WHEN \"plaid_type\" = 'sell' THEN 'sell'::\"InvestmentTransactionCategory\"\n    WHEN \"plaid_subtype\" IN ('dividend', 'qualified dividend', 'non-qualified dividend') THEN 'dividend'::\"InvestmentTransactionCategory\"\n    WHEN \"plaid_subtype\" IN ('non-resident tax', 'tax', 'tax withheld') THEN 'tax'::\"InvestmentTransactionCategory\"\n    WHEN \"plaid_type\" = 'fee' OR \"plaid_subtype\" IN ('account fee', 'legal fee', 'management fee', 'margin expense', 'transfer fee', 'trust fee') THEN 'fee'::\"InvestmentTransactionCategory\"\n    WHEN \"plaid_type\" = 'cash' THEN 'transfer'::\"InvestmentTransactionCategory\"\n    WHEN \"plaid_type\" = 'cancel' THEN 'cancel'::\"InvestmentTransactionCategory\"\n\n    ELSE 'other'::\"InvestmentTransactionCategory\"\n  END\n) STORED;\n\nCREATE OR REPLACE VIEW holdings_enriched AS (\n  SELECT\n    h.id,\n    h.account_id,\n    h.security_id,\n    h.quantity,\n    COALESCE(pricing_latest.price_close * h.quantity * COALESCE(s.shares_per_contract, 1), h.value) AS \"value\",\n    COALESCE(h.cost_basis, tcb.cost_basis * h.quantity) AS \"cost_basis\",\n    COALESCE(h.cost_basis / h.quantity / COALESCE(s.shares_per_contract, 1), tcb.cost_basis) AS \"cost_basis_per_share\",\n    pricing_latest.price_close AS \"price\",\n    pricing_prev.price_close AS \"price_prev\",\n    h.excluded\n  FROM\n    holding h\n    INNER JOIN security s ON s.id = h.security_id\n    -- latest security pricing\n    LEFT JOIN LATERAL (\n      SELECT\n        price_close\n      FROM\n        security_pricing\n      WHERE\n        security_id = h.security_id\n      ORDER BY\n        date DESC\n      LIMIT 1\n    ) pricing_latest ON true\n    -- previous security pricing (for computing daily ∆)\n    LEFT JOIN LATERAL (\n      SELECT\n        price_close\n      FROM\n        security_pricing\n      WHERE\n        security_id = h.security_id\n      ORDER BY\n        date DESC\n      LIMIT 1\n      OFFSET 1\n    ) pricing_prev ON true\n    -- calculate cost basis from transactions\n    LEFT JOIN (\n      SELECT\n        it.account_id,\n        it.security_id,\n        SUM(it.quantity * it.price) / SUM(it.quantity) AS cost_basis\n      FROM\n        investment_transaction it\n      WHERE\n        it.plaid_type = 'buy'\n        AND it.quantity > 0\n      GROUP BY\n        it.account_id,\n        it.security_id\n    ) tcb ON tcb.account_id = h.account_id AND tcb.security_id = s.id\n);\n\nALTER TABLE \"investment_transaction\" DROP COLUMN \"category_old\";\n"
  },
  {
    "path": "prisma/migrations/20240118234302_remove_finicity_transaction_categories/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"transaction\"\n    RENAME COLUMN \"category\" TO \"category_old\";\nALTER TABLE \"transaction\"\n    RENAME COLUMN \"category_user\" TO \"category_user_old\";\n\nDROP VIEW IF EXISTS transactions_enriched;\n\nALTER TABLE \"transaction\" ADD COLUMN \"category_user\" TEXT;\n\nALTER TABLE \"transaction\"\n    ADD COLUMN \"category\" TEXT NOT NULL GENERATED ALWAYS AS (COALESCE(category_user,\nCASE\n    WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'INCOME'::text) THEN 'Income'::text\n    WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = ANY (ARRAY['LOAN_PAYMENTS_MORTGAGE_PAYMENT'::text, 'RENT_AND_UTILITIES_RENT'::text])) THEN 'Housing Payments'::text\n    WHEN ((plaid_personal_finance_category ->> 'detailed'::text) = 'LOAN_PAYMENTS_CAR_PAYMENT'::text) THEN 'Vehicle Payments'::text\n    WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'LOAN_PAYMENTS'::text) THEN 'Other Payments'::text\n    WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'HOME_IMPROVEMENT'::text) THEN 'Home Improvement'::text\n    WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'GENERAL_MERCHANDISE'::text) THEN 'Shopping'::text\n    WHEN (((plaid_personal_finance_category ->> 'primary'::text) = 'RENT_AND_UTILITIES'::text) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'RENT_AND_UTILITIES_RENT'::text)) THEN 'Utilities'::text\n    WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'FOOD_AND_DRINK'::text) THEN 'Food and Drink'::text\n    WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRANSPORTATION'::text) THEN 'Transportation'::text\n    WHEN ((plaid_personal_finance_category ->> 'primary'::text) = 'TRAVEL'::text) THEN 'Travel'::text\n    WHEN (((plaid_personal_finance_category ->> 'primary'::text) = ANY (ARRAY['PERSONAL_CARE'::text, 'MEDICAL'::text])) AND ((plaid_personal_finance_category ->> 'detailed'::text) <> 'MEDICAL_VETERINARY_SERVICES'::text)) THEN 'Health'::text\n    WHEN (teller_category = 'income'::text) THEN 'Income'::text\n    WHEN (teller_category = 'home'::text) THEN 'Home Improvement'::text\n    WHEN (teller_category = ANY (ARRAY['phone'::text, 'utilities'::text])) THEN 'Utilities'::text\n    WHEN (teller_category = ANY (ARRAY['dining'::text, 'bar'::text, 'groceries'::text])) THEN 'Food and Drink'::text\n    WHEN (teller_category = ANY (ARRAY['clothing'::text, 'entertainment'::text, 'shopping'::text, 'electronics'::text, 'software'::text, 'sport'::text])) THEN 'Shopping'::text\n    WHEN (teller_category = ANY (ARRAY['transportation'::text, 'fuel'::text])) THEN 'Transportation'::text\n    WHEN (teller_category = ANY (ARRAY['accommodation'::text, 'transport'::text])) THEN 'Travel'::text\n    WHEN (teller_category = 'health'::text) THEN 'Health'::text\n    WHEN (teller_category = ANY (ARRAY['loan'::text, 'tax'::text, 'insurance'::text, 'office'::text])) THEN 'Other Payments'::text\n    ELSE 'Other'::text\nEND)) STORED;\n\nCREATE OR REPLACE VIEW transactions_enriched AS (\n  SELECT\n    t.id,\n    t.created_at as \"createdAt\",\n    t.updated_at as \"updatedAt\",\n    t.name,\n    t.account_id as \"accountId\",\n    t.date,\n    t.flow,\n    COALESCE(\n      t.type_user,\n      CASE\n        -- no matching transaction\n        WHEN t.match_id IS NULL THEN (\n          CASE\n            t.flow\n            WHEN 'INFLOW' THEN (\n              CASE\n                a.classification\n                WHEN 'asset' THEN 'INCOME' :: \"TransactionType\"\n                WHEN 'liability' THEN 'PAYMENT' :: \"TransactionType\"\n              END\n            )\n            WHEN 'OUTFLOW' THEN 'EXPENSE' :: \"TransactionType\"\n          END\n        ) -- has matching transaction\n        ELSE (\n          CASE\n            a.classification\n            WHEN 'asset' THEN 'TRANSFER' :: \"TransactionType\"\n            WHEN 'liability' THEN 'PAYMENT' :: \"TransactionType\"\n          END\n        )\n      END\n    ) AS \"type\",\n    t.type_user as \"typeUser\",\n    t.amount,\n    t.currency_code as \"currencyCode\",\n    t.pending,\n    t.merchant_name as \"merchantName\",\n    t.category,\n    t.category_user as \"categoryUser\",\n    t.excluded,\n    t.match_id as \"matchId\",\n    COALESCE(ac.user_id, a.user_id) as \"userId\",\n    a.classification as \"accountClassification\",\n    a.type as \"accountType\"\n  FROM\n    transaction t\n    inner join account a on a.id = t.account_id\n    left join account_connection ac on a.account_connection_id = ac.id\n);\n\nALTER TABLE \"transaction\" DROP COLUMN \"category_old\";\nALTER TABLE \"transaction\" DROP COLUMN \"category_user_old\";\n"
  },
  {
    "path": "prisma/migrations/20240118234303_remove_finicity_usage/migration.sql",
    "content": "/*\n  Warnings:\n\n  - The values [finicity] on the enum `AccountConnectionType` will be removed. If these variants are still used in the database, this will fail.\n  - The values [finicity] on the enum `AccountProvider` will be removed. If these variants are still used in the database, this will fail.\n  - The values [FINICITY] on the enum `Provider` will be removed. If these variants are still used in the database, this will fail.\n  - You are about to drop the column `finicity_account_id` on the `account` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_detail` on the `account` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_type` on the `account` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_error` on the `account_connection` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_institution_id` on the `account_connection` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_institution_login_id` on the `account_connection` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_position_id` on the `holding` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_investment_transaction_type` on the `investment_transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_transaction_id` on the `investment_transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_asset_class` on the `security` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_fi_asset_class` on the `security` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_security_id` on the `security` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_security_id_type` on the `security` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_type` on the `security` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_categorization` on the `transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_transaction_id` on the `transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_type` on the `transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_customer_id` on the `user` table. All the data in the column will be lost.\n  - You are about to drop the column `finicity_username` on the `user` table. All the data in the column will be lost.\n\n*/\n-- AlterEnum\nBEGIN;\nCREATE TYPE \"AccountConnectionType_new\" AS ENUM ('plaid', 'teller');\nALTER TABLE \"account_connection\" ALTER COLUMN \"type\" TYPE \"AccountConnectionType_new\" USING (\"type\"::text::\"AccountConnectionType_new\");\nALTER TYPE \"AccountConnectionType\" RENAME TO \"AccountConnectionType_old\";\nALTER TYPE \"AccountConnectionType_new\" RENAME TO \"AccountConnectionType\";\nDROP TYPE \"AccountConnectionType_old\";\nCOMMIT;\n\n-- AlterEnum\nBEGIN;\nCREATE TYPE \"AccountProvider_new\" AS ENUM ('user', 'plaid', 'teller');\nALTER TABLE \"account\" ALTER COLUMN \"provider\" TYPE \"AccountProvider_new\" USING (\"provider\"::text::\"AccountProvider_new\");\nALTER TYPE \"AccountProvider\" RENAME TO \"AccountProvider_old\";\nALTER TYPE \"AccountProvider_new\" RENAME TO \"AccountProvider\";\nDROP TYPE \"AccountProvider_old\";\nCOMMIT;\n\n-- AlterEnum\nBEGIN;\nCREATE TYPE \"Provider_new\" AS ENUM ('PLAID', 'TELLER');\nALTER TABLE \"provider_institution\" ALTER COLUMN \"provider\" TYPE \"Provider_new\" USING (\"provider\"::text::\"Provider_new\");\nALTER TYPE \"Provider\" RENAME TO \"Provider_old\";\nALTER TYPE \"Provider_new\" RENAME TO \"Provider\";\nDROP TYPE \"Provider_old\";\nCOMMIT;\n\n-- DropIndex\nDROP INDEX \"account_account_connection_id_finicity_account_id_key\";\n\n-- DropIndex\nDROP INDEX \"holding_finicity_position_id_key\";\n\n-- DropIndex\nDROP INDEX \"investment_transaction_finicity_transaction_id_key\";\n\n-- DropIndex\nDROP INDEX \"security_finicity_security_id_finicity_security_id_type_key\";\n\n-- DropIndex\nDROP INDEX \"transaction_finicity_transaction_id_key\";\n\n-- DropIndex\nDROP INDEX \"user_finicity_customer_id_key\";\n\n-- DropIndex\nDROP INDEX \"user_finicity_username_key\";\n\n-- AlterTable\nALTER TABLE \"account\" DROP COLUMN \"finicity_account_id\",\nDROP COLUMN \"finicity_detail\",\nDROP COLUMN \"finicity_type\";\n\n-- AlterTable\nALTER TABLE \"account_connection\" DROP COLUMN \"finicity_error\",\nDROP COLUMN \"finicity_institution_id\",\nDROP COLUMN \"finicity_institution_login_id\";\n\n-- AlterTable\nALTER TABLE \"holding\" DROP COLUMN \"finicity_position_id\";\n\n-- AlterTable\nALTER TABLE \"investment_transaction\" DROP COLUMN \"finicity_investment_transaction_type\",\nDROP COLUMN \"finicity_transaction_id\";\n\n-- AlterTable\nALTER TABLE \"security\" DROP COLUMN \"finicity_asset_class\",\nDROP COLUMN \"finicity_fi_asset_class\",\nDROP COLUMN \"finicity_security_id\",\nDROP COLUMN \"finicity_security_id_type\",\nDROP COLUMN \"finicity_type\";\n\n-- AlterTable\nALTER TABLE \"transaction\" DROP COLUMN \"finicity_categorization\",\nDROP COLUMN \"finicity_transaction_id\",\nDROP COLUMN \"finicity_type\";\n\n-- AlterTable\nALTER TABLE \"user\" DROP COLUMN \"finicity_customer_id\",\nDROP COLUMN \"finicity_username\";\n"
  },
  {
    "path": "prisma/migrations/20240120213022_remove_transaction_category_generation/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"transaction\"\n  RENAME COLUMN \"category\" TO \"category_old\";\n\nDROP VIEW IF EXISTS transactions_enriched;\n\nALTER TABLE \"transaction\"\n  ADD COLUMN \"category\" TEXT NOT NULL DEFAULT 'Other'::text;\n\nCREATE OR REPLACE VIEW transactions_enriched AS (\n  SELECT\n    t.id,\n    t.created_at as \"createdAt\",\n    t.updated_at as \"updatedAt\",\n    t.name,\n    t.account_id as \"accountId\",\n    t.date,\n    t.flow,\n    COALESCE(\n      t.type_user,\n      CASE\n        -- no matching transaction\n        WHEN t.match_id IS NULL THEN (\n          CASE\n            t.flow\n            WHEN 'INFLOW' THEN (\n              CASE\n                a.classification\n                WHEN 'asset' THEN 'INCOME' :: \"TransactionType\"\n                WHEN 'liability' THEN 'PAYMENT' :: \"TransactionType\"\n              END\n            )\n            WHEN 'OUTFLOW' THEN 'EXPENSE' :: \"TransactionType\"\n          END\n        ) -- has matching transaction\n        ELSE (\n          CASE\n            a.classification\n            WHEN 'asset' THEN 'TRANSFER' :: \"TransactionType\"\n            WHEN 'liability' THEN 'PAYMENT' :: \"TransactionType\"\n          END\n        )\n      END\n    ) AS \"type\",\n    t.type_user as \"typeUser\",\n    t.amount,\n    t.currency_code as \"currencyCode\",\n    t.pending,\n    t.merchant_name as \"merchantName\",\n    t.category,\n    t.category_user as \"categoryUser\",\n    t.excluded,\n    t.match_id as \"matchId\",\n    COALESCE(ac.user_id, a.user_id) as \"userId\",\n    a.classification as \"accountClassification\",\n    a.type as \"accountType\"\n  FROM\n    transaction t\n    inner join account a on a.id = t.account_id\n    left join account_connection ac on a.account_connection_id = ac.id\n);\n\nALTER TABLE \"transaction\" DROP COLUMN \"category_old\";\n"
  },
  {
    "path": "prisma/migrations/20240120215821_remove_investment_transaction_category_generation/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"investment_transaction\"\n  RENAME COLUMN \"category\" TO \"category_old\";\n\nDROP VIEW IF EXISTS holdings_enriched;\n\nALTER TABLE \"investment_transaction\"\n  ADD COLUMN \"category\" \"InvestmentTransactionCategory\" NOT NULL DEFAULT 'other'::\"InvestmentTransactionCategory\";\n\nCREATE OR REPLACE VIEW holdings_enriched AS (\n  SELECT\n    h.id,\n    h.account_id,\n    h.security_id,\n    h.quantity,\n    COALESCE(pricing_latest.price_close * h.quantity * COALESCE(s.shares_per_contract, 1), h.value) AS \"value\",\n    COALESCE(h.cost_basis, tcb.cost_basis * h.quantity) AS \"cost_basis\",\n    COALESCE(h.cost_basis / h.quantity / COALESCE(s.shares_per_contract, 1), tcb.cost_basis) AS \"cost_basis_per_share\",\n    pricing_latest.price_close AS \"price\",\n    pricing_prev.price_close AS \"price_prev\",\n    h.excluded\n  FROM\n    holding h\n    INNER JOIN security s ON s.id = h.security_id\n    -- latest security pricing\n    LEFT JOIN LATERAL (\n      SELECT\n        price_close\n      FROM\n        security_pricing\n      WHERE\n        security_id = h.security_id\n      ORDER BY\n        date DESC\n      LIMIT 1\n    ) pricing_latest ON true\n    -- previous security pricing (for computing daily ∆)\n    LEFT JOIN LATERAL (\n      SELECT\n        price_close\n      FROM\n        security_pricing\n      WHERE\n        security_id = h.security_id\n      ORDER BY\n        date DESC\n      LIMIT 1\n      OFFSET 1\n    ) pricing_prev ON true\n    -- calculate cost basis from transactions\n    LEFT JOIN (\n      SELECT\n        it.account_id,\n        it.security_id,\n        SUM(it.quantity * it.price) / SUM(it.quantity) AS cost_basis\n      FROM\n        investment_transaction it\n      WHERE\n        it.category = 'buy'\n        AND it.quantity > 0\n      GROUP BY\n        it.account_id,\n        it.security_id\n    ) tcb ON tcb.account_id = h.account_id AND tcb.security_id = s.id\n);\n\nCREATE OR REPLACE FUNCTION calculate_return_dietz(p_account_id account.id%type, p_start date, p_end date, out percentage numeric, out amount numeric) AS $$\n  DECLARE\n    v_start date := GREATEST(p_start, (SELECT MIN(date) FROM account_balance WHERE account_id = p_account_id));\n    v_end date := p_end;\n    v_days int := v_end - v_start;\n  BEGIN\n    SELECT\n      ROUND((b1.balance - b0.balance - flows.net) / NULLIF(b0.balance + flows.weighted, 0), 4) AS \"percentage\",\n      b1.balance - b0.balance - flows.net AS \"amount\"\n    INTO\n      percentage, amount\n  FROM\n    account a\n    LEFT JOIN LATERAL (\n      SELECT\n        COALESCE(SUM(-fw.flow), 0) AS \"net\",\n        COALESCE(SUM(-fw.flow * fw.weight), 0) AS \"weighted\"\n      FROM (\n        SELECT\n          SUM(it.amount) AS flow,\n          (v_days - (it.date - v_start))::numeric / v_days AS weight\n        FROM\n          investment_transaction it\n        WHERE\n          it.account_id = a.id\n          AND it.date BETWEEN v_start AND v_end\n          -- filter for investment_transactions that represent external flows\n          AND it.category = 'transfer'\n        GROUP BY\n          it.date\n      ) fw\n    ) flows ON TRUE\n    LEFT JOIN LATERAL (\n      SELECT\n        ab.balance AS \"balance\"\n      FROM\n        account_balance ab\n      WHERE\n        ab.account_id = a.id AND ab.date <= v_start\n      ORDER BY\n        ab.date DESC\n      LIMIT 1\n    ) b0 ON TRUE\n    LEFT JOIN LATERAL (\n      SELECT\n        COALESCE(ab.balance, a.current_balance) AS \"balance\"\n      FROM\n        account_balance ab\n      WHERE\n        ab.account_id = a.id AND ab.date <= v_end\n      ORDER BY\n        ab.date DESC\n      LIMIT 1\n    ) b1 ON TRUE\n  WHERE\n    a.id = p_account_id;\n  END;\n$$ LANGUAGE plpgsql STABLE;\n\nALTER TABLE \"investment_transaction\"\n  DROP COLUMN \"category_old\";\n"
  },
  {
    "path": "prisma/migrations/20240121003016_add_asset_class_to_security/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"AssetClass\" AS ENUM ('cash', 'crypto', 'fixed_income', 'stocks', 'other');\n\n-- AlterTable\nALTER TABLE \"security\"\n  ADD COLUMN \"asset_class\" \"AssetClass\" NOT NULL DEFAULT 'other';\n"
  },
  {
    "path": "prisma/migrations/20240121011219_add_provider_name_to_security/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"SecurityProvider\" AS ENUM ('polygon', 'other');\n\n-- AlterTable\nALTER TABLE \"security\" ADD COLUMN     \"provider_name\" \"SecurityProvider\" DEFAULT 'other';\n"
  },
  {
    "path": "prisma/migrations/20240121013630_add_exchange_info_to_security/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"security\" ADD COLUMN     \"exchange_acronym\" TEXT,\nADD COLUMN     \"exchange_mic\" TEXT,\nADD COLUMN     \"exchange_name\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20240121084645_create_unique_fields_for_security/migration.sql",
    "content": "ALTER TABLE security\nADD UNIQUE (symbol, exchange_mic);\n"
  },
  {
    "path": "prisma/migrations/20240121204146_add_auth_user_role/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"AuthUserRole\" AS ENUM ('user', 'admin', 'ci');\n\n-- AlterTable\nALTER TABLE \"auth_user\" ADD COLUMN     \"role\" \"AuthUserRole\" NOT NULL DEFAULT 'user';\n"
  },
  {
    "path": "prisma/migrations/20240124090855_add_options_asset_class/migration.sql",
    "content": "-- AlterEnum\nALTER TYPE \"AssetClass\" ADD VALUE 'options';\n"
  },
  {
    "path": "prisma/migrations/20240124102931_remove_plaid_usage/migration.sql",
    "content": "/*\n  Warnings:\n\n  - The values [plaid] on the enum `AccountConnectionType` will be removed. If these variants are still used in the database, this will fail.\n  - The values [plaid] on the enum `AccountProvider` will be removed. If these variants are still used in the database, this will fail.\n  - The values [PLAID] on the enum `Provider` will be removed. If these variants are still used in the database, this will fail.\n  - You are about to drop the column `plaid_account_id` on the `account` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_liability` on the `account` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_subtype` on the `account` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_type` on the `account` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_access_token` on the `account_connection` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_consent_expiration` on the `account_connection` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_error` on the `account_connection` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_institution_id` on the `account_connection` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_item_id` on the `account_connection` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_new_accounts_available` on the `account_connection` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_holding_id` on the `holding` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_investment_transaction_id` on the `investment_transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_subtype` on the `investment_transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_type` on the `investment_transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_is_cash_equivalent` on the `security` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_security_id` on the `security` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_type` on the `security` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_category` on the `transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_category_id` on the `transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_personal_finance_category` on the `transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_transaction_id` on the `transaction` table. All the data in the column will be lost.\n  - You are about to drop the column `plaid_link_token` on the `user` table. All the data in the column will be lost.\n\n*/\n-- AlterEnum\nBEGIN;\nCREATE TYPE \"AccountConnectionType_new\" AS ENUM ('teller');\nALTER TABLE \"account_connection\" ALTER COLUMN \"type\" TYPE \"AccountConnectionType_new\" USING (\"type\"::text::\"AccountConnectionType_new\");\nALTER TYPE \"AccountConnectionType\" RENAME TO \"AccountConnectionType_old\";\nALTER TYPE \"AccountConnectionType_new\" RENAME TO \"AccountConnectionType\";\nDROP TYPE \"AccountConnectionType_old\";\nCOMMIT;\n\n-- AlterEnum\nBEGIN;\nCREATE TYPE \"AccountProvider_new\" AS ENUM ('user', 'teller');\nALTER TABLE \"account\" ALTER COLUMN \"provider\" TYPE \"AccountProvider_new\" USING (\"provider\"::text::\"AccountProvider_new\");\nALTER TYPE \"AccountProvider\" RENAME TO \"AccountProvider_old\";\nALTER TYPE \"AccountProvider_new\" RENAME TO \"AccountProvider\";\nDROP TYPE \"AccountProvider_old\";\nCOMMIT;\n\n-- AlterEnum\nBEGIN;\nCREATE TYPE \"Provider_new\" AS ENUM ('TELLER');\nALTER TABLE \"provider_institution\" ALTER COLUMN \"provider\" TYPE \"Provider_new\" USING (\"provider\"::text::\"Provider_new\");\nALTER TYPE \"Provider\" RENAME TO \"Provider_old\";\nALTER TYPE \"Provider_new\" RENAME TO \"Provider\";\nDROP TYPE \"Provider_old\";\nCOMMIT;\n\n-- DropIndex\nDROP INDEX \"account_account_connection_id_plaid_account_id_key\";\n\n-- DropIndex\nDROP INDEX \"account_connection_plaid_item_id_key\";\n\n-- DropIndex\nDROP INDEX \"holding_plaid_holding_id_key\";\n\n-- DropIndex\nDROP INDEX \"investment_transaction_plaid_investment_transaction_id_key\";\n\n-- DropIndex\nDROP INDEX \"security_plaid_security_id_key\";\n\n-- DropIndex\nDROP INDEX \"transaction_plaid_transaction_id_key\";\n\n-- AlterTable\nALTER TABLE \"account\" DROP COLUMN \"plaid_account_id\",\nDROP COLUMN \"plaid_liability\",\nDROP COLUMN \"plaid_subtype\",\nDROP COLUMN \"plaid_type\";\n\n-- AlterTable\nALTER TABLE \"account_connection\" DROP COLUMN \"plaid_access_token\",\nDROP COLUMN \"plaid_consent_expiration\",\nDROP COLUMN \"plaid_error\",\nDROP COLUMN \"plaid_institution_id\",\nDROP COLUMN \"plaid_item_id\",\nDROP COLUMN \"plaid_new_accounts_available\";\n\n-- AlterTable\nALTER TABLE \"holding\" DROP COLUMN \"plaid_holding_id\";\n\n-- AlterTable\nALTER TABLE \"investment_transaction\" DROP COLUMN \"plaid_investment_transaction_id\",\nDROP COLUMN \"plaid_subtype\",\nDROP COLUMN \"plaid_type\";\n\n-- AlterTable\nALTER TABLE \"security\" DROP COLUMN \"plaid_is_cash_equivalent\",\nDROP COLUMN \"plaid_security_id\",\nDROP COLUMN \"plaid_type\";\n\n-- AlterTable\nALTER TABLE \"transaction\" DROP COLUMN \"plaid_category\",\nDROP COLUMN \"plaid_category_id\",\nDROP COLUMN \"plaid_personal_finance_category\",\nDROP COLUMN \"plaid_transaction_id\";\n\n-- AlterTable\nALTER TABLE \"user\" DROP COLUMN \"plaid_link_token\";\n"
  },
  {
    "path": "prisma/migrations/migration_lock.toml",
    "content": "# Please do not edit this file manually\n# It should be added in your version-control system (i.e. Git)\nprovider = \"postgresql\""
  },
  {
    "path": "prisma/schema.prisma",
    "content": "// Schema follows Prisma naming conventions\n// https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#naming-conventions\n\ngenerator client {\n  provider        = \"prisma-client-js\"\n  previewFeatures = [\"fullTextSearch\", \"tracing\"]\n  binaryTargets   = [\"native\", \"linux-musl\", \"debian-openssl-1.1.x\"]\n}\n\ndatasource db {\n  provider = \"postgresql\"\n  url      = env(\"NX_DATABASE_URL\")\n}\n\n// hypertable\nmodel AccountBalance {\n  createdAt DateTime @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  account   Account  @relation(fields: [accountId], references: [id], onDelete: Cascade)\n  accountId Int      @map(\"account_id\")\n  date      DateTime @db.Date\n  balance   Decimal  @db.Decimal(19, 4)\n  inflows   Decimal? @default(0) @db.Decimal(19, 4)\n  outflows  Decimal? @default(0) @db.Decimal(19, 4)\n\n  @@id([accountId, date])\n  @@index([date])\n  @@map(\"account_balance\")\n}\n\nenum AccountConnectionStatus {\n  OK\n  ERROR\n  DISCONNECTED\n}\n\nenum AccountSyncStatus {\n  IDLE\n  PENDING\n  SYNCING\n}\n\nenum AccountConnectionType {\n  teller\n}\n\nmodel AccountConnection {\n  id         Int                     @id @default(autoincrement())\n  createdAt  DateTime                @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt  DateTime                @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  user       User                    @relation(fields: [userId], references: [id], onDelete: Cascade)\n  userId     Int                     @map(\"user_id\")\n  name       String\n  type       AccountConnectionType\n  status     AccountConnectionStatus @default(OK)\n  syncStatus AccountSyncStatus       @default(IDLE) @map(\"sync_status\")\n\n  // teller data\n  tellerAccessToken   String? @map(\"teller_access_token\")\n  tellerEnrollmentId  String? @map(\"teller_enrollment_id\")\n  tellerInstitutionId String? @map(\"teller_institution_id\")\n  tellerError         Json?   @map(\"teller_error\")\n\n  accounts Account[]\n\n  @@index([userId])\n  @@map(\"account_connection\")\n}\n\nenum AccountType {\n  INVESTMENT\n  DEPOSITORY\n  CREDIT\n  LOAN\n  PROPERTY\n  VEHICLE\n  OTHER_ASSET\n  OTHER_LIABILITY\n}\n\nenum AccountCategory {\n  cash\n  investment\n  crypto\n  property\n  vehicle\n  valuable\n  loan\n  credit\n  other\n}\n\nenum AccountProvider {\n  user\n  teller\n}\n\nenum AccountBalanceStrategy {\n  current\n  available\n  sum\n  difference\n}\n\nmodel Account {\n  id                       Int                    @id @default(autoincrement())\n  createdAt                DateTime               @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt                DateTime               @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  startDate                DateTime?              @map(\"start_date\") @db.Date\n  type                     AccountType\n  provider                 AccountProvider\n  classification           AccountClassification  @default(dbgenerated(\"\\nCASE\\n    WHEN (type = ANY (ARRAY['INVESTMENT'::\\\"AccountType\\\", 'DEPOSITORY'::\\\"AccountType\\\", 'PROPERTY'::\\\"AccountType\\\", 'VEHICLE'::\\\"AccountType\\\", 'OTHER_ASSET'::\\\"AccountType\\\"])) THEN 'asset'::\\\"AccountClassification\\\"\\n    WHEN (type = ANY (ARRAY['CREDIT'::\\\"AccountType\\\", 'LOAN'::\\\"AccountType\\\", 'OTHER_LIABILITY'::\\\"AccountType\\\"])) THEN 'liability'::\\\"AccountClassification\\\"\\n    ELSE NULL::\\\"AccountClassification\\\"\\nEND\"))\n  category                 AccountCategory        @default(dbgenerated(\"COALESCE(category_user, category_provider, 'other'::\\\"AccountCategory\\\")\"))\n  categoryProvider         AccountCategory?       @map(\"category_provider\")\n  categoryUser             AccountCategory?       @map(\"category_user\")\n  subcategory              String                 @default(dbgenerated())\n  subcategoryProvider      String?                @map(\"subcategory_provider\")\n  subcategoryUser          String?                @map(\"subcategory_user\")\n  accountConnection        AccountConnection?     @relation(fields: [accountConnectionId], references: [id], onDelete: Cascade)\n  accountConnectionId      Int?                   @map(\"account_connection_id\")\n  user                     User?                  @relation(fields: [userId], references: [id], onDelete: Cascade)\n  userId                   Int?                   @map(\"user_id\")\n  name                     String\n  mask                     String?\n  isActive                 Boolean                @default(true) @map(\"is_active\")\n  syncStatus               AccountSyncStatus      @default(IDLE) @map(\"sync_status\")\n  currencyCode             String                 @default(\"USD\") @map(\"currency_code\")\n  currentBalance           Decimal?               @default(dbgenerated()) @map(\"current_balance\") @db.Decimal(19, 4)\n  currentBalanceProvider   Decimal?               @map(\"current_balance_provider\") @db.Decimal(19, 4)\n  currentBalanceStrategy   AccountBalanceStrategy @default(current) @map(\"current_balance_strategy\")\n  availableBalance         Decimal?               @default(dbgenerated()) @map(\"available_balance\") @db.Decimal(19, 4)\n  availableBalanceProvider Decimal?               @map(\"available_balance_provider\") @db.Decimal(19, 4)\n  availableBalanceStrategy AccountBalanceStrategy @default(available) @map(\"available_balance_strategy\")\n\n  // teller data\n  tellerAccountId String? @map(\"teller_account_id\")\n  tellerType      String? @map(\"teller_type\")\n  tellerSubtype   String? @map(\"teller_subtype\")\n\n  // manual account data\n  vehicleMeta  Json? @map(\"vehicle_meta\") @db.JsonB\n  propertyMeta Json? @map(\"property_meta\") @db.JsonB\n\n  loan         Json? @default(dbgenerated()) @db.JsonB\n  loanUser     Json? @map(\"loan_user\") @db.JsonB\n  loanProvider Json? @map(\"loan_provider\") @db.JsonB\n\n  credit         Json? @default(dbgenerated()) @db.JsonB\n  creditUser     Json? @map(\"credit_user\") @db.JsonB\n  creditProvider Json? @map(\"credit_provider\") @db.JsonB\n\n  balances               AccountBalance[]\n  transactions           Transaction[]\n  valuations             Valuation[]\n  holdings               Holding[]\n  investmentTransactions InvestmentTransaction[]\n\n  @@unique([accountConnectionId, tellerAccountId])\n  @@index([accountConnectionId])\n  @@index([userId])\n  @@map(\"account\")\n}\n\nenum AccountClassification {\n  asset\n  liability\n}\n\nmodel Holding {\n  id                Int      @id @default(autoincrement())\n  createdAt         DateTime @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt         DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  account           Account  @relation(fields: [accountId], references: [id], onDelete: Cascade)\n  accountId         Int      @map(\"account_id\")\n  security          Security @relation(fields: [securityId], references: [id], onDelete: Cascade)\n  securityId        Int      @map(\"security_id\")\n  value             Decimal  @db.Decimal(19, 4)\n  quantity          Decimal  @db.Decimal(36, 18)\n  costBasis         Decimal? @default(dbgenerated()) @map(\"cost_basis\") @db.Decimal(23, 8)\n  costBasisProvider Decimal? @map(\"cost_basis_provider\") @db.Decimal(23, 8)\n  costBasisUser     Decimal? @map(\"cost_basis_user\") @db.Decimal(23, 8)\n  currencyCode      String   @default(\"USD\") @map(\"currency_code\")\n  excluded          Boolean  @default(false)\n\n  @@map(\"holding\")\n}\n\nenum InvestmentTransactionCategory {\n  buy\n  sell\n  dividend\n  transfer\n  tax\n  fee\n  cancel\n  other\n}\n\nmodel InvestmentTransaction {\n  id           Int                           @id @default(autoincrement())\n  createdAt    DateTime                      @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt    DateTime                      @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  account      Account                       @relation(fields: [accountId], references: [id], onDelete: Cascade)\n  accountId    Int                           @map(\"account_id\")\n  security     Security?                     @relation(fields: [securityId], references: [id], onDelete: Cascade)\n  securityId   Int?                          @map(\"security_id\")\n  date         DateTime                      @db.Date\n  name         String\n  amount       Decimal                       @db.Decimal(19, 4)\n  fees         Decimal?                      @db.Decimal(19, 4)\n  flow         TransactionFlow               @default(dbgenerated(\"\\nCASE\\n    WHEN (amount < (0)::numeric) THEN 'INFLOW'::\\\"TransactionFlow\\\"\\n    ELSE 'OUTFLOW'::\\\"TransactionFlow\\\"\\nEND\"))\n  quantity     Decimal                       @db.Decimal(36, 18)\n  price        Decimal                       @db.Decimal(23, 8)\n  currencyCode String                        @default(\"USD\") @map(\"currency_code\")\n  category     InvestmentTransactionCategory @default(other)\n\n  @@index([accountId, date])\n  @@map(\"investment_transaction\")\n}\n\nenum SecurityProvider {\n  polygon\n  other\n}\n\nenum AssetClass {\n  cash\n  crypto\n  fixed_income\n  options\n  stocks\n  other\n}\n\nmodel Security {\n  id                  Int               @id @default(autoincrement())\n  createdAt           DateTime          @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt           DateTime          @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  name                String?\n  symbol              String?\n  cusip               String?\n  isin                String?\n  sharesPerContract   Decimal?          @map(\"shares_per_contract\") @db.Decimal(36, 18)\n  currencyCode        String            @default(\"USD\") @map(\"currency_code\")\n  pricingLastSyncedAt DateTime?         @map(\"pricing_last_synced_at\") @db.Timestamptz(6)\n  isBrokerageCash     Boolean           @default(false) @map(\"is_brokerage_cash\")\n  exchangeAcroynm     String?           @map(\"exchange_acronym\")\n  exchangeMic         String?           @map(\"exchange_mic\")\n  exchangeName        String?           @map(\"exchange_name\")\n  providerName        SecurityProvider? @default(other) @map(\"provider_name\")\n  assetClass          AssetClass        @default(other) @map(\"asset_class\")\n\n  holdings               Holding[]\n  investmentTransactions InvestmentTransaction[]\n  pricing                SecurityPricing[]\n\n  @@unique([symbol, exchangeMic])\n  @@map(\"security\")\n}\n\n// hypertable\nmodel SecurityPricing {\n  createdAt  DateTime @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt  DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  security   Security @relation(fields: [securityId], references: [id], onDelete: Cascade)\n  securityId Int      @map(\"security_id\")\n  date       DateTime @db.Date\n  priceClose Decimal  @map(\"price_close\") @db.Decimal(23, 8)\n  priceAsOf  DateTime @default(now()) @map(\"price_as_of\") @db.Timestamptz(6)\n  source     String?\n\n  @@id([securityId, date])\n  @@index([date])\n  @@map(\"security_pricing\")\n}\n\nenum TransactionFlow {\n  INFLOW\n  OUTFLOW\n}\n\nenum TransactionType {\n  INCOME // inflow to asset\n  EXPENSE // outflow from asset OR outflow from liability\n  PAYMENT // outflow from asset OR inflow/outflow from liability\n  TRANSFER // inflow/outflow from asset OR inflow/outflow from liability\n}\n\nmodel Transaction {\n  id           Int              @id @default(autoincrement())\n  createdAt    DateTime         @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt    DateTime         @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  account      Account          @relation(fields: [accountId], references: [id], onDelete: Cascade)\n  accountId    Int              @map(\"account_id\")\n  date         DateTime         @db.Date\n  flow         TransactionFlow  @default(dbgenerated(\"\\nCASE\\n    WHEN (amount < (0)::numeric) THEN 'INFLOW'::\\\"TransactionFlow\\\"\\n    ELSE 'OUTFLOW'::\\\"TransactionFlow\\\"\\nEND\"))\n  typeUser     TransactionType? @map(\"type_user\")\n  name         String\n  amount       Decimal          @db.Decimal(19, 4)\n  currencyCode String           @default(\"USD\") @map(\"currency_code\")\n  pending      Boolean          @default(false)\n  merchantName String?          @map(\"merchant_name\")\n  category     String           @default(\"Other\")\n  categoryUser String?          @map(\"category_user\")\n  excluded     Boolean          @default(false)\n\n  // transfer matching\n  matchId Int?          @map(\"match_id\")\n  match   Transaction?  @relation(\"MatchedTransaction\", fields: [matchId], references: [id])\n  matches Transaction[] @relation(\"MatchedTransaction\")\n\n  // teller data\n  tellerTransactionId String? @unique @map(\"teller_transaction_id\")\n  tellerType          String? @map(\"teller_type\")\n  tellerCategory      String? @map(\"teller_category\")\n\n  @@index([accountId, date])\n  @@index([amount])\n  @@map(\"transaction\")\n}\n\nmodel Valuation {\n  id           Int      @id @default(autoincrement())\n  createdAt    DateTime @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt    DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  account      Account  @relation(fields: [accountId], references: [id], onDelete: Cascade)\n  accountId    Int      @map(\"account_id\")\n  source       String\n  amount       Decimal  @db.Decimal(19, 4)\n  date         DateTime @db.Date\n  currencyCode String   @default(\"USD\") @map(\"currency_code\")\n\n  @@unique([accountId, source, date])\n  @@index([accountId, date])\n  @@map(\"valuation\")\n}\n\n// User's current household\nenum Household {\n  single\n  singleWithDependents\n  dual\n  dualWithDependents\n  retired\n}\n\n// User's goals for using Maybe\nenum MaybeGoal {\n  aggregate\n  advice\n  plan\n}\n\nenum TaxStatus {\n  single\n  married_joint\n  married_separate\n  head_of_household\n  qualifying_widow\n}\n\nmodel User {\n  id        Int      @id @default(autoincrement())\n  createdAt DateTime @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  authId    String   @unique @map(\"auth_id\") // NextAuth user id\n\n  // profile\n  email                  String    @db.Citext\n  firstName              String?   @map(\"first_name\")\n  lastName               String?   @map(\"last_name\")\n  name                   String?   @default(dbgenerated())\n  dob                    DateTime? @db.Date\n  picture                String?\n  onboarding             Json?     @map(\"onboarding\")\n  linkAccountDismissedAt DateTime? @map(\"link_account_dismissed_at\") @db.Timestamptz(6)\n\n  isoCurrencyCode String @default(\"USD\") @map(\"iso_currency_code\")\n\n  // Financial preferences and info\n  monthlyIncomeUser   Decimal? @map(\"monthly_income_user\") @db.Decimal(19, 4)\n  monthlyExpensesUser Decimal? @map(\"monthly_expenses_user\") @db.Decimal(19, 4)\n  monthlyDebtUser     Decimal? @map(\"monthly_debt_user\") @db.Decimal(19, 4)\n\n  // Billing\n  trialEnd               DateTime? @default(dbgenerated(\"(now() + '14 days'::interval)\")) @map(\"trial_end\") @db.Timestamptz(6)\n  trialReminderSent      DateTime? @map(\"trial_reminder_sent\") @db.Timestamptz(6)\n  stripeCustomerId       String?   @unique @map(\"stripe_customer_id\")\n  stripeSubscriptionId   String?   @unique @map(\"stripe_subscription_id\")\n  stripePriceId          String?   @map(\"stripe_price_id\")\n  stripeCurrentPeriodEnd DateTime? @map(\"stripe_current_period_end\") @db.Timestamptz(6)\n  stripeCancelAt         DateTime? @map(\"stripe_cancel_at\") @db.Timestamptz(6)\n\n  // teller data\n  tellerUserId String? @unique @map(\"teller_user_id\")\n\n  // Onboarding / usage goals\n  household             Household?\n  state                 String?\n  country               String?\n  maybeGoals            MaybeGoal[] @map(\"maybe_goals\")\n  maybeGoalsDescription String?     @map(\"maybe_goals_description\")\n  maybe                 String?\n  title                 String?\n  dependents            Int?\n  grossIncome           Int?        @map(\"gross_income\")\n  incomeType            String?     @map(\"income_type\")\n  taxStatus             TaxStatus?  @map(\"tax_status\")\n  memberNumber          Int         @unique @default(autoincrement()) @map(\"member_number\")\n  memberId              String      @unique @default(dbgenerated(\"gen_random_uuid()\")) @map(\"member_id\")\n\n  accountConnections AccountConnection[]\n  accounts           Account[]\n  plans              Plan[]\n\n  @@map(\"user\")\n}\n\nmodel Institution {\n  id           Int      @id @default(autoincrement())\n  createdAt    DateTime @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt    DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  name         String\n  url          String?\n  logo         String?\n  logoUrl      String?  @map(\"logo_url\")\n  primaryColor String?  @map(\"primary_color\")\n\n  providers ProviderInstitution[]\n\n  @@unique([name, url])\n  @@map(\"institution\")\n}\n\nenum Provider {\n  TELLER\n}\n\nmodel ProviderInstitution {\n  id            Int          @id @default(autoincrement())\n  createdAt     DateTime     @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt     DateTime     @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  provider      Provider\n  providerId    String       @map(\"provider_id\")\n  institution   Institution? @relation(fields: [institutionId], references: [id], onDelete: SetNull)\n  institutionId Int?         @map(\"institution_id\")\n  rank          Int          @default(0)\n  oauth         Boolean      @default(false)\n  name          String\n  url           String?\n  logo          String?\n  logoUrl       String?      @map(\"logo_url\")\n  primaryColor  String?      @map(\"primary_color\")\n  data          Json?        @db.JsonB\n\n  @@unique([provider, providerId])\n  @@map(\"provider_institution\")\n}\n\nmodel Plan {\n  id             Int      @id @default(autoincrement())\n  createdAt      DateTime @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt      DateTime @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  user           User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n  userId         Int      @map(\"user_id\")\n  name           String\n  lifeExpectancy Int      @default(85) @map(\"life_expectancy\")\n\n  events     PlanEvent[]\n  milestones PlanMilestone[]\n\n  @@map(\"plan\")\n}\n\nenum PlanEventFrequency {\n  monthly\n  yearly\n}\n\nmodel PlanEvent {\n  id               Int                @id @default(autoincrement())\n  createdAt        DateTime           @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt        DateTime           @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  plan             Plan               @relation(fields: [planId], references: [id], onDelete: Cascade)\n  planId           Int                @map(\"plan_id\")\n  name             String\n  category         String?\n  startYear        Int?               @map(\"start_year\")\n  startMilestone   PlanMilestone?     @relation(\"StartEvents\", fields: [startMilestoneId], references: [id], onDelete: Cascade)\n  startMilestoneId Int?               @map(\"start_milestone_id\")\n  endYear          Int?               @map(\"end_year\")\n  endMilestone     PlanMilestone?     @relation(\"EndEvents\", fields: [endMilestoneId], references: [id], onDelete: Cascade)\n  endMilestoneId   Int?               @map(\"end_milestone_id\")\n  frequency        PlanEventFrequency @default(yearly)\n  initialValue     Decimal?           @map(\"initial_value\") @db.Decimal(19, 4)\n  initialValueRef  String?            @map(\"initial_value_ref\")\n  rate             Decimal            @default(0) @db.Decimal(6, 4)\n\n  @@map(\"plan_event\")\n}\n\nenum PlanMilestoneType {\n  year\n  net_worth\n}\n\nmodel PlanMilestone {\n  id              Int               @id @default(autoincrement())\n  createdAt       DateTime          @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  updatedAt       DateTime          @default(now()) @updatedAt @map(\"updated_at\") @db.Timestamptz(6)\n  plan            Plan              @relation(fields: [planId], references: [id], onDelete: Cascade)\n  planId          Int               @map(\"plan_id\")\n  name            String\n  category        String            @default(\"retirement\")\n  type            PlanMilestoneType\n  year            Int?\n  expenseMultiple Float?            @map(\"expense_multiple\")\n  expenseYears    Int?              @map(\"expense_years\")\n\n  startEvents PlanEvent[] @relation(\"StartEvents\")\n  endEvents   PlanEvent[] @relation(\"EndEvents\")\n\n  @@map(\"plan_milestone\")\n}\n\n// NextAuth Models\nmodel AuthAccount {\n  id                String  @id @default(cuid())\n  userId            String  @map(\"user_id\")\n  type              String\n  provider          String\n  providerAccountId String  @map(\"provider_account_id\")\n  refresh_token     String? @db.Text\n  access_token      String? @db.Text\n  expires_at        Int?\n  token_type        String?\n  scope             String?\n  id_token          String? @db.Text\n  session_state     String?\n\n  user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@unique([provider, providerAccountId])\n  @@map(\"auth_account\")\n}\n\nenum AuthUserRole {\n  user\n  admin\n  ci\n}\n\nmodel AuthUser {\n  id            String        @id @default(cuid())\n  name          String?\n  firstName     String?       @map(\"first_name\")\n  lastName      String?       @map(\"last_name\")\n  email         String?       @unique\n  emailVerified DateTime?     @map(\"email_verified\")\n  password      String?\n  image         String?\n  role          AuthUserRole  @default(user)\n  accounts      AuthAccount[]\n  sessions      AuthSession[]\n\n  @@map(\"auth_user\")\n}\n\nmodel AuthSession {\n  id           String   @id @default(cuid())\n  sessionToken String   @unique @map(\"session_token\")\n  userId       String   @map(\"user_id\")\n  expires      DateTime\n  user         AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@map(\"auth_session\")\n}\n\nmodel AuthVerificationToken {\n  identifier String\n  token      String   @unique\n  expires    DateTime\n\n  @@unique([identifier, token])\n  @@map(\"auth_verification_token\")\n}\n\nenum ApprovalStatus {\n  pending\n  approved\n  rejected\n}\n\nenum AuditEventType {\n  insert\n  update\n  delete\n}\n\nmodel AuditEvent {\n  id        Int            @id @default(autoincrement())\n  createdAt DateTime       @default(now()) @map(\"created_at\") @db.Timestamptz(6)\n  type      AuditEventType\n  modelType String         @map(\"model_type\") // e.g. \"Message\"\n  modelId   Int            @map(\"model_id\")\n  data      Json\n  userId    Int?           @map(\"user_id\") // who made the change - if NULL, was an automated system change\n\n  @@map(\"audit_event\")\n}\n"
  },
  {
    "path": "prisma/seed.ts",
    "content": "import { Institution, PrismaClient, Provider } from '@prisma/client'\n\nconst prisma = new PrismaClient()\n\n/*\n * NOTE: seeding should be idempotent\n */\nasync function main() {\n    const institutions: (Pick<Institution, 'id' | 'name'> & {\n        providers: { provider: Provider; providerId: string; logoUrl: string; rank?: number }[]\n    })[] = [\n        {\n            id: 1,\n            name: 'Capital One',\n            providers: [\n                {\n                    provider: Provider.TELLER,\n                    providerId: 'capital_one',\n                    logoUrl: 'https://teller.io/images/banks/capital_one.jpg',\n                    rank: 1,\n                },\n            ],\n        },\n        {\n            id: 2,\n            name: 'Wells Fargo',\n            providers: [\n                {\n                    provider: Provider.TELLER,\n                    providerId: 'wells_fargo',\n                    logoUrl: 'https://teller.io/images/banks/wells_fargo.jpg',\n                },\n            ],\n        },\n    ]\n\n    await prisma.$transaction([\n        // create institution linked to provider institutions\n        ...institutions.map(({ id, name, providers }) =>\n            prisma.institution.upsert({\n                where: { id },\n                create: {\n                    name,\n                    providers: {\n                        connectOrCreate: providers.map(({ provider, providerId, rank = 0 }) => ({\n                            where: {\n                                provider_providerId: { provider, providerId },\n                            },\n                            create: {\n                                provider,\n                                providerId,\n                                name,\n                                rank,\n                            },\n                        })),\n                    },\n                },\n                update: {},\n            })\n        ),\n    ])\n}\n\n// Only run the seed in preview environments, not production\nif (process.env.NODE_ENV !== 'production') {\n    console.log('seeding...')\n    main()\n        .catch((e) => {\n            console.error('prisma seed failed', e)\n            process.exit(1)\n        })\n        .finally(async () => {\n            await prisma.$disconnect()\n        })\n} else {\n    console.warn('seeding skipped', process.env.NODE_ENV)\n}\n"
  },
  {
    "path": "redis.Dockerfile",
    "content": "FROM redis:6-alpine\n\nCOPY redis.conf .\n\nENTRYPOINT [\"redis-server\", \"./redis.conf\"]"
  },
  {
    "path": "redis.conf",
    "content": "# Redis configuration file (default file from Redis docs)\n# ====================================================================\n# See - https://raw.githubusercontent.com/redis/redis/6.0/redis.conf)\n# See - https://redis.io/topics/config\n# ====================================================================\n\n\n\n# Note that in order to read the configuration file, Redis must be\n# started with the file path as first argument:\n#\n# ./redis-server /path/to/redis.conf\n\n# Note on units: when memory size is needed, it is possible to specify\n# it in the usual form of 1k 5GB 4M and so forth:\n#\n# 1k => 1000 bytes\n# 1kb => 1024 bytes\n# 1m => 1000000 bytes\n# 1mb => 1024*1024 bytes\n# 1g => 1000000000 bytes\n# 1gb => 1024*1024*1024 bytes\n#\n# units are case insensitive so 1GB 1Gb 1gB are all the same.\n\n################################## INCLUDES ###################################\n\n# Include one or more other config files here.  This is useful if you\n# have a standard template that goes to all Redis servers but also need\n# to customize a few per-server settings.  Include files can include\n# other files, so use this wisely.\n#\n# Note that option \"include\" won't be rewritten by command \"CONFIG REWRITE\"\n# from admin or Redis Sentinel. Since Redis always uses the last processed\n# line as value of a configuration directive, you'd better put includes\n# at the beginning of this file to avoid overwriting config change at runtime.\n#\n# If instead you are interested in using includes to override configuration\n# options, it is better to use include as the last line.\n#\n# include /path/to/local.conf\n# include /path/to/other.conf\n\n################################## MODULES #####################################\n\n# Load modules at startup. If the server is not able to load modules\n# it will abort. It is possible to use multiple loadmodule directives.\n#\n# loadmodule /path/to/my_module.so\n# loadmodule /path/to/other_module.so\n\n################################## NETWORK #####################################\n\n# By default, if no \"bind\" configuration directive is specified, Redis listens\n# for connections from all available network interfaces on the host machine.\n# It is possible to listen to just one or multiple selected interfaces using\n# the \"bind\" configuration directive, followed by one or more IP addresses.\n#\n# Examples:\n#\n# bind 192.168.1.100 10.0.0.1\n# bind 127.0.0.1 ::1\n#\n# ~~~ WARNING ~~~ If the computer running Redis is directly exposed to the\n# internet, binding to all the interfaces is dangerous and will expose the\n# instance to everybody on the internet. So by default we uncomment the\n# following bind directive, that will force Redis to listen only on the\n# IPv4 loopback interface address (this means Redis will only be able to\n# accept client connections from the same host that it is running on).\n#\n# IF YOU ARE SURE YOU WANT YOUR INSTANCE TO LISTEN TO ALL THE INTERFACES\n# JUST COMMENT OUT THE FOLLOWING LINE.\n# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n# bind 127.0.0.1 (since we are running this in a PRIVATE Render service, okay to listen on all interfaces)\n\n# Protected mode is a layer of security protection, in order to avoid that\n# Redis instances left open on the internet are accessed and exploited.\n#\n# When protected mode is on and if:\n#\n# 1) The server is not binding explicitly to a set of addresses using the\n#    \"bind\" directive.\n# 2) No password is configured.\n#\n# The server only accepts connections from clients connecting from the\n# IPv4 and IPv6 loopback addresses 127.0.0.1 and ::1, and from Unix domain\n# sockets.\n#\n# By default protected mode is enabled. You should disable it only if\n# you are sure you want clients from other hosts to connect to Redis\n# even if no authentication is configured, nor a specific set of interfaces\n# are explicitly listed using the \"bind\" directive.\n# protected-mode yes (since we are running this in a PRIVATE Render service, okay to listen on all interfaces)\n\n# Accept connections on the specified port, default is 6379 (IANA #815344).\n# If port 0 is specified Redis will not listen on a TCP socket.\nport 6379\n\n# TCP listen() backlog.\n#\n# In high requests-per-second environments you need a high backlog in order\n# to avoid slow clients connection issues. Note that the Linux kernel\n# will silently truncate it to the value of /proc/sys/net/core/somaxconn so\n# make sure to raise both the value of somaxconn and tcp_max_syn_backlog\n# in order to get the desired effect.\ntcp-backlog 511\n\n# Unix socket.\n#\n# Specify the path for the Unix socket that will be used to listen for\n# incoming connections. There is no default, so Redis will not listen\n# on a unix socket when not specified.\n#\n# unixsocket /tmp/redis.sock\n# unixsocketperm 700\n\n# Close the connection after a client is idle for N seconds (0 to disable)\ntimeout 0\n\n# TCP keepalive.\n#\n# If non-zero, use SO_KEEPALIVE to send TCP ACKs to clients in absence\n# of communication. This is useful for two reasons:\n#\n# 1) Detect dead peers.\n# 2) Force network equipment in the middle to consider the connection to be\n#    alive.\n#\n# On Linux, the specified value (in seconds) is the period used to send ACKs.\n# Note that to close the connection the double of the time is needed.\n# On other kernels the period depends on the kernel configuration.\n#\n# A reasonable value for this option is 300 seconds, which is the new\n# Redis default starting with Redis 3.2.1.\ntcp-keepalive 300\n\n################################# TLS/SSL #####################################\n\n# By default, TLS/SSL is disabled. To enable it, the \"tls-port\" configuration\n# directive can be used to define TLS-listening ports. To enable TLS on the\n# default port, use:\n#\n# port 0\n# tls-port 6379\n\n# Configure a X.509 certificate and private key to use for authenticating the\n# server to connected clients, masters or cluster peers.  These files should be\n# PEM formatted.\n#\n# tls-cert-file redis.crt \n# tls-key-file redis.key\n\n# Configure a DH parameters file to enable Diffie-Hellman (DH) key exchange:\n#\n# tls-dh-params-file redis.dh\n\n# Configure a CA certificate(s) bundle or directory to authenticate TLS/SSL\n# clients and peers.  Redis requires an explicit configuration of at least one\n# of these, and will not implicitly use the system wide configuration.\n#\n# tls-ca-cert-file ca.crt\n# tls-ca-cert-dir /etc/ssl/certs\n\n# By default, clients (including replica servers) on a TLS port are required\n# to authenticate using valid client side certificates.\n#\n# If \"no\" is specified, client certificates are not required and not accepted.\n# If \"optional\" is specified, client certificates are accepted and must be\n# valid if provided, but are not required.\n#\n# tls-auth-clients no\n# tls-auth-clients optional\n\n# By default, a Redis replica does not attempt to establish a TLS connection\n# with its master.\n#\n# Use the following directive to enable TLS on replication links.\n#\n# tls-replication yes\n\n# By default, the Redis Cluster bus uses a plain TCP connection. To enable\n# TLS for the bus protocol, use the following directive:\n#\n# tls-cluster yes\n\n# Explicitly specify TLS versions to support. Allowed values are case insensitive\n# and include \"TLSv1\", \"TLSv1.1\", \"TLSv1.2\", \"TLSv1.3\" (OpenSSL >= 1.1.1) or\n# any combination. To enable only TLSv1.2 and TLSv1.3, use:\n#\n# tls-protocols \"TLSv1.2 TLSv1.3\"\n\n# Configure allowed ciphers.  See the ciphers(1ssl) manpage for more information\n# about the syntax of this string.\n#\n# Note: this configuration applies only to <= TLSv1.2.\n#\n# tls-ciphers DEFAULT:!MEDIUM\n\n# Configure allowed TLSv1.3 ciphersuites.  See the ciphers(1ssl) manpage for more\n# information about the syntax of this string, and specifically for TLSv1.3\n# ciphersuites.\n#\n# tls-ciphersuites TLS_CHACHA20_POLY1305_SHA256\n\n# When choosing a cipher, use the server's preference instead of the client\n# preference. By default, the server follows the client's preference.\n#\n# tls-prefer-server-ciphers yes\n\n# By default, TLS session caching is enabled to allow faster and less expensive\n# reconnections by clients that support it. Use the following directive to disable\n# caching.\n#\n# tls-session-caching no\n\n# Change the default number of TLS sessions cached. A zero value sets the cache\n# to unlimited size. The default size is 20480.\n#\n# tls-session-cache-size 5000\n\n# Change the default timeout of cached TLS sessions. The default timeout is 300\n# seconds.\n#\n# tls-session-cache-timeout 60\n\n################################# GENERAL #####################################\n\n# By default Redis does not run as a daemon. Use 'yes' if you need it.\n# Note that Redis will write a pid file in /var/run/redis.pid when daemonized.\ndaemonize no\n\n# If you run Redis from upstart or systemd, Redis can interact with your\n# supervision tree. Options:\n#   supervised no      - no supervision interaction\n#   supervised upstart - signal upstart by putting Redis into SIGSTOP mode\n#                        requires \"expect stop\" in your upstart job config\n#   supervised systemd - signal systemd by writing READY=1 to $NOTIFY_SOCKET\n#   supervised auto    - detect upstart or systemd method based on\n#                        UPSTART_JOB or NOTIFY_SOCKET environment variables\n# Note: these supervision methods only signal \"process is ready.\"\n#       They do not enable continuous pings back to your supervisor.\nsupervised no\n\n# If a pid file is specified, Redis writes it where specified at startup\n# and removes it at exit.\n#\n# When the server runs non daemonized, no pid file is created if none is\n# specified in the configuration. When the server is daemonized, the pid file\n# is used even if not specified, defaulting to \"/var/run/redis.pid\".\n#\n# Creating a pid file is best effort: if Redis is not able to create it\n# nothing bad happens, the server will start and run normally.\npidfile /var/run/redis_6379.pid\n\n# Specify the server verbosity level.\n# This can be one of:\n# debug (a lot of information, useful for development/testing)\n# verbose (many rarely useful info, but not a mess like the debug level)\n# notice (moderately verbose, what you want in production probably)\n# warning (only very important / critical messages are logged)\nloglevel notice\n\n# Specify the log file name. Also the empty string can be used to force\n# Redis to log on the standard output. Note that if you use standard\n# output for logging but daemonize, logs will be sent to /dev/null\nlogfile \"\"\n\n# To enable logging to the system logger, just set 'syslog-enabled' to yes,\n# and optionally update the other syslog parameters to suit your needs.\n# syslog-enabled no\n\n# Specify the syslog identity.\n# syslog-ident redis\n\n# Specify the syslog facility. Must be USER or between LOCAL0-LOCAL7.\n# syslog-facility local0\n\n# Set the number of databases. The default database is DB 0, you can select\n# a different one on a per-connection basis using SELECT <dbid> where\n# dbid is a number between 0 and 'databases'-1\ndatabases 16\n\n# By default Redis shows an ASCII art logo only when started to log to the\n# standard output and if the standard output is a TTY. Basically this means\n# that normally a logo is displayed only in interactive sessions.\n#\n# However it is possible to force the pre-4.0 behavior and always show a\n# ASCII art logo in startup logs by setting the following option to yes.\nalways-show-logo yes\n\n################################ SNAPSHOTTING  ################################\n#\n# Save the DB on disk:\n#\n#   save <seconds> <changes>\n#\n#   Will save the DB if both the given number of seconds and the given\n#   number of write operations against the DB occurred.\n#\n#   In the example below the behavior will be to save:\n#   after 900 sec (15 min) if at least 1 key changed\n#   after 300 sec (5 min) if at least 10 keys changed\n#   after 60 sec if at least 10000 keys changed\n#\n#   Note: you can disable saving completely by commenting out all \"save\" lines.\n#\n#   It is also possible to remove all the previously configured save\n#   points by adding a save directive with a single empty string argument\n#   like in the following example:\n#\n#   save \"\"\n\nsave 900 1\nsave 300 10\nsave 60 10000\n\n# By default Redis will stop accepting writes if RDB snapshots are enabled\n# (at least one save point) and the latest background save failed.\n# This will make the user aware (in a hard way) that data is not persisting\n# on disk properly, otherwise chances are that no one will notice and some\n# disaster will happen.\n#\n# If the background saving process will start working again Redis will\n# automatically allow writes again.\n#\n# However if you have setup your proper monitoring of the Redis server\n# and persistence, you may want to disable this feature so that Redis will\n# continue to work as usual even if there are problems with disk,\n# permissions, and so forth.\nstop-writes-on-bgsave-error yes\n\n# Compress string objects using LZF when dump .rdb databases?\n# By default compression is enabled as it's almost always a win.\n# If you want to save some CPU in the saving child set it to 'no' but\n# the dataset will likely be bigger if you have compressible values or keys.\nrdbcompression yes\n\n# Since version 5 of RDB a CRC64 checksum is placed at the end of the file.\n# This makes the format more resistant to corruption but there is a performance\n# hit to pay (around 10%) when saving and loading RDB files, so you can disable it\n# for maximum performances.\n#\n# RDB files created with checksum disabled have a checksum of zero that will\n# tell the loading code to skip the check.\nrdbchecksum yes\n\n# The filename where to dump the DB\ndbfilename dump.rdb\n\n# Remove RDB files used by replication in instances without persistence\n# enabled. By default this option is disabled, however there are environments\n# where for regulations or other security concerns, RDB files persisted on\n# disk by masters in order to feed replicas, or stored on disk by replicas\n# in order to load them for the initial synchronization, should be deleted\n# ASAP. Note that this option ONLY WORKS in instances that have both AOF\n# and RDB persistence disabled, otherwise is completely ignored.\n#\n# An alternative (and sometimes better) way to obtain the same effect is\n# to use diskless replication on both master and replicas instances. However\n# in the case of replicas, diskless is not always an option.\nrdb-del-sync-files no\n\n# The working directory.\n#\n# The DB will be written inside this directory, with the filename specified\n# above using the 'dbfilename' configuration directive.\n#\n# The Append Only File will also be created inside this directory.\n#\n# Note that you must specify a directory here, not a file name.\ndir ./\n\n################################# REPLICATION #################################\n\n# Master-Replica replication. Use replicaof to make a Redis instance a copy of\n# another Redis server. A few things to understand ASAP about Redis replication.\n#\n#   +------------------+      +---------------+\n#   |      Master      | ---> |    Replica    |\n#   | (receive writes) |      |  (exact copy) |\n#   +------------------+      +---------------+\n#\n# 1) Redis replication is asynchronous, but you can configure a master to\n#    stop accepting writes if it appears to be not connected with at least\n#    a given number of replicas.\n# 2) Redis replicas are able to perform a partial resynchronization with the\n#    master if the replication link is lost for a relatively small amount of\n#    time. You may want to configure the replication backlog size (see the next\n#    sections of this file) with a sensible value depending on your needs.\n# 3) Replication is automatic and does not need user intervention. After a\n#    network partition replicas automatically try to reconnect to masters\n#    and resynchronize with them.\n#\n# replicaof <masterip> <masterport>\n\n# If the master is password protected (using the \"requirepass\" configuration\n# directive below) it is possible to tell the replica to authenticate before\n# starting the replication synchronization process, otherwise the master will\n# refuse the replica request.\n#\n# masterauth <master-password>\n#\n# However this is not enough if you are using Redis ACLs (for Redis version\n# 6 or greater), and the default user is not capable of running the PSYNC\n# command and/or other commands needed for replication. In this case it's\n# better to configure a special user to use with replication, and specify the\n# masteruser configuration as such:\n#\n# masteruser <username>\n#\n# When masteruser is specified, the replica will authenticate against its\n# master using the new AUTH form: AUTH <username> <password>.\n\n# When a replica loses its connection with the master, or when the replication\n# is still in progress, the replica can act in two different ways:\n#\n# 1) if replica-serve-stale-data is set to 'yes' (the default) the replica will\n#    still reply to client requests, possibly with out of date data, or the\n#    data set may just be empty if this is the first synchronization.\n#\n# 2) If replica-serve-stale-data is set to 'no' the replica will reply with\n#    an error \"SYNC with master in progress\" to all commands except:\n#    INFO, REPLICAOF, AUTH, PING, SHUTDOWN, REPLCONF, ROLE, CONFIG, SUBSCRIBE,\n#    UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PUBLISH, PUBSUB, COMMAND, POST,\n#    HOST and LATENCY.\n#\nreplica-serve-stale-data yes\n\n# You can configure a replica instance to accept writes or not. Writing against\n# a replica instance may be useful to store some ephemeral data (because data\n# written on a replica will be easily deleted after resync with the master) but\n# may also cause problems if clients are writing to it because of a\n# misconfiguration.\n#\n# Since Redis 2.6 by default replicas are read-only.\n#\n# Note: read only replicas are not designed to be exposed to untrusted clients\n# on the internet. It's just a protection layer against misuse of the instance.\n# Still a read only replica exports by default all the administrative commands\n# such as CONFIG, DEBUG, and so forth. To a limited extent you can improve\n# security of read only replicas using 'rename-command' to shadow all the\n# administrative / dangerous commands.\nreplica-read-only yes\n\n# Replication SYNC strategy: disk or socket.\n#\n# New replicas and reconnecting replicas that are not able to continue the\n# replication process just receiving differences, need to do what is called a\n# \"full synchronization\". An RDB file is transmitted from the master to the\n# replicas.\n#\n# The transmission can happen in two different ways:\n#\n# 1) Disk-backed: The Redis master creates a new process that writes the RDB\n#                 file on disk. Later the file is transferred by the parent\n#                 process to the replicas incrementally.\n# 2) Diskless: The Redis master creates a new process that directly writes the\n#              RDB file to replica sockets, without touching the disk at all.\n#\n# With disk-backed replication, while the RDB file is generated, more replicas\n# can be queued and served with the RDB file as soon as the current child\n# producing the RDB file finishes its work. With diskless replication instead\n# once the transfer starts, new replicas arriving will be queued and a new\n# transfer will start when the current one terminates.\n#\n# When diskless replication is used, the master waits a configurable amount of\n# time (in seconds) before starting the transfer in the hope that multiple\n# replicas will arrive and the transfer can be parallelized.\n#\n# With slow disks and fast (large bandwidth) networks, diskless replication\n# works better.\nrepl-diskless-sync no\n\n# When diskless replication is enabled, it is possible to configure the delay\n# the server waits in order to spawn the child that transfers the RDB via socket\n# to the replicas.\n#\n# This is important since once the transfer starts, it is not possible to serve\n# new replicas arriving, that will be queued for the next RDB transfer, so the\n# server waits a delay in order to let more replicas arrive.\n#\n# The delay is specified in seconds, and by default is 5 seconds. To disable\n# it entirely just set it to 0 seconds and the transfer will start ASAP.\nrepl-diskless-sync-delay 5\n\n# -----------------------------------------------------------------------------\n# WARNING: RDB diskless load is experimental. Since in this setup the replica\n# does not immediately store an RDB on disk, it may cause data loss during\n# failovers. RDB diskless load + Redis modules not handling I/O reads may also\n# cause Redis to abort in case of I/O errors during the initial synchronization\n# stage with the master. Use only if your do what you are doing.\n# -----------------------------------------------------------------------------\n#\n# Replica can load the RDB it reads from the replication link directly from the\n# socket, or store the RDB to a file and read that file after it was completely\n# received from the master.\n#\n# In many cases the disk is slower than the network, and storing and loading\n# the RDB file may increase replication time (and even increase the master's\n# Copy on Write memory and salve buffers).\n# However, parsing the RDB file directly from the socket may mean that we have\n# to flush the contents of the current database before the full rdb was\n# received. For this reason we have the following options:\n#\n# \"disabled\"    - Don't use diskless load (store the rdb file to the disk first)\n# \"on-empty-db\" - Use diskless load only when it is completely safe.\n# \"swapdb\"      - Keep a copy of the current db contents in RAM while parsing\n#                 the data directly from the socket. note that this requires\n#                 sufficient memory, if you don't have it, you risk an OOM kill.\nrepl-diskless-load disabled\n\n# Replicas send PINGs to server in a predefined interval. It's possible to\n# change this interval with the repl_ping_replica_period option. The default\n# value is 10 seconds.\n#\n# repl-ping-replica-period 10\n\n# The following option sets the replication timeout for:\n#\n# 1) Bulk transfer I/O during SYNC, from the point of view of replica.\n# 2) Master timeout from the point of view of replicas (data, pings).\n# 3) Replica timeout from the point of view of masters (REPLCONF ACK pings).\n#\n# It is important to make sure that this value is greater than the value\n# specified for repl-ping-replica-period otherwise a timeout will be detected\n# every time there is low traffic between the master and the replica. The default\n# value is 60 seconds.\n#\n# repl-timeout 60\n\n# Disable TCP_NODELAY on the replica socket after SYNC?\n#\n# If you select \"yes\" Redis will use a smaller number of TCP packets and\n# less bandwidth to send data to replicas. But this can add a delay for\n# the data to appear on the replica side, up to 40 milliseconds with\n# Linux kernels using a default configuration.\n#\n# If you select \"no\" the delay for data to appear on the replica side will\n# be reduced but more bandwidth will be used for replication.\n#\n# By default we optimize for low latency, but in very high traffic conditions\n# or when the master and replicas are many hops away, turning this to \"yes\" may\n# be a good idea.\nrepl-disable-tcp-nodelay no\n\n# Set the replication backlog size. The backlog is a buffer that accumulates\n# replica data when replicas are disconnected for some time, so that when a\n# replica wants to reconnect again, often a full resync is not needed, but a\n# partial resync is enough, just passing the portion of data the replica\n# missed while disconnected.\n#\n# The bigger the replication backlog, the longer the replica can endure the\n# disconnect and later be able to perform a partial resynchronization.\n#\n# The backlog is only allocated if there is at least one replica connected.\n#\n# repl-backlog-size 1mb\n\n# After a master has no connected replicas for some time, the backlog will be\n# freed. The following option configures the amount of seconds that need to\n# elapse, starting from the time the last replica disconnected, for the backlog\n# buffer to be freed.\n#\n# Note that replicas never free the backlog for timeout, since they may be\n# promoted to masters later, and should be able to correctly \"partially\n# resynchronize\" with other replicas: hence they should always accumulate backlog.\n#\n# A value of 0 means to never release the backlog.\n#\n# repl-backlog-ttl 3600\n\n# The replica priority is an integer number published by Redis in the INFO\n# output. It is used by Redis Sentinel in order to select a replica to promote\n# into a master if the master is no longer working correctly.\n#\n# A replica with a low priority number is considered better for promotion, so\n# for instance if there are three replicas with priority 10, 100, 25 Sentinel\n# will pick the one with priority 10, that is the lowest.\n#\n# However a special priority of 0 marks the replica as not able to perform the\n# role of master, so a replica with priority of 0 will never be selected by\n# Redis Sentinel for promotion.\n#\n# By default the priority is 100.\nreplica-priority 100\n\n# It is possible for a master to stop accepting writes if there are less than\n# N replicas connected, having a lag less or equal than M seconds.\n#\n# The N replicas need to be in \"online\" state.\n#\n# The lag in seconds, that must be <= the specified value, is calculated from\n# the last ping received from the replica, that is usually sent every second.\n#\n# This option does not GUARANTEE that N replicas will accept the write, but\n# will limit the window of exposure for lost writes in case not enough replicas\n# are available, to the specified number of seconds.\n#\n# For example to require at least 3 replicas with a lag <= 10 seconds use:\n#\n# min-replicas-to-write 3\n# min-replicas-max-lag 10\n#\n# Setting one or the other to 0 disables the feature.\n#\n# By default min-replicas-to-write is set to 0 (feature disabled) and\n# min-replicas-max-lag is set to 10.\n\n# A Redis master is able to list the address and port of the attached\n# replicas in different ways. For example the \"INFO replication\" section\n# offers this information, which is used, among other tools, by\n# Redis Sentinel in order to discover replica instances.\n# Another place where this info is available is in the output of the\n# \"ROLE\" command of a master.\n#\n# The listed IP address and port normally reported by a replica is\n# obtained in the following way:\n#\n#   IP: The address is auto detected by checking the peer address\n#   of the socket used by the replica to connect with the master.\n#\n#   Port: The port is communicated by the replica during the replication\n#   handshake, and is normally the port that the replica is using to\n#   listen for connections.\n#\n# However when port forwarding or Network Address Translation (NAT) is\n# used, the replica may actually be reachable via different IP and port\n# pairs. The following two options can be used by a replica in order to\n# report to its master a specific set of IP and port, so that both INFO\n# and ROLE will report those values.\n#\n# There is no need to use both the options if you need to override just\n# the port or the IP address.\n#\n# replica-announce-ip 5.5.5.5\n# replica-announce-port 1234\n\n############################### KEYS TRACKING #################################\n\n# Redis implements server assisted support for client side caching of values.\n# This is implemented using an invalidation table that remembers, using\n# 16 millions of slots, what clients may have certain subsets of keys. In turn\n# this is used in order to send invalidation messages to clients. Please\n# check this page to understand more about the feature:\n#\n#   https://redis.io/topics/client-side-caching\n#\n# When tracking is enabled for a client, all the read only queries are assumed\n# to be cached: this will force Redis to store information in the invalidation\n# table. When keys are modified, such information is flushed away, and\n# invalidation messages are sent to the clients. However if the workload is\n# heavily dominated by reads, Redis could use more and more memory in order\n# to track the keys fetched by many clients.\n#\n# For this reason it is possible to configure a maximum fill value for the\n# invalidation table. By default it is set to 1M of keys, and once this limit\n# is reached, Redis will start to evict keys in the invalidation table\n# even if they were not modified, just to reclaim memory: this will in turn\n# force the clients to invalidate the cached values. Basically the table\n# maximum size is a trade off between the memory you want to spend server\n# side to track information about who cached what, and the ability of clients\n# to retain cached objects in memory.\n#\n# If you set the value to 0, it means there are no limits, and Redis will\n# retain as many keys as needed in the invalidation table.\n# In the \"stats\" INFO section, you can find information about the number of\n# keys in the invalidation table at every given moment.\n#\n# Note: when key tracking is used in broadcasting mode, no memory is used\n# in the server side so this setting is useless.\n#\n# tracking-table-max-keys 1000000\n\n################################## SECURITY ###################################\n\n# Warning: since Redis is pretty fast, an outside user can try up to\n# 1 million passwords per second against a modern box. This means that you\n# should use very strong passwords, otherwise they will be very easy to break.\n# Note that because the password is really a shared secret between the client\n# and the server, and should not be memorized by any human, the password\n# can be easily a long string from /dev/urandom or whatever, so by using a\n# long and unguessable password no brute force attack will be possible.\n\n# Redis ACL users are defined in the following format:\n#\n#   user <username> ... acl rules ...\n#\n# For example:\n#\n#   user worker +@list +@connection ~jobs:* on >ffa9203c493aa99\n#\n# The special username \"default\" is used for new connections. If this user\n# has the \"nopass\" rule, then new connections will be immediately authenticated\n# as the \"default\" user without the need of any password provided via the\n# AUTH command. Otherwise if the \"default\" user is not flagged with \"nopass\"\n# the connections will start in not authenticated state, and will require\n# AUTH (or the HELLO command AUTH option) in order to be authenticated and\n# start to work.\n#\n# The ACL rules that describe what a user can do are the following:\n#\n#  on           Enable the user: it is possible to authenticate as this user.\n#  off          Disable the user: it's no longer possible to authenticate\n#               with this user, however the already authenticated connections\n#               will still work.\n#  +<command>   Allow the execution of that command\n#  -<command>   Disallow the execution of that command\n#  +@<category> Allow the execution of all the commands in such category\n#               with valid categories are like @admin, @set, @sortedset, ...\n#               and so forth, see the full list in the server.c file where\n#               the Redis command table is described and defined.\n#               The special category @all means all the commands, but currently\n#               present in the server, and that will be loaded in the future\n#               via modules.\n#  +<command>|subcommand    Allow a specific subcommand of an otherwise\n#                           disabled command. Note that this form is not\n#                           allowed as negative like -DEBUG|SEGFAULT, but\n#                           only additive starting with \"+\".\n#  allcommands  Alias for +@all. Note that it implies the ability to execute\n#               all the future commands loaded via the modules system.\n#  nocommands   Alias for -@all.\n#  ~<pattern>   Add a pattern of keys that can be mentioned as part of\n#               commands. For instance ~* allows all the keys. The pattern\n#               is a glob-style pattern like the one of KEYS.\n#               It is possible to specify multiple patterns.\n#  allkeys      Alias for ~*\n#  resetkeys    Flush the list of allowed keys patterns.\n#  ><password>  Add this password to the list of valid password for the user.\n#               For example >mypass will add \"mypass\" to the list.\n#               This directive clears the \"nopass\" flag (see later).\n#  <<password>  Remove this password from the list of valid passwords.\n#  nopass       All the set passwords of the user are removed, and the user\n#               is flagged as requiring no password: it means that every\n#               password will work against this user. If this directive is\n#               used for the default user, every new connection will be\n#               immediately authenticated with the default user without\n#               any explicit AUTH command required. Note that the \"resetpass\"\n#               directive will clear this condition.\n#  resetpass    Flush the list of allowed passwords. Moreover removes the\n#               \"nopass\" status. After \"resetpass\" the user has no associated\n#               passwords and there is no way to authenticate without adding\n#               some password (or setting it as \"nopass\" later).\n#  reset        Performs the following actions: resetpass, resetkeys, off,\n#               -@all. The user returns to the same state it has immediately\n#               after its creation.\n#\n# ACL rules can be specified in any order: for instance you can start with\n# passwords, then flags, or key patterns. However note that the additive\n# and subtractive rules will CHANGE MEANING depending on the ordering.\n# For instance see the following example:\n#\n#   user alice on +@all -DEBUG ~* >somepassword\n#\n# This will allow \"alice\" to use all the commands with the exception of the\n# DEBUG command, since +@all added all the commands to the set of the commands\n# alice can use, and later DEBUG was removed. However if we invert the order\n# of two ACL rules the result will be different:\n#\n#   user alice on -DEBUG +@all ~* >somepassword\n#\n# Now DEBUG was removed when alice had yet no commands in the set of allowed\n# commands, later all the commands are added, so the user will be able to\n# execute everything.\n#\n# Basically ACL rules are processed left-to-right.\n#\n# For more information about ACL configuration please refer to\n# the Redis web site at https://redis.io/topics/acl\n\n# ACL LOG\n#\n# The ACL Log tracks failed commands and authentication events associated\n# with ACLs. The ACL Log is useful to troubleshoot failed commands blocked \n# by ACLs. The ACL Log is stored in memory. You can reclaim memory with \n# ACL LOG RESET. Define the maximum entry length of the ACL Log below.\nacllog-max-len 128\n\n# Using an external ACL file\n#\n# Instead of configuring users here in this file, it is possible to use\n# a stand-alone file just listing users. The two methods cannot be mixed:\n# if you configure users here and at the same time you activate the external\n# ACL file, the server will refuse to start.\n#\n# The format of the external ACL user file is exactly the same as the\n# format that is used inside redis.conf to describe users.\n#\n# aclfile /etc/redis/users.acl\n\n# IMPORTANT NOTE: starting with Redis 6 \"requirepass\" is just a compatibility\n# layer on top of the new ACL system. The option effect will be just setting\n# the password for the default user. Clients will still authenticate using\n# AUTH <password> as usually, or more explicitly with AUTH default <password>\n# if they follow the new protocol: both will work.\n#\n# requirepass foobared\n\n# Command renaming (DEPRECATED).\n#\n# ------------------------------------------------------------------------\n# WARNING: avoid using this option if possible. Instead use ACLs to remove\n# commands from the default user, and put them only in some admin user you\n# create for administrative purposes.\n# ------------------------------------------------------------------------\n#\n# It is possible to change the name of dangerous commands in a shared\n# environment. For instance the CONFIG command may be renamed into something\n# hard to guess so that it will still be available for internal-use tools\n# but not available for general clients.\n#\n# Example:\n#\n# rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52\n#\n# It is also possible to completely kill a command by renaming it into\n# an empty string:\n#\n# rename-command CONFIG \"\"\n#\n# Please note that changing the name of commands that are logged into the\n# AOF file or transmitted to replicas may cause problems.\n\n################################### CLIENTS ####################################\n\n# Set the max number of connected clients at the same time. By default\n# this limit is set to 10000 clients, however if the Redis server is not\n# able to configure the process file limit to allow for the specified limit\n# the max number of allowed clients is set to the current file limit\n# minus 32 (as Redis reserves a few file descriptors for internal uses).\n#\n# Once the limit is reached Redis will close all the new connections sending\n# an error 'max number of clients reached'.\n#\n# IMPORTANT: When Redis Cluster is used, the max number of connections is also\n# shared with the cluster bus: every node in the cluster will use two\n# connections, one incoming and another outgoing. It is important to size the\n# limit accordingly in case of very large clusters.\n#\n# maxclients 10000\n\n############################## MEMORY MANAGEMENT ################################\n\n# Set a memory usage limit to the specified amount of bytes.\n# When the memory limit is reached Redis will try to remove keys\n# according to the eviction policy selected (see maxmemory-policy).\n#\n# If Redis can't remove keys according to the policy, or if the policy is\n# set to 'noeviction', Redis will start to reply with errors to commands\n# that would use more memory, like SET, LPUSH, and so on, and will continue\n# to reply to read-only commands like GET.\n#\n# This option is usually useful when using Redis as an LRU or LFU cache, or to\n# set a hard memory limit for an instance (using the 'noeviction' policy).\n#\n# WARNING: If you have replicas attached to an instance with maxmemory on,\n# the size of the output buffers needed to feed the replicas are subtracted\n# from the used memory count, so that network problems / resyncs will\n# not trigger a loop where keys are evicted, and in turn the output\n# buffer of replicas is full with DELs of keys evicted triggering the deletion\n# of more keys, and so forth until the database is completely emptied.\n#\n# In short... if you have replicas attached it is suggested that you set a lower\n# limit for maxmemory so that there is some free RAM on the system for replica\n# output buffers (but this is not needed if the policy is 'noeviction').\n#\n# maxmemory <bytes>\n\n# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory\n# is reached. You can select one from the following behaviors:\n#\n# volatile-lru -> Evict using approximated LRU, only keys with an expire set.\n# allkeys-lru -> Evict any key using approximated LRU.\n# volatile-lfu -> Evict using approximated LFU, only keys with an expire set.\n# allkeys-lfu -> Evict any key using approximated LFU.\n# volatile-random -> Remove a random key having an expire set.\n# allkeys-random -> Remove a random key, any key.\n# volatile-ttl -> Remove the key with the nearest expire time (minor TTL)\n# noeviction -> Don't evict anything, just return an error on write operations.\n#\n# LRU means Least Recently Used\n# LFU means Least Frequently Used\n#\n# Both LRU, LFU and volatile-ttl are implemented using approximated\n# randomized algorithms.\n#\n# Note: with any of the above policies, Redis will return an error on write\n#       operations, when there are no suitable keys for eviction.\n#\n#       At the date of writing these commands are: set setnx setex append\n#       incr decr rpush lpush rpushx lpushx linsert lset rpoplpush sadd\n#       sinter sinterstore sunion sunionstore sdiff sdiffstore zadd zincrby\n#       zunionstore zinterstore hset hsetnx hmset hincrby incrby decrby\n#       getset mset msetnx exec sort\n#\n# The default is:\n#\n# maxmemory-policy noeviction\n\n# LRU, LFU and minimal TTL algorithms are not precise algorithms but approximated\n# algorithms (in order to save memory), so you can tune it for speed or\n# accuracy. By default Redis will check five keys and pick the one that was\n# used least recently, you can change the sample size using the following\n# configuration directive.\n#\n# The default of 5 produces good enough results. 10 Approximates very closely\n# true LRU but costs more CPU. 3 is faster but not very accurate.\n#\n# maxmemory-samples 5\n\n# Starting from Redis 5, by default a replica will ignore its maxmemory setting\n# (unless it is promoted to master after a failover or manually). It means\n# that the eviction of keys will be just handled by the master, sending the\n# DEL commands to the replica as keys evict in the master side.\n#\n# This behavior ensures that masters and replicas stay consistent, and is usually\n# what you want, however if your replica is writable, or you want the replica\n# to have a different memory setting, and you are sure all the writes performed\n# to the replica are idempotent, then you may change this default (but be sure\n# to understand what you are doing).\n#\n# Note that since the replica by default does not evict, it may end using more\n# memory than the one set via maxmemory (there are certain buffers that may\n# be larger on the replica, or data structures may sometimes take more memory\n# and so forth). So make sure you monitor your replicas and make sure they\n# have enough memory to never hit a real out-of-memory condition before the\n# master hits the configured maxmemory setting.\n#\n# replica-ignore-maxmemory yes\n\n# Redis reclaims expired keys in two ways: upon access when those keys are\n# found to be expired, and also in background, in what is called the\n# \"active expire key\". The key space is slowly and interactively scanned\n# looking for expired keys to reclaim, so that it is possible to free memory\n# of keys that are expired and will never be accessed again in a short time.\n#\n# The default effort of the expire cycle will try to avoid having more than\n# ten percent of expired keys still in memory, and will try to avoid consuming\n# more than 25% of total memory and to add latency to the system. However\n# it is possible to increase the expire \"effort\" that is normally set to\n# \"1\", to a greater value, up to the value \"10\". At its maximum value the\n# system will use more CPU, longer cycles (and technically may introduce\n# more latency), and will tolerate less already expired keys still present\n# in the system. It's a tradeoff between memory, CPU and latency.\n#\n# active-expire-effort 1\n\n############################# LAZY FREEING ####################################\n\n# Redis has two primitives to delete keys. One is called DEL and is a blocking\n# deletion of the object. It means that the server stops processing new commands\n# in order to reclaim all the memory associated with an object in a synchronous\n# way. If the key deleted is associated with a small object, the time needed\n# in order to execute the DEL command is very small and comparable to most other\n# O(1) or O(log_N) commands in Redis. However if the key is associated with an\n# aggregated value containing millions of elements, the server can block for\n# a long time (even seconds) in order to complete the operation.\n#\n# For the above reasons Redis also offers non blocking deletion primitives\n# such as UNLINK (non blocking DEL) and the ASYNC option of FLUSHALL and\n# FLUSHDB commands, in order to reclaim memory in background. Those commands\n# are executed in constant time. Another thread will incrementally free the\n# object in the background as fast as possible.\n#\n# DEL, UNLINK and ASYNC option of FLUSHALL and FLUSHDB are user-controlled.\n# It's up to the design of the application to understand when it is a good\n# idea to use one or the other. However the Redis server sometimes has to\n# delete keys or flush the whole database as a side effect of other operations.\n# Specifically Redis deletes objects independently of a user call in the\n# following scenarios:\n#\n# 1) On eviction, because of the maxmemory and maxmemory policy configurations,\n#    in order to make room for new data, without going over the specified\n#    memory limit.\n# 2) Because of expire: when a key with an associated time to live (see the\n#    EXPIRE command) must be deleted from memory.\n# 3) Because of a side effect of a command that stores data on a key that may\n#    already exist. For example the RENAME command may delete the old key\n#    content when it is replaced with another one. Similarly SUNIONSTORE\n#    or SORT with STORE option may delete existing keys. The SET command\n#    itself removes any old content of the specified key in order to replace\n#    it with the specified string.\n# 4) During replication, when a replica performs a full resynchronization with\n#    its master, the content of the whole database is removed in order to\n#    load the RDB file just transferred.\n#\n# In all the above cases the default is to delete objects in a blocking way,\n# like if DEL was called. However you can configure each case specifically\n# in order to instead release memory in a non-blocking way like if UNLINK\n# was called, using the following configuration directives.\n\nlazyfree-lazy-eviction no\nlazyfree-lazy-expire no\nlazyfree-lazy-server-del no\nreplica-lazy-flush no\n\n# It is also possible, for the case when to replace the user code DEL calls\n# with UNLINK calls is not easy, to modify the default behavior of the DEL\n# command to act exactly like UNLINK, using the following configuration\n# directive:\n\nlazyfree-lazy-user-del no\n\n################################ THREADED I/O #################################\n\n# Redis is mostly single threaded, however there are certain threaded\n# operations such as UNLINK, slow I/O accesses and other things that are\n# performed on side threads.\n#\n# Now it is also possible to handle Redis clients socket reads and writes\n# in different I/O threads. Since especially writing is so slow, normally\n# Redis users use pipelining in order to speed up the Redis performances per\n# core, and spawn multiple instances in order to scale more. Using I/O\n# threads it is possible to easily speedup two times Redis without resorting\n# to pipelining nor sharding of the instance.\n#\n# By default threading is disabled, we suggest enabling it only in machines\n# that have at least 4 or more cores, leaving at least one spare core.\n# Using more than 8 threads is unlikely to help much. We also recommend using\n# threaded I/O only if you actually have performance problems, with Redis\n# instances being able to use a quite big percentage of CPU time, otherwise\n# there is no point in using this feature.\n#\n# So for instance if you have a four cores boxes, try to use 2 or 3 I/O\n# threads, if you have a 8 cores, try to use 6 threads. In order to\n# enable I/O threads use the following configuration directive:\n#\n# io-threads 4\n#\n# Setting io-threads to 1 will just use the main thread as usual.\n# When I/O threads are enabled, we only use threads for writes, that is\n# to thread the write(2) syscall and transfer the client buffers to the\n# socket. However it is also possible to enable threading of reads and\n# protocol parsing using the following configuration directive, by setting\n# it to yes:\n#\n# io-threads-do-reads no\n#\n# Usually threading reads doesn't help much.\n#\n# NOTE 1: This configuration directive cannot be changed at runtime via\n# CONFIG SET. Aso this feature currently does not work when SSL is\n# enabled.\n#\n# NOTE 2: If you want to test the Redis speedup using redis-benchmark, make\n# sure you also run the benchmark itself in threaded mode, using the\n# --threads option to match the number of Redis threads, otherwise you'll not\n# be able to notice the improvements.\n\n############################ KERNEL OOM CONTROL ##############################\n\n# On Linux, it is possible to hint the kernel OOM killer on what processes\n# should be killed first when out of memory.\n#\n# Enabling this feature makes Redis actively control the oom_score_adj value\n# for all its processes, depending on their role. The default scores will\n# attempt to have background child processes killed before all others, and\n# replicas killed before masters.\n#\n# Redis supports three options:\n#\n# no:       Don't make changes to oom-score-adj (default).\n# yes:      Alias to \"relative\" see below.\n# absolute: Values in oom-score-adj-values are written as is to the kernel.\n# relative: Values are used relative to the initial value of oom_score_adj when\n#           the server starts and are then clamped to a range of -1000 to 1000.\n#           Because typically the initial value is 0, they will often match the\n#           absolute values.\noom-score-adj no\n\n# When oom-score-adj is used, this directive controls the specific values used\n# for master, replica and background child processes. Values range -2000 to\n# 2000 (higher means more likely to be killed).\n#\n# Unprivileged processes (not root, and without CAP_SYS_RESOURCE capabilities)\n# can freely increase their value, but not decrease it below its initial\n# settings. This means that setting oom-score-adj to \"relative\" and setting the\n# oom-score-adj-values to positive values will always succeed.\noom-score-adj-values 0 200 800\n\n############################## APPEND ONLY MODE ###############################\n\n# By default Redis asynchronously dumps the dataset on disk. This mode is\n# good enough in many applications, but an issue with the Redis process or\n# a power outage may result into a few minutes of writes lost (depending on\n# the configured save points).\n#\n# The Append Only File is an alternative persistence mode that provides\n# much better durability. For instance using the default data fsync policy\n# (see later in the config file) Redis can lose just one second of writes in a\n# dramatic event like a server power outage, or a single write if something\n# wrong with the Redis process itself happens, but the operating system is\n# still running correctly.\n#\n# AOF and RDB persistence can be enabled at the same time without problems.\n# If the AOF is enabled on startup Redis will load the AOF, that is the file\n# with the better durability guarantees.\n#\n# Please check http://redis.io/topics/persistence for more information.\n\nappendonly no\n\n# The name of the append only file (default: \"appendonly.aof\")\n\nappendfilename \"appendonly.aof\"\n\n# The fsync() call tells the Operating System to actually write data on disk\n# instead of waiting for more data in the output buffer. Some OS will really flush\n# data on disk, some other OS will just try to do it ASAP.\n#\n# Redis supports three different modes:\n#\n# no: don't fsync, just let the OS flush the data when it wants. Faster.\n# always: fsync after every write to the append only log. Slow, Safest.\n# everysec: fsync only one time every second. Compromise.\n#\n# The default is \"everysec\", as that's usually the right compromise between\n# speed and data safety. It's up to you to understand if you can relax this to\n# \"no\" that will let the operating system flush the output buffer when\n# it wants, for better performances (but if you can live with the idea of\n# some data loss consider the default persistence mode that's snapshotting),\n# or on the contrary, use \"always\" that's very slow but a bit safer than\n# everysec.\n#\n# More details please check the following article:\n# http://antirez.com/post/redis-persistence-demystified.html\n#\n# If unsure, use \"everysec\".\n\n# appendfsync always\nappendfsync everysec\n# appendfsync no\n\n# When the AOF fsync policy is set to always or everysec, and a background\n# saving process (a background save or AOF log background rewriting) is\n# performing a lot of I/O against the disk, in some Linux configurations\n# Redis may block too long on the fsync() call. Note that there is no fix for\n# this currently, as even performing fsync in a different thread will block\n# our synchronous write(2) call.\n#\n# In order to mitigate this problem it's possible to use the following option\n# that will prevent fsync() from being called in the main process while a\n# BGSAVE or BGREWRITEAOF is in progress.\n#\n# This means that while another child is saving, the durability of Redis is\n# the same as \"appendfsync none\". In practical terms, this means that it is\n# possible to lose up to 30 seconds of log in the worst scenario (with the\n# default Linux settings).\n#\n# If you have latency problems turn this to \"yes\". Otherwise leave it as\n# \"no\" that is the safest pick from the point of view of durability.\n\nno-appendfsync-on-rewrite no\n\n# Automatic rewrite of the append only file.\n# Redis is able to automatically rewrite the log file implicitly calling\n# BGREWRITEAOF when the AOF log size grows by the specified percentage.\n#\n# This is how it works: Redis remembers the size of the AOF file after the\n# latest rewrite (if no rewrite has happened since the restart, the size of\n# the AOF at startup is used).\n#\n# This base size is compared to the current size. If the current size is\n# bigger than the specified percentage, the rewrite is triggered. Also\n# you need to specify a minimal size for the AOF file to be rewritten, this\n# is useful to avoid rewriting the AOF file even if the percentage increase\n# is reached but it is still pretty small.\n#\n# Specify a percentage of zero in order to disable the automatic AOF\n# rewrite feature.\n\nauto-aof-rewrite-percentage 100\nauto-aof-rewrite-min-size 64mb\n\n# An AOF file may be found to be truncated at the end during the Redis\n# startup process, when the AOF data gets loaded back into memory.\n# This may happen when the system where Redis is running\n# crashes, especially when an ext4 filesystem is mounted without the\n# data=ordered option (however this can't happen when Redis itself\n# crashes or aborts but the operating system still works correctly).\n#\n# Redis can either exit with an error when this happens, or load as much\n# data as possible (the default now) and start if the AOF file is found\n# to be truncated at the end. The following option controls this behavior.\n#\n# If aof-load-truncated is set to yes, a truncated AOF file is loaded and\n# the Redis server starts emitting a log to inform the user of the event.\n# Otherwise if the option is set to no, the server aborts with an error\n# and refuses to start. When the option is set to no, the user requires\n# to fix the AOF file using the \"redis-check-aof\" utility before to restart\n# the server.\n#\n# Note that if the AOF file will be found to be corrupted in the middle\n# the server will still exit with an error. This option only applies when\n# Redis will try to read more data from the AOF file but not enough bytes\n# will be found.\naof-load-truncated yes\n\n# When rewriting the AOF file, Redis is able to use an RDB preamble in the\n# AOF file for faster rewrites and recoveries. When this option is turned\n# on the rewritten AOF file is composed of two different stanzas:\n#\n#   [RDB file][AOF tail]\n#\n# When loading, Redis recognizes that the AOF file starts with the \"REDIS\"\n# string and loads the prefixed RDB file, then continues loading the AOF\n# tail.\naof-use-rdb-preamble yes\n\n################################ LUA SCRIPTING  ###############################\n\n# Max execution time of a Lua script in milliseconds.\n#\n# If the maximum execution time is reached Redis will log that a script is\n# still in execution after the maximum allowed time and will start to\n# reply to queries with an error.\n#\n# When a long running script exceeds the maximum execution time only the\n# SCRIPT KILL and SHUTDOWN NOSAVE commands are available. The first can be\n# used to stop a script that did not yet call any write commands. The second\n# is the only way to shut down the server in the case a write command was\n# already issued by the script but the user doesn't want to wait for the natural\n# termination of the script.\n#\n# Set it to 0 or a negative value for unlimited execution without warnings.\nlua-time-limit 5000\n\n################################ REDIS CLUSTER  ###############################\n\n# Normal Redis instances can't be part of a Redis Cluster; only nodes that are\n# started as cluster nodes can. In order to start a Redis instance as a\n# cluster node enable the cluster support uncommenting the following:\n#\n# cluster-enabled yes\n\n# Every cluster node has a cluster configuration file. This file is not\n# intended to be edited by hand. It is created and updated by Redis nodes.\n# Every Redis Cluster node requires a different cluster configuration file.\n# Make sure that instances running in the same system do not have\n# overlapping cluster configuration file names.\n#\n# cluster-config-file nodes-6379.conf\n\n# Cluster node timeout is the amount of milliseconds a node must be unreachable\n# for it to be considered in failure state.\n# Most other internal time limits are a multiple of the node timeout.\n#\n# cluster-node-timeout 15000\n\n# A replica of a failing master will avoid to start a failover if its data\n# looks too old.\n#\n# There is no simple way for a replica to actually have an exact measure of\n# its \"data age\", so the following two checks are performed:\n#\n# 1) If there are multiple replicas able to failover, they exchange messages\n#    in order to try to give an advantage to the replica with the best\n#    replication offset (more data from the master processed).\n#    Replicas will try to get their rank by offset, and apply to the start\n#    of the failover a delay proportional to their rank.\n#\n# 2) Every single replica computes the time of the last interaction with\n#    its master. This can be the last ping or command received (if the master\n#    is still in the \"connected\" state), or the time that elapsed since the\n#    disconnection with the master (if the replication link is currently down).\n#    If the last interaction is too old, the replica will not try to failover\n#    at all.\n#\n# The point \"2\" can be tuned by user. Specifically a replica will not perform\n# the failover if, since the last interaction with the master, the time\n# elapsed is greater than:\n#\n#   (node-timeout * cluster-replica-validity-factor) + repl-ping-replica-period\n#\n# So for example if node-timeout is 30 seconds, and the cluster-replica-validity-factor\n# is 10, and assuming a default repl-ping-replica-period of 10 seconds, the\n# replica will not try to failover if it was not able to talk with the master\n# for longer than 310 seconds.\n#\n# A large cluster-replica-validity-factor may allow replicas with too old data to failover\n# a master, while a too small value may prevent the cluster from being able to\n# elect a replica at all.\n#\n# For maximum availability, it is possible to set the cluster-replica-validity-factor\n# to a value of 0, which means, that replicas will always try to failover the\n# master regardless of the last time they interacted with the master.\n# (However they'll always try to apply a delay proportional to their\n# offset rank).\n#\n# Zero is the only value able to guarantee that when all the partitions heal\n# the cluster will always be able to continue.\n#\n# cluster-replica-validity-factor 10\n\n# Cluster replicas are able to migrate to orphaned masters, that are masters\n# that are left without working replicas. This improves the cluster ability\n# to resist to failures as otherwise an orphaned master can't be failed over\n# in case of failure if it has no working replicas.\n#\n# Replicas migrate to orphaned masters only if there are still at least a\n# given number of other working replicas for their old master. This number\n# is the \"migration barrier\". A migration barrier of 1 means that a replica\n# will migrate only if there is at least 1 other working replica for its master\n# and so forth. It usually reflects the number of replicas you want for every\n# master in your cluster.\n#\n# Default is 1 (replicas migrate only if their masters remain with at least\n# one replica). To disable migration just set it to a very large value.\n# A value of 0 can be set but is useful only for debugging and dangerous\n# in production.\n#\n# cluster-migration-barrier 1\n\n# By default Redis Cluster nodes stop accepting queries if they detect there\n# is at least a hash slot uncovered (no available node is serving it).\n# This way if the cluster is partially down (for example a range of hash slots\n# are no longer covered) all the cluster becomes, eventually, unavailable.\n# It automatically returns available as soon as all the slots are covered again.\n#\n# However sometimes you want the subset of the cluster which is working,\n# to continue to accept queries for the part of the key space that is still\n# covered. In order to do so, just set the cluster-require-full-coverage\n# option to no.\n#\n# cluster-require-full-coverage yes\n\n# This option, when set to yes, prevents replicas from trying to failover its\n# master during master failures. However the master can still perform a\n# manual failover, if forced to do so.\n#\n# This is useful in different scenarios, especially in the case of multiple\n# data center operations, where we want one side to never be promoted if not\n# in the case of a total DC failure.\n#\n# cluster-replica-no-failover no\n\n# This option, when set to yes, allows nodes to serve read traffic while the\n# the cluster is in a down state, as long as it believes it owns the slots. \n#\n# This is useful for two cases.  The first case is for when an application \n# doesn't require consistency of data during node failures or network partitions.\n# One example of this is a cache, where as long as the node has the data it\n# should be able to serve it. \n#\n# The second use case is for configurations that don't meet the recommended  \n# three shards but want to enable cluster mode and scale later. A \n# master outage in a 1 or 2 shard configuration causes a read/write outage to the\n# entire cluster without this option set, with it set there is only a write outage.\n# Without a quorum of masters, slot ownership will not change automatically. \n#\n# cluster-allow-reads-when-down no\n\n# In order to setup your cluster make sure to read the documentation\n# available at http://redis.io web site.\n\n########################## CLUSTER DOCKER/NAT support  ########################\n\n# In certain deployments, Redis Cluster nodes address discovery fails, because\n# addresses are NAT-ted or because ports are forwarded (the typical case is\n# Docker and other containers).\n#\n# In order to make Redis Cluster working in such environments, a static\n# configuration where each node knows its public address is needed. The\n# following two options are used for this scope, and are:\n#\n# * cluster-announce-ip\n# * cluster-announce-port\n# * cluster-announce-bus-port\n#\n# Each instructs the node about its address, client port, and cluster message\n# bus port. The information is then published in the header of the bus packets\n# so that other nodes will be able to correctly map the address of the node\n# publishing the information.\n#\n# If the above options are not used, the normal Redis Cluster auto-detection\n# will be used instead.\n#\n# Note that when remapped, the bus port may not be at the fixed offset of\n# clients port + 10000, so you can specify any port and bus-port depending\n# on how they get remapped. If the bus-port is not set, a fixed offset of\n# 10000 will be used as usual.\n#\n# Example:\n#\n# cluster-announce-ip 10.1.1.5\n# cluster-announce-port 6379\n# cluster-announce-bus-port 6380\n\n################################## SLOW LOG ###################################\n\n# The Redis Slow Log is a system to log queries that exceeded a specified\n# execution time. The execution time does not include the I/O operations\n# like talking with the client, sending the reply and so forth,\n# but just the time needed to actually execute the command (this is the only\n# stage of command execution where the thread is blocked and can not serve\n# other requests in the meantime).\n#\n# You can configure the slow log with two parameters: one tells Redis\n# what is the execution time, in microseconds, to exceed in order for the\n# command to get logged, and the other parameter is the length of the\n# slow log. When a new command is logged the oldest one is removed from the\n# queue of logged commands.\n\n# The following time is expressed in microseconds, so 1000000 is equivalent\n# to one second. Note that a negative number disables the slow log, while\n# a value of zero forces the logging of every command.\nslowlog-log-slower-than 10000\n\n# There is no limit to this length. Just be aware that it will consume memory.\n# You can reclaim memory used by the slow log with SLOWLOG RESET.\nslowlog-max-len 128\n\n################################ LATENCY MONITOR ##############################\n\n# The Redis latency monitoring subsystem samples different operations\n# at runtime in order to collect data related to possible sources of\n# latency of a Redis instance.\n#\n# Via the LATENCY command this information is available to the user that can\n# print graphs and obtain reports.\n#\n# The system only logs operations that were performed in a time equal or\n# greater than the amount of milliseconds specified via the\n# latency-monitor-threshold configuration directive. When its value is set\n# to zero, the latency monitor is turned off.\n#\n# By default latency monitoring is disabled since it is mostly not needed\n# if you don't have latency issues, and collecting data has a performance\n# impact, that while very small, can be measured under big load. Latency\n# monitoring can easily be enabled at runtime using the command\n# \"CONFIG SET latency-monitor-threshold <milliseconds>\" if needed.\nlatency-monitor-threshold 0\n\n############################# EVENT NOTIFICATION ##############################\n\n# Redis can notify Pub/Sub clients about events happening in the key space.\n# This feature is documented at http://redis.io/topics/notifications\n#\n# For instance if keyspace events notification is enabled, and a client\n# performs a DEL operation on key \"foo\" stored in the Database 0, two\n# messages will be published via Pub/Sub:\n#\n# PUBLISH __keyspace@0__:foo del\n# PUBLISH __keyevent@0__:del foo\n#\n# It is possible to select the events that Redis will notify among a set\n# of classes. Every class is identified by a single character:\n#\n#  K     Keyspace events, published with __keyspace@<db>__ prefix.\n#  E     Keyevent events, published with __keyevent@<db>__ prefix.\n#  g     Generic commands (non-type specific) like DEL, EXPIRE, RENAME, ...\n#  $     String commands\n#  l     List commands\n#  s     Set commands\n#  h     Hash commands\n#  z     Sorted set commands\n#  x     Expired events (events generated every time a key expires)\n#  e     Evicted events (events generated when a key is evicted for maxmemory)\n#  t     Stream commands\n#  m     Key-miss events (Note: It is not included in the 'A' class)\n#  A     Alias for g$lshzxet, so that the \"AKE\" string means all the events\n#        (Except key-miss events which are excluded from 'A' due to their\n#         unique nature).\n#\n#  The \"notify-keyspace-events\" takes as argument a string that is composed\n#  of zero or multiple characters. The empty string means that notifications\n#  are disabled.\n#\n#  Example: to enable list and generic events, from the point of view of the\n#           event name, use:\n#\n#  notify-keyspace-events Elg\n#\n#  Example 2: to get the stream of the expired keys subscribing to channel\n#             name __keyevent@0__:expired use:\n#\n#  notify-keyspace-events Ex\n#\n#  By default all notifications are disabled because most users don't need\n#  this feature and the feature has some overhead. Note that if you don't\n#  specify at least one of K or E, no events will be delivered.\nnotify-keyspace-events \"\"\n\n############################### GOPHER SERVER #################################\n\n# Redis contains an implementation of the Gopher protocol, as specified in\n# the RFC 1436 (https://www.ietf.org/rfc/rfc1436.txt).\n#\n# The Gopher protocol was very popular in the late '90s. It is an alternative\n# to the web, and the implementation both server and client side is so simple\n# that the Redis server has just 100 lines of code in order to implement this\n# support.\n#\n# What do you do with Gopher nowadays? Well Gopher never *really* died, and\n# lately there is a movement in order for the Gopher more hierarchical content\n# composed of just plain text documents to be resurrected. Some want a simpler\n# internet, others believe that the mainstream internet became too much\n# controlled, and it's cool to create an alternative space for people that\n# want a bit of fresh air.\n#\n# Anyway for the 10nth birthday of the Redis, we gave it the Gopher protocol\n# as a gift.\n#\n# --- HOW IT WORKS? ---\n#\n# The Redis Gopher support uses the inline protocol of Redis, and specifically\n# two kind of inline requests that were anyway illegal: an empty request\n# or any request that starts with \"/\" (there are no Redis commands starting\n# with such a slash). Normal RESP2/RESP3 requests are completely out of the\n# path of the Gopher protocol implementation and are served as usual as well.\n#\n# If you open a connection to Redis when Gopher is enabled and send it\n# a string like \"/foo\", if there is a key named \"/foo\" it is served via the\n# Gopher protocol.\n#\n# In order to create a real Gopher \"hole\" (the name of a Gopher site in Gopher\n# talking), you likely need a script like the following:\n#\n#   https://github.com/antirez/gopher2redis\n#\n# --- SECURITY WARNING ---\n#\n# If you plan to put Redis on the internet in a publicly accessible address\n# to server Gopher pages MAKE SURE TO SET A PASSWORD to the instance.\n# Once a password is set:\n#\n#   1. The Gopher server (when enabled, not by default) will still serve\n#      content via Gopher.\n#   2. However other commands cannot be called before the client will\n#      authenticate.\n#\n# So use the 'requirepass' option to protect your instance.\n#\n# Note that Gopher is not currently supported when 'io-threads-do-reads'\n# is enabled.\n#\n# To enable Gopher support, uncomment the following line and set the option\n# from no (the default) to yes.\n#\n# gopher-enabled no\n\n############################### ADVANCED CONFIG ###############################\n\n# Hashes are encoded using a memory efficient data structure when they have a\n# small number of entries, and the biggest entry does not exceed a given\n# threshold. These thresholds can be configured using the following directives.\nhash-max-ziplist-entries 512\nhash-max-ziplist-value 64\n\n# Lists are also encoded in a special way to save a lot of space.\n# The number of entries allowed per internal list node can be specified\n# as a fixed maximum size or a maximum number of elements.\n# For a fixed maximum size, use -5 through -1, meaning:\n# -5: max size: 64 Kb  <-- not recommended for normal workloads\n# -4: max size: 32 Kb  <-- not recommended\n# -3: max size: 16 Kb  <-- probably not recommended\n# -2: max size: 8 Kb   <-- good\n# -1: max size: 4 Kb   <-- good\n# Positive numbers mean store up to _exactly_ that number of elements\n# per list node.\n# The highest performing option is usually -2 (8 Kb size) or -1 (4 Kb size),\n# but if your use case is unique, adjust the settings as necessary.\nlist-max-ziplist-size -2\n\n# Lists may also be compressed.\n# Compress depth is the number of quicklist ziplist nodes from *each* side of\n# the list to *exclude* from compression.  The head and tail of the list\n# are always uncompressed for fast push/pop operations.  Settings are:\n# 0: disable all list compression\n# 1: depth 1 means \"don't start compressing until after 1 node into the list,\n#    going from either the head or tail\"\n#    So: [head]->node->node->...->node->[tail]\n#    [head], [tail] will always be uncompressed; inner nodes will compress.\n# 2: [head]->[next]->node->node->...->node->[prev]->[tail]\n#    2 here means: don't compress head or head->next or tail->prev or tail,\n#    but compress all nodes between them.\n# 3: [head]->[next]->[next]->node->node->...->node->[prev]->[prev]->[tail]\n# etc.\nlist-compress-depth 0\n\n# Sets have a special encoding in just one case: when a set is composed\n# of just strings that happen to be integers in radix 10 in the range\n# of 64 bit signed integers.\n# The following configuration setting sets the limit in the size of the\n# set in order to use this special memory saving encoding.\nset-max-intset-entries 512\n\n# Similarly to hashes and lists, sorted sets are also specially encoded in\n# order to save a lot of space. This encoding is only used when the length and\n# elements of a sorted set are below the following limits:\nzset-max-ziplist-entries 128\nzset-max-ziplist-value 64\n\n# HyperLogLog sparse representation bytes limit. The limit includes the\n# 16 bytes header. When an HyperLogLog using the sparse representation crosses\n# this limit, it is converted into the dense representation.\n#\n# A value greater than 16000 is totally useless, since at that point the\n# dense representation is more memory efficient.\n#\n# The suggested value is ~ 3000 in order to have the benefits of\n# the space efficient encoding without slowing down too much PFADD,\n# which is O(N) with the sparse encoding. The value can be raised to\n# ~ 10000 when CPU is not a concern, but space is, and the data set is\n# composed of many HyperLogLogs with cardinality in the 0 - 15000 range.\nhll-sparse-max-bytes 3000\n\n# Streams macro node max size / items. The stream data structure is a radix\n# tree of big nodes that encode multiple items inside. Using this configuration\n# it is possible to configure how big a single node can be in bytes, and the\n# maximum number of items it may contain before switching to a new node when\n# appending new stream entries. If any of the following settings are set to\n# zero, the limit is ignored, so for instance it is possible to set just a\n# max entires limit by setting max-bytes to 0 and max-entries to the desired\n# value.\nstream-node-max-bytes 4096\nstream-node-max-entries 100\n\n# Active rehashing uses 1 millisecond every 100 milliseconds of CPU time in\n# order to help rehashing the main Redis hash table (the one mapping top-level\n# keys to values). The hash table implementation Redis uses (see dict.c)\n# performs a lazy rehashing: the more operation you run into a hash table\n# that is rehashing, the more rehashing \"steps\" are performed, so if the\n# server is idle the rehashing is never complete and some more memory is used\n# by the hash table.\n#\n# The default is to use this millisecond 10 times every second in order to\n# actively rehash the main dictionaries, freeing memory when possible.\n#\n# If unsure:\n# use \"activerehashing no\" if you have hard latency requirements and it is\n# not a good thing in your environment that Redis can reply from time to time\n# to queries with 2 milliseconds delay.\n#\n# use \"activerehashing yes\" if you don't have such hard requirements but\n# want to free memory asap when possible.\nactiverehashing yes\n\n# The client output buffer limits can be used to force disconnection of clients\n# that are not reading data from the server fast enough for some reason (a\n# common reason is that a Pub/Sub client can't consume messages as fast as the\n# publisher can produce them).\n#\n# The limit can be set differently for the three different classes of clients:\n#\n# normal -> normal clients including MONITOR clients\n# replica  -> replica clients\n# pubsub -> clients subscribed to at least one pubsub channel or pattern\n#\n# The syntax of every client-output-buffer-limit directive is the following:\n#\n# client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>\n#\n# A client is immediately disconnected once the hard limit is reached, or if\n# the soft limit is reached and remains reached for the specified number of\n# seconds (continuously).\n# So for instance if the hard limit is 32 megabytes and the soft limit is\n# 16 megabytes / 10 seconds, the client will get disconnected immediately\n# if the size of the output buffers reach 32 megabytes, but will also get\n# disconnected if the client reaches 16 megabytes and continuously overcomes\n# the limit for 10 seconds.\n#\n# By default normal clients are not limited because they don't receive data\n# without asking (in a push way), but just after a request, so only\n# asynchronous clients may create a scenario where data is requested faster\n# than it can read.\n#\n# Instead there is a default limit for pubsub and replica clients, since\n# subscribers and replicas receive data in a push fashion.\n#\n# Both the hard or the soft limit can be disabled by setting them to zero.\nclient-output-buffer-limit normal 0 0 0\nclient-output-buffer-limit replica 256mb 64mb 60\nclient-output-buffer-limit pubsub 32mb 8mb 60\n\n# Client query buffers accumulate new commands. They are limited to a fixed\n# amount by default in order to avoid that a protocol desynchronization (for\n# instance due to a bug in the client) will lead to unbound memory usage in\n# the query buffer. However you can configure it here if you have very special\n# needs, such us huge multi/exec requests or alike.\n#\n# client-query-buffer-limit 1gb\n\n# In the Redis protocol, bulk requests, that are, elements representing single\n# strings, are normally limited to 512 mb. However you can change this limit\n# here, but must be 1mb or greater\n#\n# proto-max-bulk-len 512mb\n\n# Redis calls an internal function to perform many background tasks, like\n# closing connections of clients in timeout, purging expired keys that are\n# never requested, and so forth.\n#\n# Not all tasks are performed with the same frequency, but Redis checks for\n# tasks to perform according to the specified \"hz\" value.\n#\n# By default \"hz\" is set to 10. Raising the value will use more CPU when\n# Redis is idle, but at the same time will make Redis more responsive when\n# there are many keys expiring at the same time, and timeouts may be\n# handled with more precision.\n#\n# The range is between 1 and 500, however a value over 100 is usually not\n# a good idea. Most users should use the default of 10 and raise this up to\n# 100 only in environments where very low latency is required.\nhz 10\n\n# Normally it is useful to have an HZ value which is proportional to the\n# number of clients connected. This is useful in order, for instance, to\n# avoid too many clients are processed for each background task invocation\n# in order to avoid latency spikes.\n#\n# Since the default HZ value by default is conservatively set to 10, Redis\n# offers, and enables by default, the ability to use an adaptive HZ value\n# which will temporarily raise when there are many connected clients.\n#\n# When dynamic HZ is enabled, the actual configured HZ will be used\n# as a baseline, but multiples of the configured HZ value will be actually\n# used as needed once more clients are connected. In this way an idle\n# instance will use very little CPU time while a busy instance will be\n# more responsive.\ndynamic-hz yes\n\n# When a child rewrites the AOF file, if the following option is enabled\n# the file will be fsync-ed every 32 MB of data generated. This is useful\n# in order to commit the file to the disk more incrementally and avoid\n# big latency spikes.\naof-rewrite-incremental-fsync yes\n\n# When redis saves RDB file, if the following option is enabled\n# the file will be fsync-ed every 32 MB of data generated. This is useful\n# in order to commit the file to the disk more incrementally and avoid\n# big latency spikes.\nrdb-save-incremental-fsync yes\n\n# Redis LFU eviction (see maxmemory setting) can be tuned. However it is a good\n# idea to start with the default settings and only change them after investigating\n# how to improve the performances and how the keys LFU change over time, which\n# is possible to inspect via the OBJECT FREQ command.\n#\n# There are two tunable parameters in the Redis LFU implementation: the\n# counter logarithm factor and the counter decay time. It is important to\n# understand what the two parameters mean before changing them.\n#\n# The LFU counter is just 8 bits per key, it's maximum value is 255, so Redis\n# uses a probabilistic increment with logarithmic behavior. Given the value\n# of the old counter, when a key is accessed, the counter is incremented in\n# this way:\n#\n# 1. A random number R between 0 and 1 is extracted.\n# 2. A probability P is calculated as 1/(old_value*lfu_log_factor+1).\n# 3. The counter is incremented only if R < P.\n#\n# The default lfu-log-factor is 10. This is a table of how the frequency\n# counter changes with a different number of accesses with different\n# logarithmic factors:\n#\n# +--------+------------+------------+------------+------------+------------+\n# | factor | 100 hits   | 1000 hits  | 100K hits  | 1M hits    | 10M hits   |\n# +--------+------------+------------+------------+------------+------------+\n# | 0      | 104        | 255        | 255        | 255        | 255        |\n# +--------+------------+------------+------------+------------+------------+\n# | 1      | 18         | 49         | 255        | 255        | 255        |\n# +--------+------------+------------+------------+------------+------------+\n# | 10     | 10         | 18         | 142        | 255        | 255        |\n# +--------+------------+------------+------------+------------+------------+\n# | 100    | 8          | 11         | 49         | 143        | 255        |\n# +--------+------------+------------+------------+------------+------------+\n#\n# NOTE: The above table was obtained by running the following commands:\n#\n#   redis-benchmark -n 1000000 incr foo\n#   redis-cli object freq foo\n#\n# NOTE 2: The counter initial value is 5 in order to give new objects a chance\n# to accumulate hits.\n#\n# The counter decay time is the time, in minutes, that must elapse in order\n# for the key counter to be divided by two (or decremented if it has a value\n# less <= 10).\n#\n# The default value for the lfu-decay-time is 1. A special value of 0 means to\n# decay the counter every time it happens to be scanned.\n#\n# lfu-log-factor 10\n# lfu-decay-time 1\n\n########################### ACTIVE DEFRAGMENTATION #######################\n#\n# What is active defragmentation?\n# -------------------------------\n#\n# Active (online) defragmentation allows a Redis server to compact the\n# spaces left between small allocations and deallocations of data in memory,\n# thus allowing to reclaim back memory.\n#\n# Fragmentation is a natural process that happens with every allocator (but\n# less so with Jemalloc, fortunately) and certain workloads. Normally a server\n# restart is needed in order to lower the fragmentation, or at least to flush\n# away all the data and create it again. However thanks to this feature\n# implemented by Oran Agra for Redis 4.0 this process can happen at runtime\n# in a \"hot\" way, while the server is running.\n#\n# Basically when the fragmentation is over a certain level (see the\n# configuration options below) Redis will start to create new copies of the\n# values in contiguous memory regions by exploiting certain specific Jemalloc\n# features (in order to understand if an allocation is causing fragmentation\n# and to allocate it in a better place), and at the same time, will release the\n# old copies of the data. This process, repeated incrementally for all the keys\n# will cause the fragmentation to drop back to normal values.\n#\n# Important things to understand:\n#\n# 1. This feature is disabled by default, and only works if you compiled Redis\n#    to use the copy of Jemalloc we ship with the source code of Redis.\n#    This is the default with Linux builds.\n#\n# 2. You never need to enable this feature if you don't have fragmentation\n#    issues.\n#\n# 3. Once you experience fragmentation, you can enable this feature when\n#    needed with the command \"CONFIG SET activedefrag yes\".\n#\n# The configuration parameters are able to fine tune the behavior of the\n# defragmentation process. If you are not sure about what they mean it is\n# a good idea to leave the defaults untouched.\n\n# Enabled active defragmentation\n# activedefrag no\n\n# Minimum amount of fragmentation waste to start active defrag\n# active-defrag-ignore-bytes 100mb\n\n# Minimum percentage of fragmentation to start active defrag\n# active-defrag-threshold-lower 10\n\n# Maximum percentage of fragmentation at which we use maximum effort\n# active-defrag-threshold-upper 100\n\n# Minimal effort for defrag in CPU percentage, to be used when the lower\n# threshold is reached\n# active-defrag-cycle-min 1\n\n# Maximal effort for defrag in CPU percentage, to be used when the upper\n# threshold is reached\n# active-defrag-cycle-max 25\n\n# Maximum number of set/hash/zset/list fields that will be processed from\n# the main dictionary scan\n# active-defrag-max-scan-fields 1000\n\n# Jemalloc background thread for purging will be enabled by default\njemalloc-bg-thread yes\n\n# It is possible to pin different threads and processes of Redis to specific\n# CPUs in your system, in order to maximize the performances of the server.\n# This is useful both in order to pin different Redis threads in different\n# CPUs, but also in order to make sure that multiple Redis instances running\n# in the same host will be pinned to different CPUs.\n#\n# Normally you can do this using the \"taskset\" command, however it is also\n# possible to this via Redis configuration directly, both in Linux and FreeBSD.\n#\n# You can pin the server/IO threads, bio threads, aof rewrite child process, and\n# the bgsave child process. The syntax to specify the cpu list is the same as\n# the taskset command:\n#\n# Set redis server/io threads to cpu affinity 0,2,4,6:\n# server_cpulist 0-7:2\n#\n# Set bio threads to cpu affinity 1,3:\n# bio_cpulist 1,3\n#\n# Set aof rewrite child process to cpu affinity 8,9,10,11:\n# aof_rewrite_cpulist 8-11\n#\n# Set bgsave child process to cpu affinity 1,10,11\n# bgsave_cpulist 1,10-11\n\n# In some cases redis will emit warnings and even refuse to start if it detects\n# that the system is in bad state, it is possible to suppress these warnings\n# by setting the following config which takes a space delimited list of warnings\n# to suppress\n#\n# ignore-warnings ARM64-COW-BUG"
  },
  {
    "path": "render.yaml",
    "content": "# https://render.com/docs/blueprint-spec\n\npreviewsEnabled: false\n\nservices:\n    - type: web\n      name: design-system\n      env: static\n      buildCommand: pnpm && NODE_ENV=production pnpm nx run design-system:build-storybook # NODE_ENV=production is required to fix this issue: https://github.com/nrwl/nx/issues/8403\n      staticPublishPath: dist/storybook/design-system\n      autoDeploy: true\n      domains:\n          - design.maybe.co\n\nenvVarGroups:\n    - name: global-env\n      envVars:\n          - key: NODE_VERSION # https://render.com/docs/node-version\n            value: 16.14.0 # LTS\n"
  },
  {
    "path": "tools/generators/.gitkeep",
    "content": ""
  },
  {
    "path": "tools/generators/index.ts",
    "content": "export * as TellerGenerator from './tellerGenerator'\n"
  },
  {
    "path": "tools/generators/tellerGenerator.ts",
    "content": "import { faker } from '@faker-js/faker'\nimport type { TellerTypes } from '../../libs/teller-api/src'\nimport type { Prisma } from '@prisma/client'\nimport { DateTime } from 'luxon'\n\nfunction generateSubType(\n    type: TellerTypes.AccountTypes\n): TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype {\n    if (type === 'depository') {\n        return faker.helpers.arrayElement([\n            'checking',\n            'savings',\n            'money_market',\n            'certificate_of_deposit',\n            'treasury',\n            'sweep',\n        ]) as TellerTypes.DepositorySubtypes\n    } else {\n        return 'credit_card' as TellerTypes.CreditSubtype\n    }\n}\n\ntype GenerateAccountsParams = {\n    count: number\n    enrollmentId: string\n    institutionName: string\n    institutionId: string\n    accountType?: TellerTypes.AccountTypes\n    accountSubType?: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype\n}\n\nexport function generateAccounts({\n    count,\n    enrollmentId,\n    institutionName,\n    institutionId,\n    accountType,\n    accountSubType,\n}: GenerateAccountsParams) {\n    const accounts: TellerTypes.Account[] = []\n    for (let i = 0; i < count; i++) {\n        const accountId = faker.string.uuid()\n        const lastFour = faker.finance.creditCardNumber().slice(-4)\n        const type: TellerTypes.AccountTypes =\n            accountType ?? faker.helpers.arrayElement(['depository', 'credit'])\n        let subType: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype\n        subType = generateSubType(type)\n\n        const accountStub = {\n            enrollment_id: enrollmentId,\n            links: {\n                balances: `https://api.teller.io/accounts/${accountId}/balances`,\n                self: `https://api.teller.io/accounts/${accountId}`,\n                transactions: `https://api.teller.io/accounts/${accountId}/transactions`,\n            },\n            institution: {\n                name: institutionName,\n                id: institutionId,\n            },\n            name: faker.finance.accountName(),\n            currency: 'USD',\n            id: accountId,\n            last_four: lastFour,\n            status: faker.helpers.arrayElement(['open', 'closed']) as TellerTypes.AccountStatus,\n        }\n\n        if (faker.datatype.boolean()) {\n            accounts.push({\n                ...accountStub,\n                type: 'depository',\n                subtype: faker.helpers.arrayElement([\n                    'checking',\n                    'savings',\n                    'money_market',\n                    'certificate_of_deposit',\n                    'treasury',\n                    'sweep',\n                ]),\n            })\n        } else {\n            accounts.push({\n                ...accountStub,\n                type: 'credit',\n                subtype: 'credit_card',\n            })\n        }\n    }\n    return accounts\n}\n\nexport function generateBalance(account_id: string): TellerTypes.AccountBalance {\n    const amount = faker.finance.amount()\n    return {\n        available: amount,\n        ledger: amount,\n        links: {\n            account: `https://api.teller.io/accounts/${account_id}`,\n            self: `https://api.teller.io/accounts/${account_id}/balances`,\n        },\n        account_id,\n    }\n}\n\ntype GenerateAccountsWithBalancesParams = {\n    count: number\n    enrollmentId: string\n    institutionName: string\n    institutionId: string\n    accountType?: TellerTypes.AccountTypes\n    accountSubType?: TellerTypes.DepositorySubtypes | TellerTypes.CreditSubtype\n}\n\nexport function generateAccountsWithBalances({\n    count,\n    enrollmentId,\n    institutionName,\n    institutionId,\n    accountType,\n    accountSubType,\n}: GenerateAccountsWithBalancesParams): TellerTypes.GetAccountsResponse {\n    const accountsWithBalances: TellerTypes.AccountWithBalances[] = []\n    for (let i = 0; i < count; i++) {\n        const account = generateAccounts({\n            count,\n            enrollmentId,\n            institutionName,\n            institutionId,\n            accountType,\n            accountSubType,\n        })[0]\n        const balance = generateBalance(account.id)\n        accountsWithBalances.push({\n            ...account,\n            balance,\n        })\n    }\n    return accountsWithBalances\n}\n\nexport function generateTransactions(count: number, accountId: string): TellerTypes.Transaction[] {\n    const transactions: TellerTypes.Transaction[] = []\n\n    for (let i = 0; i < count; i++) {\n        const transactionId = `txn_${faker.string.uuid()}`\n        const transaction = {\n            details: {\n                processing_status: faker.helpers.arrayElement(['complete', 'pending']),\n                category: faker.helpers.arrayElement([\n                    'accommodation',\n                    'advertising',\n                    'bar',\n                    'charity',\n                    'clothing',\n                    'dining',\n                    'education',\n                    'electronics',\n                    'entertainment',\n                    'fuel',\n                    'general',\n                    'groceries',\n                    'health',\n                    'home',\n                    'income',\n                    'insurance',\n                    'investment',\n                    'loan',\n                    'office',\n                    'phone',\n                    'service',\n                    'shopping',\n                    'software',\n                    'sport',\n                    'tax',\n                    'transport',\n                    'transportation',\n                    'utilities',\n                ]),\n                counterparty: {\n                    name: faker.company.name(),\n                    type: faker.helpers.arrayElement(['person', 'business']),\n                },\n            },\n            running_balance: null,\n            description: faker.word.words({ count: { min: 3, max: 10 } }),\n            id: transactionId,\n            date: faker.date\n                .between({ from: lowerBound.toJSDate(), to: now.toJSDate() })\n                .toISOString()\n                .split('T')[0], // recent date in 'YYYY-MM-DD' format\n            account_id: accountId,\n            links: {\n                account: `https://api.teller.io/accounts/${accountId}`,\n                self: `https://api.teller.io/accounts/${accountId}/transactions/${transactionId}`,\n            },\n            amount: faker.finance.amount(),\n            type: faker.helpers.arrayElement(['transfer', 'deposit', 'withdrawal']),\n            status: faker.helpers.arrayElement(['pending', 'posted']),\n        } as TellerTypes.Transaction\n        transactions.push(transaction)\n    }\n    return transactions\n}\n\nexport function generateEnrollment(): TellerTypes.Enrollment & { institutionId: string } {\n    const institutionName = faker.company.name()\n    const institutionId = institutionName.toLowerCase().replace(/\\s/g, '_')\n    return {\n        accessToken: `token_${faker.string.alphanumeric(15)}`,\n        user: {\n            id: `usr_${faker.string.alphanumeric(15)}`,\n        },\n        enrollment: {\n            id: `enr_${faker.string.alphanumeric(15)}`,\n            institution: {\n                name: institutionName,\n            },\n        },\n        signatures: [faker.string.alphanumeric(15)],\n        institutionId,\n    }\n}\n\ntype GenerateConnectionsResponse = {\n    enrollment: TellerTypes.Enrollment & { institutionId: string }\n    accounts: TellerTypes.Account[]\n    accountsWithBalances: TellerTypes.AccountWithBalances[]\n    transactions: TellerTypes.Transaction[]\n}\n\nexport function generateConnection(): GenerateConnectionsResponse {\n    const accountsWithBalances: TellerTypes.AccountWithBalances[] = []\n    const accounts: TellerTypes.Account[] = []\n    const transactions: TellerTypes.Transaction[] = []\n\n    const enrollment = generateEnrollment()\n\n    const accountCount: number = faker.number.int({ min: 1, max: 3 })\n\n    const enrollmentId = enrollment.enrollment.id\n    const institutionName = enrollment.enrollment.institution.name\n    const institutionId = enrollment.institutionId\n    accountsWithBalances.push(\n        ...generateAccountsWithBalances({\n            count: accountCount,\n            enrollmentId,\n            institutionName,\n            institutionId,\n        })\n    )\n    for (const account of accountsWithBalances) {\n        const { balance, ...accountWithoutBalance } = account\n        accounts.push(accountWithoutBalance)\n        const transactionsCount: number = faker.number.int({ min: 1, max: 5 })\n        const generatedTransactions = generateTransactions(transactionsCount, account.id)\n        transactions.push(...generatedTransactions)\n    }\n\n    return {\n        enrollment,\n        accounts,\n        accountsWithBalances,\n        transactions,\n    }\n}\n\nexport const now = DateTime.fromISO('2022-01-03', { zone: 'utc' })\n\nexport const lowerBound = DateTime.fromISO('2021-12-01', { zone: 'utc' })\n\nexport const testDates = {\n    now,\n    lowerBound,\n    totalDays: now.diff(lowerBound, 'days').days,\n    prismaWhereFilter: {\n        date: {\n            gte: lowerBound.toJSDate(),\n            lte: now.toJSDate(),\n        },\n    } as Prisma.AccountBalanceWhereInput,\n}\n\nexport function calculateDailyBalances(startingBalance, transactions, dateInterval) {\n    transactions.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())\n\n    const balanceChanges = {}\n\n    transactions.forEach((transaction) => {\n        const date = new Date(transaction.date).toISOString().split('T')[0]\n        balanceChanges[date] = (balanceChanges[date] || 0) + Number(transaction.amount)\n    })\n    return dateInterval.map((date) => {\n        return Object.keys(balanceChanges)\n            .filter((d) => d <= date)\n            .reduce((acc, d) => acc + balanceChanges[d], startingBalance)\n    })\n}\n"
  },
  {
    "path": "tools/pages/projections.html",
    "content": "<html>\n    <head>\n        <script src=\"https://cdn.jsdelivr.net/npm/chart.js\"></script>\n        <script src=\"https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js\"></script>\n        <script>\n            window.API_URL = 'http://localhost:3333'\n        </script>\n    </head>\n    <body>\n        <div style=\"padding: 16px 12px\">\n            <canvas id=\"simulationsChart\"></canvas>\n            <canvas id=\"successRateChart\"></canvas>\n        </div>\n\n        <script>\n            const GRAYS = ['#e2e8f0', '#cbd5e1', '#e5e7eb', '#d1d5db', '#e4e4e7', '#d4d4d8']\n            const TILES = ['#ef4444', '#fb923c', '#155e75', '#06b6d4', '#10b981'] // 10, 25, 50, 75, 90\n\n            function getColor(idx, palette = COLORS) {\n                return palette[idx % palette.length]\n            }\n\n            function getOptions(title, overrides = {}) {\n                return {\n                    ...overrides,\n                    responsive: true,\n                    interaction: {\n                        mode: 'index',\n                        intersect: false,\n                    },\n                    plugins: {\n                        legend: false,\n                        title: {\n                            display: true,\n                            text: title,\n                        },\n                        tooltip: {\n                            filter: (item) => !item.dataset.disableTooltip,\n                        },\n                    },\n                }\n            }\n\n            fetch(`${window.API_URL}/tools/projections`, {\n                method: 'POST',\n                headers: {\n                    accept: 'application/json',\n                    'content-type': 'application/json',\n                },\n                body: JSON.stringify({}),\n            })\n                .then((res) => res.json())\n                .then(({ theo, simulations, simulationsWithStats, simulationsByPercentile }) => {\n                    const theoDataset = {\n                        label: `Theo`,\n                        data: theo.map(({ year, netWorth }) => ({\n                            x: `${year}`,\n                            y: +netWorth,\n                        })),\n                        backgroundColor: '#333333',\n                        borderColor: '#333333',\n                        pointRadius: 0,\n                        order: 0,\n                    }\n\n                    const simulationsFiltered = _.sortBy(\n                        simulations,\n                        (s) => +s.at(-1).netWorth\n                    ).slice(simulations.length * 0.1, simulations.length * 0.9)\n\n                    const simulationsDataset = simulationsFiltered.map((simulation, idx) => {\n                        const color = getColor(idx, GRAYS)\n                        return {\n                            label: `Simulation ${idx}`,\n                            data: simulation.map(({ year, netWorth }) => ({\n                                x: `${year}`,\n                                y: +netWorth,\n                            })),\n                            backgroundColor: color,\n                            borderColor: color,\n                            borderWidth: 2,\n                            pointRadius: 0,\n                            order: 1,\n                            disableTooltip: true,\n                        }\n                    })\n\n                    const simulationsChart = new Chart(\n                        document.getElementById('simulationsChart'),\n                        {\n                            type: 'line',\n                            data: {\n                                datasets: [\n                                    theoDataset,\n                                    ...simulationsDataset,\n                                    ...simulationsByPercentile.map(\n                                        ({ percentile, simulation }, idx) => {\n                                            const color = getColor(idx, TILES)\n\n                                            return {\n                                                label: `${+percentile * 100}%`,\n                                                data: simulation.map(({ year, netWorth }) => ({\n                                                    x: `${year}`,\n                                                    y: +netWorth,\n                                                })),\n                                                backgroundColor: color,\n                                                borderColor: color,\n                                                pointRadius: 0,\n                                            }\n                                        }\n                                    ),\n                                    // {\n                                    //     label: `CI (95%) - Lower`,\n                                    //     data: simulationsWithStats.map(({ year, ci95 }) => ({\n                                    //         x: `${year}`,\n                                    //         y: +ci95[0],\n                                    //     })),\n                                    //     backgroundColor: '#FA9E9E',\n                                    //     borderColor: '#FA9E9E',\n                                    //     pointRadius: 0,\n                                    // },\n                                    // {\n                                    //     label: `CI (95%) - Upper`,\n                                    //     data: simulationsWithStats.map(({ year, ci95 }) => ({\n                                    //         x: `${year}`,\n                                    //         y: +ci95[1],\n                                    //     })),\n                                    //     backgroundColor: '#3AE478',\n                                    //     borderColor: '#3AE478',\n                                    //     pointRadius: 0,\n                                    // },\n                                    {\n                                        label: `Avg Net Worth`,\n                                        data: simulationsWithStats.map(({ year, avg }, idx) => ({\n                                            x: `${year}`,\n                                            y: +avg,\n                                        })),\n                                        backgroundColor: '#777',\n                                        borderColor: '#777',\n                                        pointRadius: 0,\n                                    },\n                                ],\n                            },\n                            options: getOptions('Simulations w/ Percentiles'),\n                        }\n                    )\n\n                    const successRateChart = new Chart(\n                        document.getElementById('successRateChart'),\n                        {\n                            type: 'line',\n                            data: {\n                                datasets: [\n                                    {\n                                        label: `Success Rate`,\n                                        data: simulationsWithStats.map(\n                                            ({ year, successRate }, idx) => ({\n                                                x: `${year}`,\n                                                y: successRate * 100,\n                                            })\n                                        ),\n                                        backgroundColor: '#333',\n                                        borderColor: '#333',\n                                        pointRadius: 0,\n                                    },\n                                ],\n                            },\n                            options: getOptions('Success Rate', {\n                                scales: {\n                                    y: { min: 0, max: 101 },\n                                },\n                            }),\n                        }\n                    )\n                })\n        </script>\n    </body>\n</html>\n"
  },
  {
    "path": "tools/scripts/gen-cloudfront-signing-keys.sh",
    "content": "#!/usr/bin/env bash\n\n# Script for documentation purposes\n# Further instructions here:\n# https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html#private-content-creating-cloudfront-key-pairs\n\n# REMOVE FROM SOURCE CONTROL!\n# Store in secrets manager\nopenssl genrsa -out private_key.pem 2048\n\n# Store in param store \nopenssl rsa -pubout -in private_key.pem -out public_key.pem"
  },
  {
    "path": "tools/scripts/gen-secret.sh",
    "content": "#!/usr/bin/env bash\n\nopenssl rand -hex 32"
  },
  {
    "path": "tools/scripts/getAffectedApps.sh",
    "content": "#!/bin/bash\n\nNX_COMMAND=$(./node_modules/.bin/nx affected:apps --plain)\n\necho -e \"Apps that will deploy: $NX_COMMAND \\n\"\n\nAPPS=(client server workers)\n\nfor APP in \"${APPS[@]}\"\ndo \n    APP_AFFECTED=$(echo $NX_COMMAND | grep -wq $APP && echo 'true' || echo 'false' )\n    echo \"::set-output name=${APP}_affected::$APP_AFFECTED\"\ndone"
  },
  {
    "path": "tools/scripts/runStagingE2ETests.sh",
    "content": "#!/bin/bash\n\npnpm nx run e2e:e2e \\\n    --baseUrl https://staging-app.maybe.co \\\n    --headed \\\n    --skip-nx-cache \\\n    --env.API_URL https://staging-api.maybe.co/v1 \\\n    --env.STRIPE_WEBHOOK_SECRET REPLACE_THIS\n"
  },
  {
    "path": "tools/scripts/vercelBuildIgnore.js",
    "content": "const https = require('https')\n\nconst VERCEL_GIT_COMMIT_REF = process.env.VERCEL_GIT_COMMIT_REF\nconst CI_PROJECT_NAME = process.env.CI_PROJECT_NAME\nconst CI_TEAM_ID = process.env.CI_TEAM_ID\nconst CI_PROJECT_ID = process.env.CI_PROJECT_ID\nconst CI_DEPLOY_HOOK_ID = process.env.CI_DEPLOY_HOOK_ID\nconst CI_VERCEL_TOKEN = process.env.CI_VERCEL_TOKEN\n\nconsole.log(`VERCEL_GIT_COMMIT_REF: ${VERCEL_GIT_COMMIT_REF}`)\nconsole.log(`CI_PROJECT_NAME: ${CI_PROJECT_NAME}`)\n\n/**\n * Vercel currently has no easy way to determine whether a deploy was triggered\n * from a deploy hook, and therefore, all manual builds would be cancelled without this logic here.\n * @see https://github.com/vercel/community/discussions/285#discussioncomment-1696833\n *\n * We deploy the staging-client project on merge to main to avoid conflicting deploys\n * with our main app's PR preview deploys.\n */\nif (process.env.VERCEL_GIT_COMMIT_REF === 'main') {\n    if (CI_PROJECT_NAME === 'staging-client') {\n        console.log('✅ - Build can proceed, staging-client auto-deploys on merge to main')\n        process.exit(1)\n    }\n\n    let data = ''\n    const req = https.request(\n        {\n            hostname: 'api.vercel.com',\n            path: `/v6/deployments?limit=1&projectId=${CI_PROJECT_ID}&teamId=${CI_TEAM_ID}&state=BUILDING&target=production`,\n            headers: {\n                Authorization: `Bearer ${CI_VERCEL_TOKEN}`,\n            },\n        },\n        (res) => {\n            res.on('data', (d) => (data += d.toString()))\n            res.on('end', (d) => {\n                const parsed = JSON.parse(data)\n\n                try {\n                    const deployment = parsed.deployments[0]\n                    const hookId = deployment.meta.deployHookId\n\n                    if (hookId === CI_DEPLOY_HOOK_ID) {\n                        console.log('✅ - Build can proceed, using deploy hook')\n                        process.exit(1)\n                    } else {\n                        throw new Error('Could not find deployment triggered from deploy hook')\n                    }\n                } catch (e) {\n                    console.error(e)\n                    console.log('🛑 - Build skipped, error finding deployments')\n                    process.exit(0)\n                }\n            })\n        }\n    )\n\n    req.on('error', console.error)\n    req.end()\n} else {\n    if (CI_PROJECT_NAME === 'staging-client') {\n        console.log('🛑 - Build skipped, staging-client does not deploy PR previews')\n        process.exit(0)\n    }\n\n    // Allow PR previews to deploy\n    console.log('✅ - Build can proceed')\n    process.exit(1)\n}\n"
  },
  {
    "path": "tools/scripts/wait-for-it.sh",
    "content": "#!/usr/bin/env bash\n# Use this script to test if a given TCP host/port are available\n\nWAITFORIT_cmdname=${0##*/}\n\nechoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo \"$@\" 1>&2; fi }\n\nusage()\n{\n    cat << USAGE >&2\nUsage:\n    $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]\n    -h HOST | --host=HOST       Host or IP under test\n    -p PORT | --port=PORT       TCP port under test\n                                Alternatively, you specify the host and port as host:port\n    -s | --strict               Only execute subcommand if the test succeeds\n    -q | --quiet                Don't output any status messages\n    -t TIMEOUT | --timeout=TIMEOUT\n                                Timeout in seconds, zero for no timeout\n    -- COMMAND ARGS             Execute command with args after the test finishes\nUSAGE\n    exit 1\n}\n\nwait_for()\n{\n    if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then\n        echoerr \"$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT\"\n    else\n        echoerr \"$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout\"\n    fi\n    WAITFORIT_start_ts=$(date +%s)\n    while :\n    do\n        if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then\n            nc -z $WAITFORIT_HOST $WAITFORIT_PORT\n            WAITFORIT_result=$?\n        else\n            (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1\n            WAITFORIT_result=$?\n        fi\n        if [[ $WAITFORIT_result -eq 0 ]]; then\n            WAITFORIT_end_ts=$(date +%s)\n            echoerr \"$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds\"\n            break\n        fi\n        sleep 1\n    done\n    return $WAITFORIT_result\n}\n\nwait_for_wrapper()\n{\n    # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692\n    if [[ $WAITFORIT_QUIET -eq 1 ]]; then\n        timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &\n    else\n        timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &\n    fi\n    WAITFORIT_PID=$!\n    trap \"kill -INT -$WAITFORIT_PID\" INT\n    wait $WAITFORIT_PID\n    WAITFORIT_RESULT=$?\n    if [[ $WAITFORIT_RESULT -ne 0 ]]; then\n        echoerr \"$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT\"\n    fi\n    return $WAITFORIT_RESULT\n}\n\n# process arguments\nwhile [[ $# -gt 0 ]]\ndo\n    case \"$1\" in\n        *:* )\n        WAITFORIT_hostport=(${1//:/ })\n        WAITFORIT_HOST=${WAITFORIT_hostport[0]}\n        WAITFORIT_PORT=${WAITFORIT_hostport[1]}\n        shift 1\n        ;;\n        --child)\n        WAITFORIT_CHILD=1\n        shift 1\n        ;;\n        -q | --quiet)\n        WAITFORIT_QUIET=1\n        shift 1\n        ;;\n        -s | --strict)\n        WAITFORIT_STRICT=1\n        shift 1\n        ;;\n        -h)\n        WAITFORIT_HOST=\"$2\"\n        if [[ $WAITFORIT_HOST == \"\" ]]; then break; fi\n        shift 2\n        ;;\n        --host=*)\n        WAITFORIT_HOST=\"${1#*=}\"\n        shift 1\n        ;;\n        -p)\n        WAITFORIT_PORT=\"$2\"\n        if [[ $WAITFORIT_PORT == \"\" ]]; then break; fi\n        shift 2\n        ;;\n        --port=*)\n        WAITFORIT_PORT=\"${1#*=}\"\n        shift 1\n        ;;\n        -t)\n        WAITFORIT_TIMEOUT=\"$2\"\n        if [[ $WAITFORIT_TIMEOUT == \"\" ]]; then break; fi\n        shift 2\n        ;;\n        --timeout=*)\n        WAITFORIT_TIMEOUT=\"${1#*=}\"\n        shift 1\n        ;;\n        --)\n        shift\n        WAITFORIT_CLI=(\"$@\")\n        break\n        ;;\n        --help)\n        usage\n        ;;\n        *)\n        echoerr \"Unknown argument: $1\"\n        usage\n        ;;\n    esac\ndone\n\nif [[ \"$WAITFORIT_HOST\" == \"\" || \"$WAITFORIT_PORT\" == \"\" ]]; then\n    echoerr \"Error: you need to provide a host and port to test.\"\n    usage\nfi\n\nWAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}\nWAITFORIT_STRICT=${WAITFORIT_STRICT:-0}\nWAITFORIT_CHILD=${WAITFORIT_CHILD:-0}\nWAITFORIT_QUIET=${WAITFORIT_QUIET:-0}\n\n# Check to see if timeout is from busybox?\nWAITFORIT_TIMEOUT_PATH=$(type -p timeout)\nWAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)\n\nWAITFORIT_BUSYTIMEFLAG=\"\"\nif [[ $WAITFORIT_TIMEOUT_PATH =~ \"busybox\" ]]; then\n    WAITFORIT_ISBUSY=1\n    # Check if busybox timeout uses -t flag\n    # (recent Alpine versions don't support -t anymore)\n    if timeout &>/dev/stdout | grep -q -e '-t '; then\n        WAITFORIT_BUSYTIMEFLAG=\"-t\"\n    fi\nelse\n    WAITFORIT_ISBUSY=0\nfi\n\nif [[ $WAITFORIT_CHILD -gt 0 ]]; then\n    wait_for\n    WAITFORIT_RESULT=$?\n    exit $WAITFORIT_RESULT\nelse\n    if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then\n        wait_for_wrapper\n        WAITFORIT_RESULT=$?\n    else\n        wait_for\n        WAITFORIT_RESULT=$?\n    fi\nfi\n\nif [[ $WAITFORIT_CLI != \"\" ]]; then\n    if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then\n        echoerr \"$WAITFORIT_cmdname: strict mode, refusing to execute subprocess\"\n        exit $WAITFORIT_RESULT\n    fi\n    exec \"${WAITFORIT_CLI[@]}\"\nelse\n    exit $WAITFORIT_RESULT\nfi"
  },
  {
    "path": "tools/test-data/index.ts",
    "content": "export * as PolygonTestData from './polygon'\n"
  },
  {
    "path": "tools/test-data/polygon/exchanges.ts",
    "content": "export const getExchanges = {\n    results: [\n        {\n            id: 10,\n            type: 'exchange',\n            asset_class: 'stocks',\n            locale: 'us',\n            name: 'New York Stock Exchange',\n            mic: 'XNYS',\n            operating_mic: 'XNYS',\n            participant_id: 'N',\n            url: 'https://www.nyse.com',\n        },\n\n        {\n            id: 12,\n            type: 'exchange',\n            asset_class: 'stocks',\n            locale: 'us',\n            name: 'Nasdaq',\n            mic: 'XNAS',\n            operating_mic: 'XNAS',\n            participant_id: 'T',\n            url: 'https://www.nasdaq.com',\n        },\n    ],\n    status: 'OK',\n    request_id: '48cc174fa6b10a733d1b3cdc530c9894',\n    count: 2,\n}\n"
  },
  {
    "path": "tools/test-data/polygon/index.ts",
    "content": "export * from './snapshots'\nexport * from './exchanges'\nexport * from './tickers'\n"
  },
  {
    "path": "tools/test-data/polygon/snapshots.ts",
    "content": "// https://api.polygon.io/v2/snapshot/locale/us/markets/stocks/tickers?tickers=AAPL,VOO\nexport const snapshotAllTickers = {\n    count: 2,\n    status: 'DELAYED',\n    tickers: [\n        {\n            day: {\n                c: 366.03,\n                h: 371.445,\n                l: 364.02,\n                o: 371.3,\n                v: 5033415,\n                vw: 366.3978,\n            },\n            lastQuote: {\n                P: 366.03,\n                S: 4,\n                p: 365.98,\n                s: 4,\n                t: 1661892857579892500,\n            },\n            lastTrade: {\n                c: [14, 12, 41],\n                i: '52983575693109',\n                p: 366.16,\n                s: 400,\n                t: 1661892199398749200,\n                x: 11,\n            },\n            min: {\n                av: 5033152,\n                c: 366.16,\n                h: 366.16,\n                l: 366.16,\n                o: 366.16,\n                v: 412,\n                vw: 366.16,\n            },\n            prevDay: {\n                c: 370.05,\n                h: 373.05,\n                l: 368.78,\n                o: 369.77,\n                v: 4953159,\n                vw: 371.0004,\n            },\n            ticker: 'VOO',\n            todaysChange: -3.89,\n            todaysChangePerc: -1.051,\n            updated: 1661892240000000000,\n        },\n        {\n            day: {\n                c: 158.91,\n                h: 162.56,\n                l: 157.72,\n                o: 162.13,\n                v: 77314864,\n                vw: 159.2953,\n            },\n            lastQuote: {\n                P: 159.16,\n                S: 1,\n                p: 159.12,\n                s: 5,\n                t: 1661892795748055800,\n            },\n            lastTrade: {\n                c: [12],\n                i: '61326',\n                p: 159.13,\n                s: 100,\n                t: 1661892795747899000,\n                x: 11,\n            },\n            min: {\n                av: 77314859,\n                c: 159.13,\n                h: 159.13,\n                l: 159.12,\n                o: 159.12,\n                v: 970,\n                vw: 159.1241,\n            },\n            prevDay: {\n                c: 161.38,\n                h: 162.9,\n                l: 159.82,\n                o: 161.145,\n                v: 73313953,\n                vw: 161.5291,\n            },\n            ticker: 'AAPL',\n            todaysChange: -2.25,\n            todaysChangePerc: -1.394,\n            updated: 1661892840000000000,\n        },\n    ],\n}\n\nexport const dailyPricing = {\n    resultsCount: 2,\n    queryCount: 2,\n    adjusted: true,\n    results: [\n        {\n            T: 'AAPL',\n            v: 70790813,\n            vw: 131.6292,\n            o: 130.465,\n            c: 130.15,\n            h: 133.41,\n            l: 129.89,\n            t: 1673298000000,\n            n: 645365,\n        },\n        {\n            T: 'VO0',\n            v: 10002985,\n            vw: 10.7843,\n            o: 10.83,\n            c: 10.74,\n            h: 10.89,\n            l: 10.715,\n            t: 1673298000000,\n            n: 22513,\n        },\n    ],\n    status: 'OK',\n    request_id: '5548dd4c60d6c3cdb644cc62fe9db7c8',\n    count: 2,\n}\n"
  },
  {
    "path": "tools/test-data/polygon/tickers.ts",
    "content": "export const getNYSETickers = {\n    results: [\n        {\n            ticker: 'A',\n            name: 'Agilent Technologies Inc.',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNYS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001090872',\n            composite_figi: 'BBG000C2V3D6',\n            share_class_figi: 'BBG001SCTQY4',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AA',\n            name: 'Alcoa Corporation',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNYS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001675149',\n            composite_figi: 'BBG00B3T3HD3',\n            share_class_figi: 'BBG00B3T3HF1',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AACT',\n            name: 'Ares Acquisition Corporation II',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNYS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001853138',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AACT.U',\n            name: 'Ares Acquisition Corporation II Units, each consisting of one Class A ordinary share and one-half of one redeemable warrant',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNYS',\n            type: 'UNIT',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001853138',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AACT.WS',\n            name: 'Ares Acquisition Corporation II Redeemable Warrants, each whole warrant exercisable for one Class A ordinary share at an exercise price of $11.50',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNYS',\n            type: 'WARRANT',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001853138',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AAN',\n            name: \"The Aaron's Company, Inc.\",\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNYS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001821393',\n            composite_figi: 'BBG00WCNDCZ6',\n            share_class_figi: 'BBG00WCNDDH4',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AAP',\n            name: 'ADVANCE AUTO PARTS INC',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNYS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001158449',\n            composite_figi: 'BBG000F7RCJ1',\n            share_class_figi: 'BBG001SD2SB2',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AAT',\n            name: 'AMERICAN ASSETS TRUST, INC.',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNYS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001500217',\n            composite_figi: 'BBG00161BCR0',\n            share_class_figi: 'BBG001TCBJS5',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AB',\n            name: 'AllianceBernstein Holding, L.P.',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNYS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0000825313',\n            composite_figi: 'BBG000B9WM03',\n            share_class_figi: 'BBG001S5N9S0',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'ABBV',\n            name: 'ABBVIE INC.',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNYS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001551152',\n            composite_figi: 'BBG0025Y4RY4',\n            share_class_figi: 'BBG0025Y4RZ3',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n    ],\n    status: 'OK',\n    request_id: '5548dd4c60d6c3cdb644cc62fe9db7c8',\n    count: 10,\n    next_url: '',\n}\n\nexport const getNASDAQTickers = {\n    results: [\n        {\n            ticker: 'AACG',\n            name: 'ATA Creativity Global American Depositary Shares',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNAS',\n            type: 'ADRC',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001420529',\n            composite_figi: 'BBG000V2S3P6',\n            share_class_figi: 'BBG001T125S9',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AACI',\n            name: 'Armada Acquisition Corp. I Common Stock',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNAS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001844817',\n            composite_figi: 'BBG011XR7306',\n            share_class_figi: 'BBG011XR7315',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AACIU',\n            name: 'Armada Acquisition Corp. I Unit',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNAS',\n            type: 'UNIT',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001844817',\n            composite_figi: 'BBG011PFP1D1',\n            share_class_figi: 'BBG011PFP285',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AACIW',\n            name: 'Armada Acquisition Corp. I Warrant',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNAS',\n            type: 'WARRANT',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001844817',\n            composite_figi: 'BBG011XRPQV1',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AADI',\n            name: 'Aadi Bioscience, Inc. Common Stock',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNAS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001422142',\n            composite_figi: 'BBG002WN7DT2',\n            share_class_figi: 'BBG002WN7DV9',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AADR',\n            name: 'AdvisorShares Dorsey Wright ADR ETF',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNAS',\n            type: 'ETF',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001408970',\n            composite_figi: 'BBG000BDYRW6',\n            share_class_figi: 'BBG001T9B1Y4',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AAGR',\n            name: 'African Agriculture Holdings Inc. Common Stock',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNAS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001848898',\n            composite_figi: 'BBG00ZKGK0R2',\n            share_class_figi: 'BBG00ZKGK109',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AAGRW',\n            name: 'African Agriculture Holdings Inc. Warrant',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNAS',\n            type: 'WARRANT',\n            active: true,\n            currency_name: 'usd',\n            cik: '0001848898',\n            composite_figi: 'BBG00ZKXGR82',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AAL',\n            name: 'American Airlines Group Inc.',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNAS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0000006201',\n            composite_figi: 'BBG005P7Q881',\n            share_class_figi: 'BBG005P7Q907',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n        {\n            ticker: 'AAME',\n            name: 'Atlantic American Corp',\n            market: 'stocks',\n            locale: 'us',\n            primary_exchange: 'XNAS',\n            type: 'CS',\n            active: true,\n            currency_name: 'usd',\n            cik: '0000008177',\n            composite_figi: 'BBG000B9XB24',\n            share_class_figi: 'BBG001S5N8T1',\n            last_updated_utc: '2024-01-19T00:00:00Z',\n        },\n    ],\n    status: 'OK',\n    request_id: 'd50bcbf03ec5a45021fd52f7b218bd6e',\n    count: 10,\n    next_url: '',\n}\n"
  },
  {
    "path": "tools/tsconfig.tools.json",
    "content": "{\n    \"extends\": \"../tsconfig.base.json\",\n    \"compilerOptions\": {\n        \"outDir\": \"../dist/out-tsc/tools\",\n        \"rootDir\": \".\",\n        \"module\": \"commonjs\",\n        \"target\": \"es5\",\n        \"types\": [\"node\"],\n        \"importHelpers\": false\n    },\n    \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "tsconfig.base.json",
    "content": "{\n    \"compileOnSave\": false,\n    \"compilerOptions\": {\n        \"rootDir\": \".\",\n        \"sourceMap\": true,\n        \"declaration\": false,\n        \"moduleResolution\": \"node\",\n        \"emitDecoratorMetadata\": true,\n        \"experimentalDecorators\": true,\n        \"importHelpers\": true,\n        \"target\": \"ES6\",\n        \"module\": \"ESNext\",\n        \"lib\": [\"es2017\", \"dom\", \"ESNext\"],\n        \"skipLibCheck\": true,\n        \"skipDefaultLibCheck\": true,\n        \"importsNotUsedAsValues\": \"error\",\n        \"baseUrl\": \".\",\n        \"paths\": {\n            \"@maybe-finance/client/features\": [\"libs/client/features/src/index.ts\"],\n            \"@maybe-finance/client/shared\": [\"libs/client/shared/src/index.ts\"],\n            \"@maybe-finance/design-system\": [\"libs/design-system/src/index.ts\"],\n            \"@maybe-finance/server/features\": [\"libs/server/features/src/index.ts\"],\n            \"@maybe-finance/server/shared\": [\"libs/server/shared/src/index.ts\"],\n            \"@maybe-finance/shared\": [\"libs/shared/src/index.ts\"],\n            \"@maybe-finance/teller-api\": [\"libs/teller-api/src/index.ts\"],\n            \"@maybe-finance/trpc\": [\"apps/server/src/app/trpc.ts\"]\n        }\n    },\n    \"exclude\": [\"node_modules\", \"tmp\"]\n}\n"
  },
  {
    "path": "vercel.json",
    "content": "{\n    \"github\": {\n        \"enabled\": true\n    }\n}\n"
  },
  {
    "path": "workspace.json",
    "content": "{\n    \"version\": 2,\n    \"projects\": {\n        \"client\": {\n            \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n            \"root\": \"apps/client\",\n            \"sourceRoot\": \"apps/client\",\n            \"projectType\": \"application\",\n            \"targets\": {\n                \"build\": {\n                    \"executor\": \"@nrwl/next:build\",\n                    \"outputs\": [\"{options.outputPath}\"],\n                    \"options\": {\n                        \"root\": \"apps/client\",\n                        \"outputPath\": \"dist/apps/client\"\n                    },\n                    \"configurations\": {\n                        \"production\": {},\n                        \"development\": {\n                            \"outputPath\": \"apps/client\"\n                        }\n                    },\n                    \"defaultConfiguration\": \"production\"\n                },\n                \"serve\": {\n                    \"executor\": \"@nrwl/next:server\",\n                    \"options\": {\n                        \"buildTarget\": \"client:build\",\n                        \"dev\": true\n                    },\n                    \"configurations\": {\n                        \"production\": {\n                            \"buildTarget\": \"client:build:production\",\n                            \"dev\": false\n                        },\n                        \"development\": {\n                            \"buildTarget\": \"client:build:development\",\n                            \"dev\": true\n                        }\n                    },\n                    \"defaultConfiguration\": \"development\"\n                },\n                \"export\": {\n                    \"executor\": \"@nrwl/next:export\",\n                    \"options\": {\n                        \"buildTarget\": \"client:build:production\"\n                    }\n                },\n                \"test\": {\n                    \"executor\": \"@nrwl/jest:jest\",\n                    \"outputs\": [\"coverage/apps/client\"],\n                    \"options\": {\n                        \"jestConfig\": \"apps/client/jest.config.ts\",\n                        \"passWithNoTests\": true\n                    }\n                },\n                \"lint\": {\n                    \"executor\": \"@nrwl/linter:eslint\",\n                    \"outputs\": [\"{options.outputFile}\"],\n                    \"options\": {\n                        \"lintFilePatterns\": [\"apps/client/**/*.{ts,tsx,js,jsx}\"]\n                    }\n                },\n                \"deploy\": {\n                    \"executor\": \"nx:run-commands\",\n                    \"options\": {\n                        \"command\": \"node tools/scripts/triggerClientDeploy.js\"\n                    }\n                },\n                \"storybook\": {\n                    \"executor\": \"@nrwl/storybook:storybook\",\n                    \"options\": {\n                        \"uiFramework\": \"@storybook/react\",\n                        \"port\": 4400,\n                        \"configDir\": \"apps/client/.storybook\"\n                    },\n                    \"configurations\": {\n                        \"ci\": {\n                            \"quiet\": true\n                        }\n                    }\n                },\n                \"build-storybook\": {\n                    \"executor\": \"@nrwl/storybook:build\",\n                    \"outputs\": [\"{options.outputDir}\"],\n                    \"options\": {\n                        \"uiFramework\": \"@storybook/react\",\n                        \"outputDir\": \"dist/storybook/client\",\n                        \"configDir\": \"apps/client/.storybook\"\n                    },\n                    \"configurations\": {\n                        \"ci\": {\n                            \"quiet\": true\n                        }\n                    }\n                }\n            },\n            \"tags\": [\"scope:app\"]\n        },\n        \"client-features\": {\n            \"$schema\": \"../../../node_modules/nx/schemas/project-schema.json\",\n            \"root\": \"libs/client/features\",\n            \"sourceRoot\": \"libs/client/features/src\",\n            \"projectType\": \"library\",\n            \"targets\": {\n                \"lint\": {\n                    \"executor\": \"@nrwl/linter:eslint\",\n                    \"outputs\": [\"{options.outputFile}\"],\n                    \"options\": {\n                        \"lintFilePatterns\": [\"libs/client/features/**/*.{ts,tsx,js,jsx}\"]\n                    }\n                },\n                \"test\": {\n                    \"executor\": \"@nrwl/jest:jest\",\n                    \"outputs\": [\"coverage/libs/client/features\"],\n                    \"options\": {\n                        \"jestConfig\": \"libs/client/features/jest.config.ts\",\n                        \"passWithNoTests\": true\n                    }\n                }\n            },\n            \"tags\": [\"scope:client\"]\n        },\n        \"client-shared\": {\n            \"$schema\": \"../../../node_modules/nx/schemas/project-schema.json\",\n            \"root\": \"libs/client/shared\",\n            \"sourceRoot\": \"libs/client/shared/src\",\n            \"projectType\": \"library\",\n            \"targets\": {\n                \"lint\": {\n                    \"executor\": \"@nrwl/linter:eslint\",\n                    \"outputs\": [\"{options.outputFile}\"],\n                    \"options\": {\n                        \"lintFilePatterns\": [\"libs/client/shared/**/*.{ts,tsx,js,jsx}\"]\n                    }\n                },\n                \"test\": {\n                    \"executor\": \"@nrwl/jest:jest\",\n                    \"outputs\": [\"coverage/libs/client/shared\"],\n                    \"options\": {\n                        \"jestConfig\": \"libs/client/shared/jest.config.ts\",\n                        \"passWithNoTests\": true\n                    }\n                }\n            },\n            \"tags\": [\"scope:client-shared\"]\n        },\n        \"design-system\": {\n            \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n            \"root\": \"libs/design-system\",\n            \"sourceRoot\": \"libs/design-system/src\",\n            \"projectType\": \"library\",\n            \"targets\": {\n                \"build\": {\n                    \"executor\": \"@nrwl/rollup:rollup\",\n                    \"outputs\": [\"{options.outputPath}\"],\n                    \"options\": {\n                        \"outputPath\": \"dist/libs/design-system\",\n                        \"tsConfig\": \"libs/design-system/tsconfig.lib.json\",\n                        \"project\": \"libs/design-system/package.json\",\n                        \"entryFile\": \"libs/design-system/src/index.ts\",\n                        \"external\": [\"react/jsx-runtime\"],\n                        \"rollupConfig\": \"@nrwl/react/plugins/bundle-rollup\",\n                        \"assets\": [\n                            {\n                                \"glob\": \"libs/design-system/README.md\",\n                                \"input\": \".\",\n                                \"output\": \".\"\n                            }\n                        ]\n                    },\n                    \"configurations\": {\n                        \"production\": {\n                            \"optimization\": true,\n                            \"extractLicenses\": true,\n                            \"inspect\": false\n                        }\n                    }\n                },\n                \"lint\": {\n                    \"executor\": \"@nrwl/linter:eslint\",\n                    \"outputs\": [\"{options.outputFile}\"],\n                    \"options\": {\n                        \"lintFilePatterns\": [\"libs/design-system/**/*.{ts,tsx,js,jsx}\"]\n                    }\n                },\n                \"test\": {\n                    \"executor\": \"@nrwl/jest:jest\",\n                    \"outputs\": [\"coverage/libs/design-system\"],\n                    \"options\": {\n                        \"jestConfig\": \"libs/design-system/jest.config.ts\",\n                        \"passWithNoTests\": true\n                    }\n                },\n                \"storybook\": {\n                    \"executor\": \"@nrwl/storybook:storybook\",\n                    \"options\": {\n                        \"uiFramework\": \"@storybook/react\",\n                        \"port\": 4400,\n                        \"staticDir\": [\"libs/design-system/.storybook/public\"],\n                        \"configDir\": \"libs/design-system/.storybook\",\n                        \"docs\": true\n                    },\n                    \"configurations\": {\n                        \"ci\": {\n                            \"quiet\": true\n                        }\n                    }\n                },\n                \"build-storybook\": {\n                    \"executor\": \"@nrwl/storybook:build\",\n                    \"outputs\": [\"{options.outputDir}\"],\n                    \"options\": {\n                        \"uiFramework\": \"@storybook/react\",\n                        \"staticDir\": [\"libs/design-system/.storybook/public\"],\n                        \"configDir\": \"libs/design-system/.storybook\",\n                        \"outputDir\": \"dist/storybook/design-system\",\n                        \"docs\": true\n                    },\n                    \"configurations\": {\n                        \"ci\": {\n                            \"quiet\": true\n                        }\n                    }\n                },\n                \"deploy\": {\n                    \"executor\": \"nx:run-commands\",\n                    \"options\": {\n                        \"command\": \"node tools/scripts/triggerDesignSystemDeploy.js\"\n                    }\n                }\n            },\n            \"tags\": [\"scope:client-shared\"]\n        },\n        \"e2e\": {\n            \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n            \"root\": \"apps/e2e\",\n            \"sourceRoot\": \"apps/e2e/src\",\n            \"projectType\": \"application\",\n            \"targets\": {\n                \"e2e\": {\n                    \"executor\": \"@nrwl/cypress:cypress\",\n                    \"options\": {\n                        \"cypressConfig\": \"apps/e2e/cypress.config.ts\",\n                        \"tsConfig\": \"apps/e2e/tsconfig.json\",\n                        \"testingType\": \"e2e\"\n                    }\n                },\n                \"lint\": {\n                    \"executor\": \"@nrwl/linter:eslint\",\n                    \"outputs\": [\"{options.outputFile}\"],\n                    \"options\": {\n                        \"lintFilePatterns\": [\"apps/e2e/**/*.{js,ts}\"]\n                    }\n                }\n            },\n            \"tags\": [\"scope:app\"],\n            \"implicitDependencies\": [\"client\", \"server\", \"workers\"]\n        },\n        \"server\": {\n            \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n            \"root\": \"apps/server\",\n            \"sourceRoot\": \"apps/server/src\",\n            \"projectType\": \"application\",\n            \"targets\": {\n                \"build\": {\n                    \"executor\": \"@nrwl/webpack:webpack\",\n                    \"outputs\": [\"{options.outputPath}\"],\n                    \"options\": {\n                        \"outputPath\": \"dist/apps/server\",\n                        \"main\": \"apps/server/src/main.ts\",\n                        \"tsConfig\": \"apps/server/tsconfig.app.json\",\n                        \"assets\": [\"apps/server/src/assets\", \"apps/server/src/app/admin/views\"],\n                        \"target\": \"node\",\n                        \"compiler\": \"tsc\",\n                        \"generatePackageJson\": true\n                    },\n                    \"configurations\": {\n                        \"production\": {\n                            \"optimization\": true,\n                            \"extractLicenses\": true,\n                            \"inspect\": false,\n                            \"fileReplacements\": [\n                                {\n                                    \"replace\": \"apps/server/src/environments/environment.ts\",\n                                    \"with\": \"apps/server/src/environments/environment.prod.ts\"\n                                }\n                            ]\n                        }\n                    }\n                },\n                \"serve\": {\n                    \"executor\": \"@nrwl/node:node\",\n                    \"options\": {\n                        \"buildTarget\": \"server:build\",\n                        \"inspect\": true,\n                        \"port\": 9228\n                    }\n                },\n                \"lint\": {\n                    \"executor\": \"@nrwl/linter:eslint\",\n                    \"outputs\": [\"{options.outputFile}\"],\n                    \"options\": {\n                        \"lintFilePatterns\": [\"apps/server/**/*.ts\"]\n                    }\n                },\n                \"test\": {\n                    \"executor\": \"@nrwl/jest:jest\",\n                    \"outputs\": [\"coverage/apps/server\"],\n                    \"options\": {\n                        \"jestConfig\": \"apps/server/jest.config.ts\",\n                        \"passWithNoTests\": true\n                    }\n                },\n                \"deploy\": {\n                    \"executor\": \"nx:run-commands\",\n                    \"options\": {\n                        \"command\": \"node tools/scripts/triggerServerDeploy.js\"\n                    }\n                }\n            },\n            \"tags\": [\"scope:app\"]\n        },\n        \"server-features\": {\n            \"$schema\": \"../../../node_modules/nx/schemas/project-schema.json\",\n            \"root\": \"libs/server/features\",\n            \"sourceRoot\": \"libs/server/features/src\",\n            \"projectType\": \"library\",\n            \"targets\": {\n                \"lint\": {\n                    \"executor\": \"@nrwl/linter:eslint\",\n                    \"outputs\": [\"{options.outputFile}\"],\n                    \"options\": {\n                        \"lintFilePatterns\": [\"libs/server/features/**/*.ts\"]\n                    }\n                },\n                \"test\": {\n                    \"executor\": \"@nrwl/jest:jest\",\n                    \"outputs\": [\"coverage/libs/server/features\"],\n                    \"options\": {\n                        \"jestConfig\": \"libs/server/features/jest.config.ts\",\n                        \"passWithNoTests\": true\n                    }\n                }\n            },\n            \"tags\": [\"scope:server\"]\n        },\n        \"server-shared\": {\n            \"$schema\": \"../../../node_modules/nx/schemas/project-schema.json\",\n            \"root\": \"libs/server/shared\",\n            \"sourceRoot\": \"libs/server/shared/src\",\n            \"projectType\": \"library\",\n            \"targets\": {\n                \"lint\": {\n                    \"executor\": \"@nrwl/linter:eslint\",\n                    \"outputs\": [\"{options.outputFile}\"],\n                    \"options\": {\n                        \"lintFilePatterns\": [\"libs/server/shared/**/*.ts\"]\n                    }\n                },\n                \"test\": {\n                    \"executor\": \"@nrwl/jest:jest\",\n                    \"outputs\": [\"coverage/libs/server/shared\"],\n                    \"options\": {\n                        \"jestConfig\": \"libs/server/shared/jest.config.ts\",\n                        \"passWithNoTests\": true\n                    }\n                }\n            },\n            \"tags\": [\"scope:server-shared\"]\n        },\n        \"shared\": {\n            \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n            \"root\": \"libs/shared\",\n            \"sourceRoot\": \"libs/shared/src\",\n            \"projectType\": \"library\",\n            \"targets\": {\n                \"lint\": {\n                    \"executor\": \"@nrwl/linter:eslint\",\n                    \"outputs\": [\"{options.outputFile}\"],\n                    \"options\": {\n                        \"lintFilePatterns\": [\"libs/shared/**/*.ts\"]\n                    }\n                },\n                \"test\": {\n                    \"executor\": \"@nrwl/jest:jest\",\n                    \"outputs\": [\"coverage/libs/shared\"],\n                    \"options\": {\n                        \"jestConfig\": \"libs/shared/jest.config.ts\",\n                        \"passWithNoTests\": true\n                    }\n                }\n            },\n            \"tags\": [\"scope:shared\"]\n        },\n        \"teller-api\": {\n            \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n            \"root\": \"libs/teller-api\",\n            \"sourceRoot\": \"libs/teller-api/src\",\n            \"projectType\": \"library\",\n            \"targets\": {\n                \"lint\": {\n                    \"executor\": \"@nrwl/linter:eslint\",\n                    \"outputs\": [\"{options.outputFile}\"],\n                    \"options\": {\n                        \"lintFilePatterns\": [\"libs/teller-api/**/*.ts\"]\n                    }\n                },\n                \"test\": {\n                    \"executor\": \"@nrwl/jest:jest\",\n                    \"outputs\": [\"/coverage/libs/teller-api\"],\n                    \"options\": {\n                        \"jestConfig\": \"libs/teller-api/jest.config.ts\",\n                        \"passWithNoTests\": true\n                    }\n                }\n            },\n            \"tags\": [\"scope:shared\"]\n        },\n        \"workers\": {\n            \"$schema\": \"../../node_modules/nx/schemas/project-schema.json\",\n            \"root\": \"apps/workers\",\n            \"sourceRoot\": \"apps/workers/src\",\n            \"projectType\": \"application\",\n            \"targets\": {\n                \"build\": {\n                    \"executor\": \"@nrwl/webpack:webpack\",\n                    \"outputs\": [\"{options.outputPath}\"],\n                    \"options\": {\n                        \"outputPath\": \"dist/apps/workers\",\n                        \"main\": \"apps/workers/src/main.ts\",\n                        \"tsConfig\": \"apps/workers/tsconfig.app.json\",\n                        \"assets\": [\"apps/workers/src/assets\"],\n                        \"target\": \"node\",\n                        \"compiler\": \"tsc\",\n                        \"generatePackageJson\": true\n                    },\n                    \"configurations\": {\n                        \"production\": {\n                            \"optimization\": true,\n                            \"extractLicenses\": true,\n                            \"inspect\": false,\n                            \"fileReplacements\": [\n                                {\n                                    \"replace\": \"apps/workers/src/environments/environment.ts\",\n                                    \"with\": \"apps/workers/src/environments/environment.prod.ts\"\n                                }\n                            ]\n                        }\n                    }\n                },\n                \"serve\": {\n                    \"executor\": \"@nrwl/node:node\",\n                    \"options\": {\n                        \"buildTarget\": \"workers:build\",\n                        \"inspect\": true,\n                        \"port\": 9227\n                    }\n                },\n                \"lint\": {\n                    \"executor\": \"@nrwl/linter:eslint\",\n                    \"outputs\": [\"{options.outputFile}\"],\n                    \"options\": {\n                        \"lintFilePatterns\": [\"apps/workers/**/*.ts\"]\n                    }\n                },\n                \"test\": {\n                    \"executor\": \"@nrwl/jest:jest\",\n                    \"outputs\": [\"coverage/apps/workers\"],\n                    \"options\": {\n                        \"jestConfig\": \"apps/workers/jest.config.ts\",\n                        \"passWithNoTests\": true\n                    }\n                },\n                \"deploy\": {\n                    \"executor\": \"nx:run-commands\",\n                    \"options\": {\n                        \"command\": \"node tools/scripts/triggerWorkersDeploy.js\"\n                    }\n                }\n            },\n            \"tags\": [\"scope:app\"]\n        }\n    },\n    \"$schema\": \"./node_modules/nx/schemas/workspace-schema.json\"\n}\n"
  }
]