[
  {
    "path": ".cursor/rules/cursor_rules.mdc",
    "content": "---\ndescription: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness.\nglobs: .cursor/rules/*.mdc\nalwaysApply: true\n---\n\n- **Required Rule Structure:**\n  ```markdown\n  ---\n  description: Clear, one-line description of what the rule enforces\n  globs: path/to/files/*.ext, other/path/**/*\n  alwaysApply: boolean\n  ---\n\n  - **Main Points in Bold**\n    - Sub-points with details\n    - Examples and explanations\n  ```\n\n- **File References:**\n  - Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files\n  - Example: [prisma.mdc](mdc:.cursor/rules/prisma.mdc) for rule references\n  - Example: [schema.prisma](mdc:prisma/schema.prisma) for code references\n\n- **Code Examples:**\n  - Use language-specific code blocks\n  ```typescript\n  // ✅ DO: Show good examples\n  const goodExample = true;\n  \n  // ❌ DON'T: Show anti-patterns\n  const badExample = false;\n  ```\n\n- **Rule Content Guidelines:**\n  - Start with high-level overview\n  - Include specific, actionable requirements\n  - Show examples of correct implementation\n  - Reference existing code when possible\n  - Keep rules DRY by referencing other rules\n\n- **Rule Maintenance:**\n  - Update rules when new patterns emerge\n  - Add examples from actual codebase\n  - Remove outdated patterns\n  - Cross-reference related rules\n\n- **Best Practices:**\n  - Use bullet points for clarity\n  - Keep descriptions concise\n  - Include both DO and DON'T examples\n  - Reference actual code over theoretical examples\n  - Use consistent formatting across rules "
  },
  {
    "path": ".cursor/rules/general-rules.mdc",
    "content": "---\ndescription: Miscellaneous rules to get the AI to behave\nglobs: *\nalwaysApply: true\n---\n# General rules for AI \n\n- Use `Current.user` for the current user. Do NOT use `current_user`.\n- Use `Current.family` for the current family. Do NOT use `current_family`.\n- Prior to generating any code, carefully read the project conventions and guidelines\n  - Read [project-design.mdc](mdc:.cursor/rules/project-design.mdc) to understand the codebase\n  - Read [project-conventions.mdc](mdc:.cursor/rules/project-conventions.mdc) to understand _how_ to write code for the codebase\n  - Read [ui-ux-design-guidelines.mdc](mdc:.cursor/rules/ui-ux-design-guidelines.mdc) to understand how to implement frontend code specifically\n- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development.\n\n## Prohibited actions\n\n- Do not run `rails server` in your responses.\n- Do not run `touch tmp/restart.txt`\n- Do not run `rails credentials`\n- Do not automatically run migrations"
  },
  {
    "path": ".cursor/rules/project-conventions.mdc",
    "content": "---\ndescription: \nglobs: \nalwaysApply: true\n---\nThis rule serves as high-level documentation for how you should write code for the Maybe codebase. \n\n## Project Tech Stack\n\n- Web framework: Ruby on Rails\n  - Minitest + fixtures for testing\n  - Propshaft for asset pipeline\n  - Hotwire Turbo/Stimulus for SPA-like UI/UX\n  - TailwindCSS for styles\n  - Lucide Icons for icons\n  - OpenAI for AI chat\n- Database: PostgreSQL\n- Jobs: Sidekiq + Redis\n- External\n  - Payments: Stripe\n  - User bank data syncing: Plaid \n  - Market data: Synth (our custom API)\n\n## Project conventions\n\nThese conventions should be used when writing code for Maybe.\n\n### Convention 1: Minimize dependencies, vanilla Rails is plenty\n\nDependencies are a natural part of building software, but we aim to minimize them when possible to keep this open-source codebase easy to understand, maintain, and contribute to.\n\n- Push Rails to its limits before adding new dependencies\n- When a new dependency is added, there must be a strong technical or business reason to add it\n- When adding dependencies, you should favor old and reliable over new and flashy \n\n### Convention 2: Leverage POROs and concerns over \"service objects\"\n\nThis codebase adopts a \"skinny controller, fat models\" convention.  Furthermore, we put almost _everything_ directly in the `app/models/` folder and avoid separate folders for business logic such as `app/services/`.\n\n- Organize large pieces of business logic into Rails concerns and POROs (Plain ole' Ruby Objects)\n- While a Rails concern _may_ offer shared functionality (i.e. \"duck types\"), it can also be a \"one-off\" concern that is only included in one place for better organization and readability.\n- When concerns are used for code organization, they should be organized around the \"traits\" of a model; not for simply moving code to another spot in the codebase.\n- When possible, models should answer questions about themselves—for example, we might have a method, `account.balance_series` that returns a time-series of the account's most recent balances.  We prefer this over something more service-like such as `AccountSeries.new(account).call`.\n\n### Convention 3: Leverage Hotwire, write semantic HTML, CSS, and JS, prefer server-side solutions\n\n- Native HTML is always preferred over JS-based components\n  - Example 1: Use `<dialog>` element for modals instead of creating a custom component\n  - Example 2: Use `<details><summary>...</summary></details>` for disclosures rather than custom components\n- Leverage Turbo frames to break up the page over JS-driven client-side solutions\n  - Example 1: A good example of turbo frame usage is in [application.html.erb](mdc:app/views/layouts/application.html.erb) where we load [chats_controller.rb](mdc:app/controllers/chats_controller.rb) actions in a turbo frame in the global layout\n- Leverage query params in the URL for state over local storage and sessions.  If absolutely necessary, utilize the DB for persistent state.\n- Use Turbo streams to enhance functionality, but do not solely depend on it\n- Format currencies, numbers, dates, and other values server-side, then pass to Stimulus controllers for display only\n- Keep client-side code for where it truly shines.  For example, @bulk_select_controller.js is a case where server-side solutions would degrade the user experience significantly.  When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this.\n- Always use the `icon` helper in [application_helper.rb](mdc:app/helpers/application_helper.rb) for icons.  NEVER use `lucide_icon` helper directly.\n\nThe Hotwire suite (Turbo/Stimulus) works very well with these native elements and we optimize for this.\n\n### Convention 4: Optimize for simplicitly and clarity\n\nAll code should maximize readability and simplicity.\n\n- Prioritize good OOP domain design over performance\n- Only focus on performance for critical and global areas of the codebase; otherwise, don't sweat the small stuff.\n  - Example 1: be mindful of loading large data payloads in global layouts\n  - Example 2: Avoid N+1 queries\n\n### Convention 5: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB\n\n- Enforce `null` checks, unique indexes, and other simple validations in the DB\n- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary.  These are for convenience when error handling in forms.  Always prefer client-side form validation when possible.\n- Complex validations and business logic should remain in ActiveRecord\n"
  },
  {
    "path": ".cursor/rules/project-design.mdc",
    "content": "---\ndescription: This rule explains the system architecture and data flow of the Rails app\nglobs: *\nalwaysApply: true\n---\n\nThis file outlines how the codebase is structured and how data flows through the app.\n\nThis is a personal finance application built in Ruby on Rails.  The primary domain entities for this app are outlined below.  For an authoritative overview of the relationships, [schema.rb](mdc:db/schema.rb) is the source of truth.\n\n## App Modes\n\nThe Maybe app runs in two distinct \"modes\", dictated by `Rails.application.config.app_mode`, which can be `managed` or `self_hosted`.\n\n- \"Managed\" - in managed mode, the Maybe team operates and manages servers for users\n- \"Self Hosted\" - in self hosted mode, users host the Maybe app on their own infrastructure, typically through Docker Compose.  We have an example [docker-compose.example.yml](mdc:docker-compose.example.yml) file that runs [Dockerfile](mdc:Dockerfile) for this mode.\n\n## Families and Users\n\n- `Family` - all Stripe subscriptions, financial accounts, and the majority of preferences are stored at the [family.rb](mdc:app/models/family.rb) level.\n- `User` - all [session.rb](mdc:app/models/session.rb) happen at the [user.rb](mdc:app/models/user.rb) level.  A user belongs to a `Family` and can either be an `admin` or a `member`.  Typically, a `Family` has a single admin, or \"head of household\" that manages finances while there will be several `member` users who can see the family's finances from varying perspectives.\n\n## Currency Preference\n\nEach `Family` selects a currency preference.  This becomes the \"main\" currency in which all records are \"normalized\" to via [exchange_rate.rb](mdc:app/models/exchange_rate.rb) records so that the Maybe app can calculate metrics, historical graphs, and other insights in a single family currency.\n\n## Accounts\n\nThe center of the app's domain is the [account.rb](mdc:app/models/account.rb).  This represents a single financial account that has a `balance` and `currency`.  For example, an `Account` could be \"Chase Checking\", which is a single financial account at Chase Bank.  A user could have multiple accounts at a single institution (i.e. \"Chase Checking\", \"Chase Credit Card\", \"Chase Savings\") or an account could be a standalone account, such as \"My Home\" (a primary residence).\n\n### Accountables\n\nIn the app, [account.rb](mdc:app/models/account.rb) is a Rails \"delegated type\" with the following subtypes (separate DB tables).  Each account has a `classification` or either `asset` or `liability`.  While the types are a flat hierarchy, below, they have been organized by their classification:\n\n- Asset accountables\n  - [depository.rb](mdc:app/models/depository.rb) - a typical \"bank account\" such as a savings or checking account\n  - [investment.rb](mdc:app/models/investment.rb) - an account that has \"holdings\" such as a brokerage, 401k, etc.\n  - [crypto.rb](mdc:app/models/crypto.rb) - an account that tracks the value of one or more crypto holdings\n  - [property.rb](mdc:app/models/property.rb) - an account that tracks the value of a physical property such as a house or rental property\n  - [vehicle.rb](mdc:app/models/vehicle.rb) - an account that tracks the value of a vehicle\n  - [other_asset.rb](mdc:app/models/other_asset.rb) - an asset that cannot be classified by the other account types.  For example, \"jewelry\".\n- Liability accountables\n  - [credit_card.rb](mdc:app/models/credit_card.rb) - an account that tracks the debt owed on a credit card\n  - [loan.rb](mdc:app/models/loan.rb) - an account that tracks the debt owed on a loan (i.e. mortgage, student loan)\n  - [other_liability.rb](mdc:app/models/other_liability.rb) - a liability that cannot be classified by the other account types.  For example, \"IOU to a friend\"\n\n### Account Balances\n\nAn account [balance.rb](mdc:app/models/account/balance.rb) represents a single balance value for an account on a specific `date`.  A series of balance records is generated daily for each account and is how we show a user's historical balance graph.  \n\n- For simple accounts like a \"Checking Account\", the balance represents the amount of cash in the account for a date.  \n- For a more complex account like \"Investment Brokerage\", the `balance` represents the combination of the \"cash balance\" + \"holdings value\".  Each accountable type has different components that make up the \"balance\", but in all cases, the \"balance\" represents \"How much the account is worth\" (when `classification` is `asset`) or \"How much is owed on the account\" (when `classification` is `liability`)\n\nAll balances are calculated daily by [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb).\n\n### Account Holdings\n\nAn account [holding.rb](mdc:app/models/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`.\n\nFor investment accounts with holdings, [base_calculator.rb](mdc:app/models/holding/base_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final \"Balance\" for the account in [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb).\n\n### Account Entries\n\nAn account [entry.rb](mdc:app/models/entry.rb) is also a Rails \"delegated type\".  `Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/holding.rb).  Therefore, every entry must have a `date`, `amount`, and `currency`.\n\nThe `amount` of an [entry.rb](mdc:app/models/entry.rb) is a signed value.  A _negative_ amount is an \"inflow\" of money to that account.  A _positive_ value is an \"outflow\" of money from that account.  For example:\n\n- A negative amount for a credit card account represents a \"payment\" to that account, which _reduces_ its balance (since it is a `liability`)\n- A negative amount for a checking account represents an \"income\" to that account, which _increases_ its balance (since it is an `asset`)\n- A negative amount for an investment/brokerage trade represents a \"sell\" transaction, which _increases_ the cash balance of the account \n\nThere are 3 entry types, defined as [entryable.rb](mdc:app/models/entryable.rb) records: \n\n- `Valuation` - an account [valuation.rb](mdc:app/models/valuation.rb) is an entry that says, \"here is the value of this account on this date\".  It is an absolute measure of an account value / debt.  If there is an `Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today.\n- `Transaction` - an account [transaction.rb](mdc:app/models/transaction.rb) is an entry that alters the account balance by the `amount`.  This is the most common type of entry and can be thought of as an \"income\" or \"expense\".  \n- `Trade` - an account [trade.rb](mdc:app/models/trade.rb) is an entry that only applies to an investment account.  This represents a \"buy\" or \"sell\" of a holding and has a `qty` and `price`.\n\n### Account Transfers\n\nA [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts.  A transfer has an inflow [transaction.rb](mdc:app/models/transaction.rb) and an outflow [transaction.rb](mdc:app/models/transaction.rb).  The Maybe system auto-matches transfers based on the following criteria:\n\n- Must be from different accounts\n- Must be within 4 days of each other\n- Must be the same currency\n- Must be opposite values\n\nThere are two primary forms of a transfer:\n\n- Regular transfer - a normal movement of money between two accounts.  For example, \"Transfer $500 from Checking account to Brokerage account\". \n- Debt payment - a special form of transfer where the _receiver_ of funds is a [loan.rb](mdc:app/models/loan.rb) type account.  \n\nRegular transfers are typically _excluded_ from income and expense calculations while a debt payment is considered an \"expense\".\n\n## Plaid Items\n\nA [plaid_item.rb](mdc:app/models/plaid_item.rb) represents a \"connection\" maintained by our external data provider, Plaid in the \"hosted\" mode of the app.  An \"Item\" has 1 or more [plaid_account.rb](mdc:app/models/plaid_account.rb) records, which are each associated 1:1 with an internal Maybe [account.rb](mdc:app/models/account.rb).\n\nAll relevant metadata about the item and its underlying accounts are stored on [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb), while the \"normalized\" data is then stored on internal Maybe domain models.\n\n## \"Syncs\"\n\nThe Maybe app has the concept of a [syncable.rb](mdc:app/models/concerns/syncable.rb), which represents any model which can have its data \"synced\" in the background.  \"Syncables\" include:\n\n- `Account` - an account \"sync\" will sync account holdings, balances, and enhance transaction metadata\n- `PlaidItem` - a Plaid Item \"sync\" fetches data from Plaid APIs, normalizes that data, stores it on internal Maybe models, and then finally performs an \"Account sync\" for each of the underlying accounts created from the Plaid Item.\n- `Family` - a Family \"sync\" loops through the family's Plaid Items and individual Accounts and \"syncs\" each of them.  A family is synced once per day, automatically through [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb).\n\nEach \"sync\" creates a [sync.rb](mdc:app/models/sync.rb) record in the database, which keeps track of the status of the sync, any errors that it encounters, and acts as an \"audit table\" for synced data.\n\nBelow are brief descriptions of each type of sync in more detail.\n\n### Account Syncs\n\nThe most important type of sync is the account sync.  It is orchestrated by the account's `sync_data` method, which performs a few important tasks:\n\n- Auto-matches transfer records for the account\n- Calculates daily [balance.rb](mdc:app/models/account/balance.rb) records for the account from `account.start_date` to `Date.current` using [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb)\n  - Balances are dependent on the calculation of [holding.rb](mdc:app/models/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb) \n- Enriches transaction data if enabled by user\n\nAn account sync happens every time an [entry.rb](mdc:app/models/entry.rb) is updated.\n\n### Plaid Item Syncs\n\nA Plaid Item sync is an ETL (extract, transform, load) operation:\n\n1. [plaid_item.rb](mdc:app/models/plaid_item.rb) fetches data from the external Plaid API\n2. [plaid_item.rb](mdc:app/models/plaid_item.rb) creates and loads this data to [plaid_account.rb](mdc:app/models/plaid_account.rb) records\n3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/entry.rb), the internal Maybe representations of the data.\n\n### Family Syncs\n\nA family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb).  A family sync is an \"orchestrator\" of Account and Plaid Item syncs.\n\n## Data Providers\n\nThe Maybe app utilizes several 3rd party data services to calculate historical account balances, enrich data, and more.  Since the app can be run in both \"hosted\" and \"self hosted\" mode, this means that data providers are _optional_ for self hosted users and must be configured.\n\nBecause of this optionality, data providers must be configured at _runtime_ through [registry.rb](mdc:app/models/provider/registry.rb) utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys:\n\nThere are two types of 3rd party data in the Maybe app:\n\n1. \"Concept\" data\n2. One-off data\n\n### \"Concept\" data\n\nSince the app is self hostable, users may prefer using different providers for generic data like exchange rates and security prices.  When data is generic enough where we can easily swap out different providers, we call it a data \"concept\".\n\nEach \"concept\" has an interface defined in the `app/models/provider/concepts` directory.\n\n```\napp/models/\n  exchange_rate/\n    provided.rb # <- Responsible for selecting the concept provider from the registry\n  provider.rb # <- Base provider class\n  provider/\n    registry.rb <- Defines available providers by concept\n    concepts/\n      exchange_rate.rb <- defines the interface required for the exchange rate concept\n    synth.rb # <- Concrete provider implementation\n```\n\n### One-off data\n\nFor data that does not fit neatly into a \"concept\", an interface is not required and the concrete provider may implement ad-hoc methods called directly in code.  For example, the [synth.rb](mdc:app/models/provider/synth.rb) provider has a `usage` method that is only applicable to this specific provider.  This should be called directly without any abstractions:\n\n```rb\nclass SomeModel < Application\n  def synth_usage\n    Provider::Registry.get_provider(:synth)&.usage\n  end\nend\n```\n\n## \"Provided\" Concerns\n\nIn general, domain models should not be calling [registry.rb](mdc:app/models/provider/registry.rb) directly.  When 3rd party data is required for a domain model, we use the `Provided` concern within that model's namespace.  This concern is primarily responsible for:\n\n- Choosing the provider to use for this \"concept\"\n- Providing convenience methods on the model for accessing data\n\nFor example, [exchange_rate.rb](mdc:app/models/exchange_rate.rb) has a [provided.rb](mdc:app/models/exchange_rate/provided.rb) concern with the following convenience methods:\n\n```rb\nmodule ExchangeRate::Provided\n  extend ActiveSupport::Concern\n\n  class_methods do\n    def provider\n      registry = Provider::Registry.for_concept(:exchange_rates)\n      registry.get_provider(:synth)\n    end\n\n    def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)\n      # Implementation \n    end\n\n    def sync_provider_rates(from:, to:, start_date:, end_date: Date.current)\n      # Implementation \n    end\n  end\nend\n```\n\nThis exposes a generic access pattern where the caller does not care _which_ provider has been chosen for the concept of exchange rates and can get a predictable response:\n\n```rb\ndef access_patterns_example\n  # Call exchange rate provider directly\n  ExchangeRate.provider.fetch_exchange_rate(from: \"USD\", to: \"CAD\", date: Date.current)\n\n  # Call convenience method\n  ExchangeRate.sync_provider_rates(from: \"USD\", to: \"CAD\", start_date: 2.days.ago.to_date)\nend\n```\n\n## Concrete provider implementations\n\nEach 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `with_provider_response`, which will return a `Provider::ProviderResponse` object:\n\n```rb\nclass ConcreteProvider < Provider\n  def fetch_some_data\n    with_provider_response do\n      ExampleData.new(\n        example: \"data\"\n      )\n    end\n  end\nend\n```\n\nThe `with_provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible:\n\n```rb\nclass ConcreteProvider < Provider\n  def fetch_some_data\n    with_provider_response do\n      data = nil\n\n      # Raise an error if data cannot be returned\n      raise ProviderError.new(\"Could not find the data you need\") if data.nil?\n\n      data\n    end\n  end\nend\n```\n"
  },
  {
    "path": ".cursor/rules/self_improve.mdc",
    "content": "---\ndescription: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices.\nglobs: **/*\nalwaysApply: true\n---\n\n- **Rule Improvement Triggers:**\n  - New code patterns not covered by existing rules\n  - Repeated similar implementations across files\n  - Common error patterns that could be prevented\n  - New libraries or tools being used consistently\n  - Emerging best practices in the codebase\n\n- **Analysis Process:**\n  - Compare new code with existing rules\n  - Identify patterns that should be standardized\n  - Look for references to external documentation\n  - Check for consistent error handling patterns\n  - Monitor test patterns and coverage\n\n- **Rule Updates:**\n  - **Add New Rules When:**\n    - A new technology/pattern is used in 3+ files\n    - Common bugs could be prevented by a rule\n    - Code reviews repeatedly mention the same feedback\n    - New security or performance patterns emerge\n\n  - **Modify Existing Rules When:**\n    - Better examples exist in the codebase\n    - Additional edge cases are discovered\n    - Related rules have been updated\n    - Implementation details have changed\n\n- **Example Pattern Recognition:**\n  ```typescript\n  // If you see repeated patterns like:\n  const data = await prisma.user.findMany({\n    select: { id: true, email: true },\n    where: { status: 'ACTIVE' }\n  });\n  \n  // Consider adding to [prisma.mdc](mdc:.cursor/rules/prisma.mdc):\n  // - Standard select fields\n  // - Common where conditions\n  // - Performance optimization patterns\n  ```\n\n- **Rule Quality Checks:**\n  - Rules should be actionable and specific\n  - Examples should come from actual code\n  - References should be up to date\n  - Patterns should be consistently enforced\n\n- **Continuous Improvement:**\n  - Monitor code review comments\n  - Track common development questions\n  - Update rules after major refactors\n  - Add links to relevant documentation\n  - Cross-reference related rules\n\n- **Rule Deprecation:**\n  - Mark outdated patterns as deprecated\n  - Remove rules that no longer apply\n  - Update references to deprecated rules\n  - Document migration paths for old patterns\n\n- **Documentation Updates:**\n  - Keep examples synchronized with code\n  - Update references to external docs\n  - Maintain links between related rules\n  - Document breaking changes\nFollow [cursor_rules.mdc](mdc:.cursor/rules/cursor_rules.mdc) for proper rule formatting and structure.\n"
  },
  {
    "path": ".cursor/rules/stimulus_conventions.mdc",
    "content": "---\ndescription: \nglobs: \nalwaysApply: false\n---\nThis rule describes how to write Stimulus controllers.\n\n- **Use declarative actions, not imperative event listeners**\n  - Instead of assigning a Stimulus target and binding it to an event listener in the initializer, always write Controllers + ERB views declaratively by using Stimulus actions in ERB to call methods in the Stimulus JS controller.  Below are good vs. bad code.\n\n  BAD code:\n\n  ```js\n  // BAD!!!! DO NOT DO THIS!!\n  // Imperative - controller does all the work\n  export default class extends Controller {\n    static targets = [\"button\", \"content\"]\n\n    connect() {\n      this.buttonTarget.addEventListener(\"click\", this.toggle.bind(this))\n    }\n\n    toggle() {\n      this.contentTarget.classList.toggle(\"hidden\")\n      this.buttonTarget.textContent = this.contentTarget.classList.contains(\"hidden\") ? \"Show\" : \"Hide\"\n    }\n  }\n  ```\n\n  GOOD code:\n\n  ```erb\n  <!-- Declarative - HTML declares what happens -->\n\n  <div data-controller=\"toggle\">\n    <button data-action=\"click->toggle#toggle\" data-toggle-target=\"button\">Show</button>\n    <div data-toggle-target=\"content\" class=\"hidden\">Hello World!</div>\n  </div>\n  ```\n\n  ```js\n  // Declarative - controller just responds\n  export default class extends Controller {\n    static targets = [\"button\", \"content\"]\n\n    toggle() {\n      this.contentTarget.classList.toggle(\"hidden\")\n      this.buttonTarget.textContent = this.contentTarget.classList.contains(\"hidden\") ? \"Show\" : \"Hide\"\n    }\n  }\n  ```\n\n- **Keep Stimulus controllers lightweight and simple**\n  - Always aim for less than 7 controller targets. Any more is a sign of too much complexity.\n  - Use private methods and expose a clear public API\n\n- **Keep Stimulus controllers focused on what they do best**\n  - Domain logic does NOT belong in a Stimulus controller\n  - Stimulus controllers should aim for a single responsibility, or a group of highly related responsibilities\n  - Make good use of Stimulus's callbacks, actions, targets, values, and classes\n\n- **Component controllers should not be used outside the component**\n  - If a Stimulus controller is in the app/components directory, it should only be used in its component view. It should not be used anywhere in app/views.\n\n"
  },
  {
    "path": ".cursor/rules/testing.mdc",
    "content": "---\ndescription: \nglobs: test/**\nalwaysApply: false\n---\nUse this rule to learn how to write tests for the Maybe codebase.\n\nDue to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.\n\n- **General testing rules**\n  - Always use Minitest and fixtures for testing, NEVER rspec or factories\n  - Keep fixtures to a minimum.  Most models should have 2-3 fixtures maximum that represent the \"base cases\" for that model.  \"Edge cases\" should be created on the fly, within the context of the test which it is needed.\n  - For tests that require a large number of fixture records to be created, use Rails helpers to help create the records needed for the test, then inline the creation. For example, [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) provides helpers to easily do this.\n\n- **Write minimal, effective tests**\n  - Use system tests sparingly as they increase the time to complete the test suite\n  - Only write tests for critical and important code paths\n  - Write tests as you go, when required\n  - Take a practical approach to testing.  Tests are effective when their presence _significantly increases confidence in the codebase_.\n\n  Below are examples of necessary vs. unnecessary tests:\n\n  ```rb\n  # GOOD!!\n  # Necessary test - in this case, we're testing critical domain business logic\n  test \"syncs balances\" do\n    Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once\n\n    @account.expects(:start_date).returns(2.days.ago.to_date)\n\n    Balance::ForwardCalculator.any_instance.expects(:calculate).returns(\n      [\n        Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: \"USD\"),\n        Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: \"USD\")\n      ]\n    )\n\n    assert_difference \"@account.balances.count\", 2 do\n      Balance::Syncer.new(@account, strategy: :forward).sync_balances\n    end\n  end\n\n  # BAD!!\n  # Unnecessary test - in this case, this is simply testing ActiveRecord's functionality\n  test \"saves balance\" do \n    balance_record = Balance.new(balance: 100, currency: \"USD\")\n\n    assert balance_record.save\n  end\n  ```\n\n- **Test boundaries correctly**\n  - Distinguish between commands and query methods. Test output of query methods; test that commands were called with the correct params. See an example below:\n\n  ```rb\n  class ExampleClass\n    def do_something\n      result = 2 + 2\n\n      CustomEventProcessor.process_result(result)\n\n      result\n    end\n  end\n\n  class ExampleClass < ActiveSupport::TestCase\n    test \"boundaries are tested correctly\" do \n      result = ExampleClass.new.do_something\n\n      # GOOD - we're only testing that the command was received, not internal implementation details\n      # The actual tests for CustomEventProcessor belong in a different test suite!\n      CustomEventProcessor.expects(:process_result).with(4).once\n\n      # GOOD - we're testing the implementation of ExampleClass inside its own test suite\n      assert_equal 4, result\n    end\n  end\n  ```\n\n  - Never test the implementation details of one class in another classes test suite\n\n- **Stubs and mocks**\n  - Use `mocha` gem \n  - Always prefer `OpenStruct` when creating mock instances, or in complex cases, a mock class\n  - Only mock what's necessary. If you're not testing return values, don't mock a return value.\n\n\n"
  },
  {
    "path": ".cursor/rules/ui-ux-design-guidelines.mdc",
    "content": "---\ndescription: This file describes Maybe's design system and how views should be styled\nglobs: app/views/**,app/helpers/**,app/javascript/controllers/**\nalwaysApply: true\n---\nUse the rules below when:\n\n- You are writing HTML\n- You are writing CSS\n- You are writing styles in a JavaScript Stimulus controller\n\n## Rules for AI (mandatory)\n\nThe codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css)\n\n- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase\n- Always prefer using the functional \"tokens\" defined in @maybe-design-system.css when possible.\n  - Example 1: use `text-primary` rather than `text-white`\n  - Example 2: use `bg-container` rather than `bg-white`\n  - Example 3: use `border border-primary` rather than `border border-gray-200` \n- Never create new styles in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so\n- Always generate semantic HTML\n"
  },
  {
    "path": ".cursor/rules/view_conventions.mdc",
    "content": "---\ndescription: \nglobs: app/views/**,app/javascript/**,app/components/**/*.js\nalwaysApply: false\n---\nUse this rule to learn how to write ERB views, partials, and Stimulus controllers should be incorporated into them.\n\n- **Component vs. Partial Decision Making**\n  - **Use ViewComponents when:**\n    - Element has complex logic or styling patterns\n    - Element will be reused across multiple views/contexts\n    - Element needs structured styling with variants/sizes (like buttons, badges)\n    - Element requires interactive behavior or Stimulus controllers\n    - Element has configurable slots or complex APIs\n    - Element needs accessibility features or ARIA support\n  \n  - **Use Partials when:**\n    - Element is primarily static HTML with minimal logic\n    - Element is used in only one or few specific contexts\n    - Element is simple template content (like CTAs, static sections)\n    - Element doesn't need variants, sizes, or complex configuration\n    - Element is more about content organization than reusable functionality\n\n- **Prefer components over partials**\n  - If there is a component available for the use case in app/components, use it\n  - If there is no component, look for a partial\n  - If there is no partial, decide between component or partial based on the criteria above\n\n- **Examples of Component vs. Partial Usage**\n  ```erb\n  <%# Component: Complex, reusable with variants and interactivity %>\n  <%= render DialogComponent.new(variant: :drawer) do |dialog| %>\n    <% dialog.with_header(title: \"Account Settings\") %>\n    <% dialog.with_body { \"Dialog content here\" } %>\n  <% end %>\n  \n  <%# Component: Interactive with complex styling options %>\n  <%= render ButtonComponent.new(text: \"Save Changes\", variant: \"primary\", confirm: \"Are you sure?\") %>\n  \n  <%# Component: Reusable with variants %>\n  <%= render FilledIconComponent.new(icon: \"credit-card\", variant: :surface) %>\n  \n  <%# Partial: Static template content %>\n  <%= render \"shared/logo\" %>\n  \n  <%# Partial: Simple, context-specific content with basic styling %>\n  <%= render \"shared/trend_change\", trend: @account.trend, comparison_label: \"vs last month\" %>\n  \n  <%# Partial: Simple divider/utility %>\n  <%= render \"shared/ruler\", classes: \"my-4\" %>\n  \n  <%# Partial: Simple form utility %>\n  <%= render \"shared/form_errors\", model: @account %>\n  ```\n\n- **Keep domain logic out of the views**\n   ```erb\n    <%# BAD!!! %>\n\n    <%# This belongs in the component file, not the template file! %>\n    <% button_classes = { class: \"bg-blue-500 hover:bg-blue-600\" } %>\n\n    <%= tag.button class: button_classes do %>\n      Save Account\n    <% end %>\n\n    <%# GOOD! %>\n\n    <%= tag.button class: computed_button_classes do %>\n      Save Account\n    <% end %>\n    ```\n\n- **Stimulus Integration in Views**\n  - Always use the **declarative approach** when integrating Stimulus controllers\n  - The ERB template should declare what happens, the Stimulus controller should respond\n  - Refer to [stimulus_conventions.mdc](mdc:.cursor/rules/stimulus_conventions.mdc) to learn how to incorporate them into \n\n  GOOD Stimulus controller integration into views:\n\n  ```erb\n  <!-- Declarative - HTML declares what happens -->\n\n  <div data-controller=\"toggle\">\n    <button data-action=\"click->toggle#toggle\" data-toggle-target=\"button\">Show</button>\n    <div data-toggle-target=\"content\" class=\"hidden\">Hello World!</div>\n  </div>\n  ```\n\n- **Stimulus Controller Placement Guidelines**\n  - **Component controllers** (in `app/components/`) should only be used within their component templates\n  - **Global controllers** (in `app/javascript/controllers/`) can be used across any view\n  - Pass data from Rails to Stimulus using `data-*-value` attributes, not inline JavaScript\n  - Use Stimulus targets to reference DOM elements, not manual `getElementById` calls\n\n- **Naming Conventions**\n  - **Components**: Use `ComponentName` suffix (e.g., `ButtonComponent`, `DialogComponent`, `FilledIconComponent`)\n  - **Partials**: Use underscore prefix (e.g., `_trend_change.html.erb`, `_form_errors.html.erb`, `_sync_indicator.html.erb`)\n  - **Shared partials**: Place in `app/views/shared/` directory for reusable content\n  - **Context-specific partials**: Place in relevant controller view directory (e.g., `accounts/_account_sidebar_tabs.html.erb`)"
  },
  {
    "path": ".devcontainer/Dockerfile",
    "content": "ARG RUBY_VERSION=3.4.4\nFROM ruby:${RUBY_VERSION}-slim-bullseye\n\nRUN apt-get update && export DEBIAN_FRONTEND=noninteractive \\\n  && apt-get -y install --no-install-recommends \\\n  apt-utils \\\n  build-essential \\\n  curl \\\n  git \\\n  imagemagick \\\n  iproute2 \\\n  libpq-dev \\\n  libyaml-dev \\\n  libyaml-0-2 \\\n  openssh-client \\\n  postgresql-client \\\n  vim\n\nRUN gem install bundler\nRUN gem install foreman\n\n# Install Node.js 20\nRUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\\n&& apt-get install -y nodejs\n\nWORKDIR /workspace\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "{\n  \"name\": \"Maybe\",\n  \"dockerComposeFile\": \"docker-compose.yml\",\n  \"service\": \"app\",\n  \"workspaceFolder\": \"/workspace\",\n  \"containerEnv\": {\n    \"GITHUB_TOKEN\": \"${localEnv:GITHUB_TOKEN}\",\n    \"GITHUB_USER\": \"${localEnv:GITHUB_USER}\"\n  },\n  \"remoteEnv\": {\n    \"PATH\": \"/workspace/bin:${containerEnv:PATH}\"\n  },\n  \"postCreateCommand\": \"bundle install && npm install\",\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\n        \"biomejs.biome\",\n        \"EditorConfig.EditorConfig\"\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": ".devcontainer/docker-compose.yml",
    "content": "x-db-env: &db_env\n  POSTGRES_USER: postgres\n  POSTGRES_DB: postgres\n  POSTGRES_PASSWORD: postgres\n\nx-rails-env: &rails_env\n  DB_HOST: db\n  HOST: \"0.0.0.0\"\n  POSTGRES_USER: postgres\n  POSTGRES_PASSWORD: postgres\n  BUNDLE_PATH: /bundle\n  REDIS_URL: redis://redis:6379/1\n\nservices:\n  app:\n    build:\n      context: ..\n      dockerfile: .devcontainer/Dockerfile\n\n    volumes:\n      - ..:/workspace:cached\n      - bundle_cache:/bundle\n\n    ports:\n      - \"3000:3000\"\n\n    command: sleep infinity\n\n    environment:\n      <<: *rails_env\n\n    depends_on:\n      - db\n      - redis\n\n  worker:\n    build:\n      context: ..\n      dockerfile: .devcontainer/Dockerfile\n    command: bundle exec sidekiq\n    restart: unless-stopped\n    environment:\n      <<: *rails_env\n    depends_on:\n      - redis\n\n  redis:\n    image: redis:latest\n    ports:\n      - \"6379:6379\"\n    restart: unless-stopped\n    volumes:\n      - redis-data:/data\n  db:\n    image: postgres:latest\n    restart: unless-stopped\n    volumes:\n      - postgres-data:/var/lib/postgresql/data\n    environment:\n      <<: *db_env\n    ports:\n      - \"5432:5432\"\n\nvolumes:\n  postgres-data:\n  redis-data:\n  bundle_cache:\n"
  },
  {
    "path": ".dockerignore",
    "content": "# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files.\n\n# Ignore git directory.\n/.git/\n/.gitignore\n\n# Ignore bundler config.\n/.bundle\n\n# Ignore all environment files (except templates).\n/.env*\n!/.env*.erb\n\n# Ignore all default key files.\n/config/master.key\n/config/credentials/*.key\n\n# Ignore all logfiles and tempfiles.\n/log/*\n/tmp/*\n!/log/.keep\n!/tmp/.keep\n\n# Ignore pidfiles, but keep the directory.\n/tmp/pids/*\n!/tmp/pids/.keep\n\n# Ignore storage (uploaded files in development and any SQLite databases).\n/storage/*\n!/storage/.keep\n/tmp/storage/*\n!/tmp/storage/.keep\n\n# Ignore assets.\n/node_modules/\n/app/assets/builds/*\n!/app/assets/builds/.keep\n/public/assets\n\n# Ignore CI service files.\n/.github\n\n# Ignore Docker-related files\n/.dockerignore\n/Dockerfile*\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_style = space\nindent_size = 2"
  },
  {
    "path": ".env.example",
    "content": "# ================================ PLEASE READ ===========================================================\n# This file outlines all the possible environment variables supported by the Maybe app for self hosting.\n#\n# If you're a developer setting up your local environment, please use `.env.local.example` instead.\n# ========================================================================================================\n\n# Required self-hosting vars\n# --------------------------------------------------------------------------------------------------------\n\n# Enables self hosting features (should be set to true unless you know what you're doing)\nSELF_HOSTED=true\n\n# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)\n# Has to be a random string, generated eg. by running `openssl rand -hex 64`\nSECRET_KEY_BASE=secret-value\n\n# Optional self-hosting vars\n# --------------------------------------------------------------------------------------------------------\n\n# Optional: Synth API Key for exchange rates + stock prices\n# (you can also set this in your self-hosted settings page)\n# Get it here: https://synthfinance.com/\nSYNTH_API_KEY=\n\n# Custom port config\n# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.\nPORT=3000\n\n# SMTP Configuration\n# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).\n# Resend.com is a good option that offers a free tier for sending emails.\nSMTP_ADDRESS=\nSMTP_PORT=465\nSMTP_USERNAME=\nSMTP_PASSWORD=\nSMTP_TLS_ENABLED=true\n\n# Address that emails are sent from\nEMAIL_SENDER=\n\n# Database Configuration\nDB_HOST=localhost # May need to be changed to `DB_HOST=db` if using devcontainer\nDB_PORT=5432\nPOSTGRES_PASSWORD=postgres\nPOSTGRES_USER=postgres\n\n# App Domain\n# This is the domain that your Maybe instance will be hosted at. It is used to generate links in emails and other places.\nAPP_DOMAIN=\n\n# Disable enforcing SSL connections\n# DISABLE_SSL=true\n\n# Active Record Encryption Keys (Optional)\n# These keys are used to encrypt sensitive data like API keys in the database.\n# If not provided, they will be automatically generated based on your SECRET_KEY_BASE.\n# You can generate your own keys by running: rails db:encryption:init\n# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=\n# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=\n# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=\n\n# ======================================================================================================\n# Active Storage Configuration - responsible for storing file uploads\n# ======================================================================================================\n#\n# * Defaults to disk storage but you can also use Amazon S3 or Cloudflare R2\n# * Set the appropriate environment variables to use these services.\n# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips\n#\n# Amazon S3\n# ==========\n# ACTIVE_STORAGE_SERVICE=amazon <- Enables Amazon S3 storage\n# S3_ACCESS_KEY_ID=\n# S3_SECRET_ACCESS_KEY=\n# S3_REGION= # defaults to `us-east-1` if not set\n# S3_BUCKET=\n#\n# Cloudflare R2\n# =============\n# ACTIVE_STORAGE_SERVICE=cloudflare <- Enables Cloudflare R2 storage\n# CLOUDFLARE_ACCOUNT_ID=\n# CLOUDFLARE_ACCESS_KEY_ID=\n# CLOUDFLARE_SECRET_ACCESS_KEY=\n# CLOUDFLARE_BUCKET=\n#\n"
  },
  {
    "path": ".env.local.example",
    "content": "# To enable / disable self-hosting features.\nSELF_HOSTED=false\n\n# Enable Synth market data (careful, this will use your API credits)\nSYNTH_API_KEY=yourapikeyhere\n"
  },
  {
    "path": ".env.test.example",
    "content": "SELF_HOSTED=false\n\n# ================\n# Data Providers\n# ---------------------------------------------------------------------------------\n# Uncomment and fill in live keys when you need to generate a VCR cassette fixture\n# ================\n\n# SYNTH_API_KEY=<add live key here>\n\n# ================\n# Miscellaneous\n# ================\n\n# Set to true if you want SimpleCov reports generated\nCOVERAGE=false\n\n# Set to true to run test suite serially\nDISABLE_PARALLELIZATION=false"
  },
  {
    "path": ".erb_lint.yml",
    "content": "EnableDefaultLinters: true\nlinters:\n  Rubocop:\n    enabled: true\n    only: [Style/StringLiterals]\n    rubocop_config:\n      Style/StringLiterals:\n        Enabled: true\n        EnforcedStyle: double_quotes"
  },
  {
    "path": ".gitattributes",
    "content": "# See https://git-scm.com/docs/gitattributes for more about git attribute files.\n\n# Mark the database schema as having been generated.\ndb/schema.rb linguist-generated\n\n# Mark any vendored files as having been vendored.\nvendor/* linguist-vendored\nconfig/credentials/*.yml.enc diff=rails_credentials\nconfig/credentials.yml.enc diff=rails_credentials\n"
  },
  {
    "path": ".github/DISCUSSION_TEMPLATE/feature-requests.yml",
    "content": "title: Feature Request\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for your interest in Maybe!  Please follow the template below to submit your feature request.  You can visit our [roadmap](https://github.com/orgs/maybe-finance/projects/13) to see what's currently planned.\n  - type: textarea\n    attributes:\n      label: Describe the feature\n      description: Provide a clear and concise description of the feature you would like.\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Why is this feature important?\n      description: Tell us what specific problem(s) this feature solves for you or other users.\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Additional context, screenshots, and relevant links\n      description: Provide additional info to help us evaluate whether this feature is a good fit for the product.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Open a bug report when you experience broken functionality within the latest\n  version of the Maybe app\ntitle: 'Bug: [Add descriptive title here]'\nlabels: ''\nassignees: ''\n\n---\n\n## Before you start (required)\n\n### General checklist\n\n- [ ] I have removed personal / sensitive data from screenshots and logs\n- [ ] I have searched [existing issues](https://github.com/maybe-finance/maybe/issues?q=is:issue) and [discussions](https://github.com/maybe-finance/maybe/discussions) to ensure this is not a duplicate issue\n    \n### How are you using Maybe?\n\n- [ ] I am a paying Maybe customer (hosted version)\n  - Paying Maybe users can also open requests in Intercom (if there is sensitive info involved)\n- [ ] I am a self-hosted user\n\n### Self hoster checklist\n\n_Paying, hosted users should delete this entire section._\n\nIf you are a self-hosted user, please complete all of the information below.  Issues with incomplete information will be marked as `Needs Info` to help our small team prioritize bug fixes.\n\n- Self hosted app commit SHA (find in user menu): [enter commit sha here]\n  - [ ] I have confirmed that my app's commit is the latest version of Maybe\n- Where are you hosting?\n  - [ ] Render\n  - [ ] Docker Compose\n  - [ ] Umbrel\n  - [ ] Other (please specify)\n\n---\n\n## Bug description\n\nA clear and concise description of what the bug is.\n\n### To Reproduce\n\nBe as specific as possible so Maybe maintainers can quickly reproduce the bug you're experiencing.\n\nSteps to reproduce the behavior:\n\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n### Expected behavior\n\nWhat is the intended behavior that you would expect?\n\n### Screenshots and/or recordings\n\nWe highly recommend providing additional context with screenshots and/or screen recordings.  This will _significantly_ improve the chances of the bug being addressed and fixed quickly.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/other.md",
    "content": "---\nname: Other\nabout: All other issues\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n## Before you start (required)\n\n### Is this a bug?\n\nA bug is _broken functionality_ of the app (i.e. it prevents you from using the app).  For bugs, please use the [\"Bug Report\" template](https://github.com/maybe-finance/maybe/issues) instead.\n\n### Is this a bug with _sensitive info_?\n\nIf you are a _paying_ Maybe user, you can open a support request in Intercom.\n\n### Is this a feature request?\n\nA feature request is functionality that you would like that is not already on our [Roadmap](https://github.com/maybe-finance/maybe/wiki/Roadmap).\n\nAll feature requests should be opened in a [Feature request Discussion](https://github.com/maybe-finance/maybe/discussions/categories/feature-requests).\n\nBe sure to search existing discussions prior to opening a new feature request.\n\n### Is this related to Docker and/or hosting for self hosting?\n\nIf you are having a Docker configuration issue, please do not open a Github issue unless you've identified a bug in our Dockerfile.  To get help with self hosting, there are several options:\n\n- **First**: Read our [Docker hosting guide](https://github.com/maybe-finance/maybe/tree/main/docs/hosting/docker.md) and follow it step-by-step\n- Open a [Docker Discussion](https://github.com/maybe-finance/maybe/discussions/categories/docker-compose-hosting)\n\n---\n\n## Issue description\n\nIf your issue does not fall into the categories above, please provide a **descriptive and complete** overview of your issue.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: bundler\n    directory: \"/\"\n    schedule:\n      interval: weekly\n    open-pull-requests-limit: 10\n  - package-ecosystem: github-actions\n    directory: \"/\"\n    schedule:\n      interval: weekly\n    open-pull-requests-limit: 10\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  workflow_call:\n\njobs:\n  scan_ruby:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Set up Ruby\n        uses: ruby/setup-ruby@v1\n        with:\n          ruby-version: .ruby-version\n          bundler-cache: true\n\n      - name: Scan for security vulnerabilities in Ruby dependencies\n        run: bin/brakeman --no-pager\n\n  scan_js:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Set up Ruby\n        uses: ruby/setup-ruby@v1\n        with:\n          ruby-version: .ruby-version\n          bundler-cache: true\n\n      - name: Scan for security vulnerabilities in JavaScript dependencies\n        run: bin/importmap audit\n\n  lint:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Set up Ruby\n        uses: ruby/setup-ruby@v1\n        with:\n          ruby-version: .ruby-version\n          bundler-cache: true\n\n      - name: Lint code for consistent style\n        run: bin/rubocop -f github\n\n  lint_js:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Setup Node.js environment\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n          cache: \"npm\"\n\n      - name: Install dependencies\n        run: npm install\n        shell: bash\n\n      - name: Lint/Format js code\n        run: npm run lint\n\n  test:\n    runs-on: ubuntu-latest\n    timeout-minutes: 10\n\n    env:\n      PLAID_CLIENT_ID: foo\n      PLAID_SECRET: bar\n      DATABASE_URL: postgres://postgres:postgres@localhost:5432\n      REDIS_URL: redis://localhost:6379\n      RAILS_ENV: test\n\n    services:\n      postgres:\n        image: postgres\n        env:\n          POSTGRES_USER: postgres\n          POSTGRES_PASSWORD: postgres\n        ports:\n          - 5432:5432\n        options: --health-cmd=\"pg_isready\" --health-interval=10s --health-timeout=5s --health-retries=3\n\n      redis:\n        image: redis\n        ports:\n          - 6379:6379\n        options: --health-cmd=\"redis-cli ping\" --health-interval=10s --health-timeout=5s --health-retries=3\n\n    steps:\n      - name: Install packages\n        run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev\n\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Set up Ruby\n        uses: ruby/setup-ruby@v1\n        with:\n          ruby-version: .ruby-version\n          bundler-cache: true\n\n      - name: DB setup and smoke test\n        run: |\n          bin/rails db:create\n          bin/rails db:schema:load\n          bin/rails db:seed\n\n      - name: Unit and integration tests\n        run: bin/rails test\n\n      - name: System tests\n        run: DISABLE_PARALLELIZATION=true bin/rails test:system\n\n      - name: Keep screenshots from failed system tests\n        uses: actions/upload-artifact@v4\n        if: failure()\n        with:\n          name: screenshots\n          path: ${{ github.workspace }}/tmp/screenshots\n          if-no-files-found: ignore\n"
  },
  {
    "path": ".github/workflows/pr.yml",
    "content": "name: Pull Request\n\non:\n  pull_request:\n\njobs:\n  ci:\n    uses: ./.github/workflows/ci.yml"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish Docker image\n\non:\n  workflow_dispatch:\n    inputs:\n      ref:\n        description: 'Git ref (tag or commit SHA) to build'\n        required: true\n        type: string \n        default: 'main'\n  push:\n    tags:\n      - 'v*'\n    branches:\n      - main\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\npermissions:\n  contents: read\n\njobs:\n  ci:\n    uses: ./.github/workflows/ci.yml\n\n  build:\n    name: Build docker image\n    needs: [ ci ]\n\n    timeout-minutes: 60\n\n    runs-on: ubuntu-latest\n\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Check out the repo\n        uses: actions/checkout@v4\n        with:\n          ref: ${{ github.event.inputs.ref || github.ref }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to the container registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata for Docker\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          flavor: latest=auto\n          tags: |\n            type=sha,format=long\n            type=semver,pattern={{version}}\n            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}\n            type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v6\n        id: build\n        with:\n          context: .\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: 'linux/amd64,linux/arm64'\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          provenance: false\n          # https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images\n          outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=A multi-arch Docker image for the Maybe Rails app\n          build-args: BUILD_COMMIT_SHA=${{ github.sha }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files for more about ignoring files.\n#\n# If you find yourself ignoring temporary files generated by your text editor\n# or operating system, you probably want to add a global ignore instead:\n#   git config --global core.excludesfile '~/.gitignore_global'\n\n# Ignore bundler config.\n/.bundle\n/vendor/bundle\n\n# Ignore all environment files (except templates).\n/.env*\n!/.env*.erb\n!.env*.example\n\n# Ignore all logfiles and tempfiles.\n/log/*\n/tmp/*\n!/log/.keep\n!/tmp/.keep\n\n# Ignore pidfiles, but keep the directory.\n/tmp/pids/*\n!/tmp/pids/\n!/tmp/pids/.keep\n\n# Ignore storage (uploaded files in development and any SQLite databases).\n/storage/*\n!/storage/.keep\n/tmp/storage/*\n!/tmp/storage/\n!/tmp/storage/.keep\n\n/public/assets\n\n# Ignore master key for decrypting credentials and more.\n/config/master.key\n\n/app/assets/builds/*\n!/app/assets/builds/.keep\n\n# Ignore Jetbrains IDEs\n.idea\n\n# Ignore VS Code\n.vscode/*\n!.vscode/extensions.json\n!.vscode/*.code-snippets\n\n# Ignore macOS specific files\n*/.DS_Store\n.DS_Store\n\n# Ignore .devcontainer files\ncompose-dev.yaml\n\n# Ignore asdf ruby version file\n.tool-versions\n\n# Ignore GCP keyfile\ngcp-storage-keyfile.json\n\ncoverage\n.cursorrules\n.cursor/rules/structure.mdc\n.cursor/rules/agent.mdc\n\n# Ignore node related files\nnode_modules\n\ncompose.yml\n\nplaid_test_accounts/\n\n# Added by Claude Task Master\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\ndev-debug.log\n# Dependency directories\nnode_modules/\n# Environment variables\n.env\n# Editor directories and files\n.vscode\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n*.roo*\n# OS specific\n# Task files\n.taskmaster/\ntasks.json\n.taskmaster/tasks/\n.taskmaster/reports/\n.taskmaster/state.json\n*.mcp.json\nscripts/\n.cursor/mcp.json\n.taskmasterconfig\n.windsurfrules\n.cursor/rules/dev_workflow.mdc\n.cursor/rules/taskmaster.mdc\n"
  },
  {
    "path": ".rubocop.yml",
    "content": "inherit_gem:\n  rubocop-rails-omakase: rubocop.yml\n  \nLayout/IndentationWidth:\n  Enabled: true\n\nLayout/IndentationStyle:\n  EnforcedStyle: spaces\n  IndentationWidth: 2\n\nLayout/IndentationConsistency:\n  Enabled: true\n\nLayout/SpaceInsidePercentLiteralDelimiters:\n  Enabled: true"
  },
  {
    "path": ".ruby-version",
    "content": "3.4.4\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Common Development Commands\n\n### Development Server\n- `bin/dev` - Start development server (Rails, Sidekiq, Tailwind CSS watcher)\n- `bin/rails server` - Start Rails server only\n- `bin/rails console` - Open Rails console\n\n### Testing\n- `bin/rails test` - Run all tests\n- `bin/rails test:db` - Run tests with database reset\n- `bin/rails test:system` - Run system tests only (use sparingly - they take longer)\n- `bin/rails test test/models/account_test.rb` - Run specific test file\n- `bin/rails test test/models/account_test.rb:42` - Run specific test at line\n\n### Linting & Formatting\n- `bin/rubocop` - Run Ruby linter\n- `npm run lint` - Check JavaScript/TypeScript code\n- `npm run lint:fix` - Fix JavaScript/TypeScript issues\n- `npm run format` - Format JavaScript/TypeScript code\n- `bin/brakeman` - Run security analysis\n\n### Database\n- `bin/rails db:prepare` - Create and migrate database\n- `bin/rails db:migrate` - Run pending migrations\n- `bin/rails db:rollback` - Rollback last migration\n- `bin/rails db:seed` - Load seed data\n\n### Setup\n- `bin/setup` - Initial project setup (installs dependencies, prepares database)\n\n## Pre-Pull Request CI Workflow\n\nALWAYS run these commands before opening a pull request:\n\n1. **Tests** (Required):\n   - `bin/rails test` - Run all tests (always required)\n   - `bin/rails test:system` - Run system tests (only when applicable, they take longer)\n\n2. **Linting** (Required):\n   - `bin/rubocop -f github -a` - Ruby linting with auto-correct\n   - `bundle exec erb_lint ./app/**/*.erb -a` - ERB linting with auto-correct\n\n3. **Security** (Required):\n   - `bin/brakeman --no-pager` - Security analysis\n\nOnly proceed with pull request creation if ALL checks pass.\n\n## General Development Rules\n\n### Authentication Context\n- Use `Current.user` for the current user. Do NOT use `current_user`.\n- Use `Current.family` for the current family. Do NOT use `current_family`.\n\n### Development Guidelines\n- Prior to generating any code, carefully read the project conventions and guidelines\n- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development\n- Do not run `rails server` in your responses\n- Do not run `touch tmp/restart.txt`\n- Do not run `rails credentials`\n- Do not automatically run migrations\n\n## High-Level Architecture\n\n### Application Modes\nThe Maybe app runs in two distinct modes:\n- **Managed**: The Maybe team operates and manages servers for users (Rails.application.config.app_mode = \"managed\")\n- **Self Hosted**: Users host the Maybe app on their own infrastructure, typically through Docker Compose (Rails.application.config.app_mode = \"self_hosted\")\n\n### Core Domain Model\nThe application is built around financial data management with these key relationships:\n- **User** → has many **Accounts** → has many **Transactions**\n- **Account** types: checking, savings, credit cards, investments, crypto, loans, properties\n- **Transaction** → belongs to **Category**, can have **Tags** and **Rules**\n- **Investment accounts** → have **Holdings** → track **Securities** via **Trades**\n\n### API Architecture\nThe application provides both internal and external APIs:\n- Internal API: Controllers serve JSON via Turbo for SPA-like interactions\n- External API: `/api/v1/` namespace with Doorkeeper OAuth and API key authentication\n- API responses use Jbuilder templates for JSON rendering\n- Rate limiting via Rack Attack with configurable limits per API key\n\n### Sync & Import System\nTwo primary data ingestion methods:\n1. **Plaid Integration**: Real-time bank account syncing\n   - `PlaidItem` manages connections\n   - `Sync` tracks sync operations\n   - Background jobs handle data updates\n2. **CSV Import**: Manual data import with mapping\n   - `Import` manages import sessions\n   - Supports transaction and balance imports\n   - Custom field mapping with transformation rules\n\n### Background Processing\nSidekiq handles asynchronous tasks:\n- Account syncing (`SyncAccountsJob`)\n- Import processing (`ImportDataJob`)\n- AI chat responses (`CreateChatResponseJob`)\n- Scheduled maintenance via sidekiq-cron\n\n### Frontend Architecture\n- **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript\n- **ViewComponents**: Reusable UI components in `app/components/`\n- **Stimulus Controllers**: Handle interactivity, organized alongside components\n- **Charts**: D3.js for financial visualizations (time series, donut, sankey)\n- **Styling**: Tailwind CSS v4.x with custom design system\n  - Design system defined in `app/assets/tailwind/maybe-design-system.css`\n  - Always use functional tokens (e.g., `text-primary` not `text-white`)\n  - Prefer semantic HTML elements over JS components\n  - Use `icon` helper for icons, never `lucide_icon` directly\n\n### Multi-Currency Support\n- All monetary values stored in base currency (user's primary currency)\n- Exchange rates fetched from Synth API\n- `Money` objects handle currency conversion and formatting\n- Historical exchange rates for accurate reporting\n\n### Security & Authentication\n- Session-based auth for web users\n- API authentication via:\n  - OAuth2 (Doorkeeper) for third-party apps\n  - API keys with JWT tokens for direct API access\n- Scoped permissions system for API access\n- Strong parameters and CSRF protection throughout\n\n### Testing Philosophy\n- Comprehensive test coverage using Rails' built-in Minitest\n- Fixtures for test data (avoid FactoryBot)\n- Keep fixtures minimal (2-3 per model for base cases)\n- VCR for external API testing\n- System tests for critical user flows (use sparingly)\n- Test helpers in `test/support/` for common scenarios\n- Only test critical code paths that significantly increase confidence\n- Write tests as you go, when required\n\n### Performance Considerations\n- Database queries optimized with proper indexes\n- N+1 queries prevented via includes/joins\n- Background jobs for heavy operations\n- Caching strategies for expensive calculations\n- Turbo Frames for partial page updates\n\n### Development Workflow\n- Feature branches merged to `main`\n- Docker support for consistent environments\n- Environment variables via `.env` files\n- Lookbook for component development (`/lookbook`)\n- Letter Opener for email preview in development\n\n## Project Conventions\n\n### Convention 1: Minimize Dependencies\n- Push Rails to its limits before adding new dependencies\n- Strong technical/business reason required for new dependencies\n- Favor old and reliable over new and flashy\n\n### Convention 2: Skinny Controllers, Fat Models\n- Business logic in `app/models/` folder, avoid `app/services/`\n- Use Rails concerns and POROs for organization\n- Models should answer questions about themselves: `account.balance_series` not `AccountSeries.new(account).call`\n\n### Convention 3: Hotwire-First Frontend\n- **Native HTML preferred over JS components**\n  - Use `<dialog>` for modals, `<details><summary>` for disclosures\n- **Leverage Turbo frames** for page sections over client-side solutions\n- **Query params for state** over localStorage/sessions\n- **Server-side formatting** for currencies, numbers, dates\n- **Always use `icon` helper** in `application_helper.rb`, NEVER `lucide_icon` directly\n\n### Convention 4: Optimize for Simplicity\n- Prioritize good OOP domain design over performance\n- Focus performance only on critical/global areas (avoid N+1 queries, mindful of global layouts)\n\n### Convention 5: Database vs ActiveRecord Validations\n- Simple validations (null checks, unique indexes) in DB\n- ActiveRecord validations for convenience in forms (prefer client-side when possible)\n- Complex validations and business logic in ActiveRecord\n\n## TailwindCSS Design System\n\n### Design System Rules\n- **Always reference `app/assets/tailwind/maybe-design-system.css`** for primitives and tokens\n- **Use functional tokens** defined in design system:\n  - `text-primary` instead of `text-white`\n  - `bg-container` instead of `bg-white`\n  - `border border-primary` instead of `border border-gray-200`\n- **NEVER create new styles** in design system files without permission\n- **Always generate semantic HTML**\n\n## Component Architecture\n\n### ViewComponent vs Partials Decision Making\n\n**Use ViewComponents when:**\n- Element has complex logic or styling patterns\n- Element will be reused across multiple views/contexts\n- Element needs structured styling with variants/sizes\n- Element requires interactive behavior or Stimulus controllers\n- Element has configurable slots or complex APIs\n- Element needs accessibility features or ARIA support\n\n**Use Partials when:**\n- Element is primarily static HTML with minimal logic\n- Element is used in only one or few specific contexts\n- Element is simple template content\n- Element doesn't need variants, sizes, or complex configuration\n- Element is more about content organization than reusable functionality\n\n**Component Guidelines:**\n- Prefer components over partials when available\n- Keep domain logic OUT of view templates\n- Logic belongs in component files, not template files\n\n### Stimulus Controller Guidelines\n\n**Declarative Actions (Required):**\n```erb\n<!-- GOOD: Declarative - HTML declares what happens -->\n<div data-controller=\"toggle\">\n  <button data-action=\"click->toggle#toggle\" data-toggle-target=\"button\">Show</button>\n  <div data-toggle-target=\"content\" class=\"hidden\">Hello World!</div>\n</div>\n```\n\n**Controller Best Practices:**\n- Keep controllers lightweight and simple (< 7 targets)\n- Use private methods and expose clear public API\n- Single responsibility or highly related responsibilities\n- Component controllers stay in component directory, global controllers in `app/javascript/controllers/`\n- Pass data via `data-*-value` attributes, not inline JavaScript\n\n## Testing Philosophy\n\n### General Testing Rules\n- **ALWAYS use Minitest + fixtures** (NEVER RSpec or factories)\n- Keep fixtures minimal (2-3 per model for base cases)\n- Create edge cases on-the-fly within test context\n- Use Rails helpers for large fixture creation needs\n\n### Test Quality Guidelines\n- **Write minimal, effective tests** - system tests sparingly\n- **Only test critical and important code paths**\n- **Test boundaries correctly:**\n  - Commands: test they were called with correct params\n  - Queries: test output\n  - Don't test implementation details of other classes\n\n### Testing Examples\n\n```ruby\n# GOOD - Testing critical domain business logic\ntest \"syncs balances\" do\n  Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once\n  assert_difference \"@account.balances.count\", 2 do\n    Balance::Syncer.new(@account, strategy: :forward).sync_balances\n  end\nend\n\n# BAD - Testing ActiveRecord functionality\ntest \"saves balance\" do \n  balance_record = Balance.new(balance: 100, currency: \"USD\")\n  assert balance_record.save\nend\n```\n\n### Stubs and Mocks\n- Use `mocha` gem\n- Prefer `OpenStruct` for mock instances\n- Only mock what's necessary"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Maybe\n\nIt means so much that you're interested in contributing to Maybe! Seriously. Thank you. The entire community benefits from these contributions!\n\n## House Rules\n\n- Before contributing, familiarize yourself with our project conventions. You should read through our [Project Conventions Rule](https://github.com/maybe-finance/maybe/.cursor/rules/project-conventions.mdc), which is intended for LLMs, but is also an excellent primer on how we write code for Maybe.\n- While totally optional, consider using Cursor + VSCode as it will automatically apply our project conventions to your code via the `.cursor/rules` directory.\n- Before contributing, please check if it already exists in [issues](https://github.com/maybe-finance/maybe/issues) or [PRs](https://github.com/maybe-finance/maybe/pulls)\n- Given the speed at which we're moving on the codebase, we don't assign issues or \"give\" issues to anyone.\n- When 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- Priority is generally given to previous committers as they've proven familiarity with the codebase and product.\n\n## What should I contribute?\n\nAs we are still in the early days of this project, we recommend [heading over to the Wiki](https://github.com/maybe-finance/maybe/wiki) to get a better idea of _what_ to contribute.\n\nIn general, _full features_ that get us closer to [our Vision](https://github.com/maybe-finance/maybe/wiki/Vision) are the most valuable contributions at this stage.\n\n## Development\n\n### Setup\n\nTo get setup for local development, you have two options:\n\n1. [Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers) with VSCode (see the `.devcontainer` folder)\n2. Local Development\n   - [Mac Setup Guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide)\n   - [Linux Setup Guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide)\n   - [Windows Setup Guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide)\n\n### Making a Pull Request\n\n1. Fork the repo\n2. Create your feature branch (`git checkout -b my-new-feature`)\n3. Commit your changes (`git commit -am 'Add some feature'`)\n4. Push to the branch (`git push origin my-new-feature`)\n5. Create new Pull Request, and be sure to check the [Allow edits from maintainers](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) option while creating your PR. This allows maintainers to collaborate with you on your PR if needed.\n6. If possible, [link your pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) by adding the appropriate keyword (e.g. `fixes issue #XXX`)\n7. Before requesting a review, please make sure that all [Github Checks](https://docs.github.com/en/rest/checks?apiVersion=2022-11-28) have passed and your branch is up-to-date with the `main` branch. After doing so, request a review and wait for a maintainer's approval.\n\nAll PRs should target the `main` branch.\n"
  },
  {
    "path": "Dockerfile",
    "content": "# syntax = docker/dockerfile:1\n\n# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile\nARG RUBY_VERSION=3.4.4\nFROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base\n\n# Rails app lives here\nWORKDIR /rails\n\n# Install base packages\nRUN apt-get update -qq && \\\n    apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2\n\n# Set production environment\nARG BUILD_COMMIT_SHA\nENV RAILS_ENV=\"production\" \\\n    BUNDLE_DEPLOYMENT=\"1\" \\\n    BUNDLE_PATH=\"/usr/local/bundle\" \\\n    BUNDLE_WITHOUT=\"development\" \\\n    BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA}\n    \n# Throw-away build stage to reduce size of final image\nFROM base AS build\n\n# Install packages needed to build gems\nRUN apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config libyaml-dev\n\n# Install application gems\nCOPY .ruby-version Gemfile Gemfile.lock ./\nRUN bundle install\n\nRUN rm -rf ~/.bundle/ \"${BUNDLE_PATH}\"/ruby/*/cache \"${BUNDLE_PATH}\"/ruby/*/bundler/gems/*/.git\n\nRUN bundle exec bootsnap precompile --gemfile -j 0\n\n# Copy application code\nCOPY . .\n\n# Precompile bootsnap code for faster boot times\nRUN bundle exec bootsnap precompile -j 0 app/ lib/\n\n# Precompiling assets for production without requiring secret RAILS_MASTER_KEY\nRUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile\n\n# Final stage for app image\nFROM base\n\n# Clean up installation packages to reduce image size\nRUN rm -rf /var/lib/apt/lists /var/cache/apt/archives\n\n# Copy built artifacts: gems, application\nCOPY --from=build \"${BUNDLE_PATH}\" \"${BUNDLE_PATH}\"\nCOPY --from=build /rails /rails\n\n# Run and own only the runtime files as a non-root user for security\nRUN groupadd --system --gid 1000 rails && \\\n    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \\\n    chown -R rails:rails db log storage tmp\nUSER 1000:1000\n\n# Entrypoint prepares the database.\nENTRYPOINT [\"/rails/bin/docker-entrypoint\"]\n\n# Start the server by default, this can be overwritten at runtime\nEXPOSE 3000\nCMD [\"./bin/rails\", \"server\"]\n"
  },
  {
    "path": "Gemfile",
    "content": "source \"https://rubygems.org\"\n\nruby file: \".ruby-version\"\n\n# Rails\ngem \"rails\", \"~> 7.2.2\"\n\n# Drivers\ngem \"pg\", \"~> 1.5\"\ngem \"redis\", \"~> 5.4\"\n\n# Deployment\ngem \"puma\", \">= 5.0\"\ngem \"bootsnap\", require: false\n\n# Assets\ngem \"importmap-rails\"\ngem \"propshaft\"\ngem \"tailwindcss-rails\"\ngem \"lucide-rails\", github: \"maybe-finance/lucide-rails\"\n\n# Hotwire + UI\ngem \"stimulus-rails\"\ngem \"turbo-rails\"\ngem \"view_component\"\n\n# https://github.com/lookbook-hq/lookbook/issues/712\n# TODO: Remove max version constraint when fixed\ngem \"lookbook\", \"2.3.11\"\n\ngem \"hotwire_combobox\"\n\n# Background Jobs\ngem \"sidekiq\"\ngem \"sidekiq-cron\"\n\n# Monitoring\ngem \"vernier\"\ngem \"rack-mini-profiler\"\ngem \"sentry-ruby\"\ngem \"sentry-rails\"\ngem \"sentry-sidekiq\"\ngem \"logtail-rails\"\ngem \"skylight\", groups: [ :production ]\n\n# Active Storage\ngem \"aws-sdk-s3\", \"~> 1.177.0\", require: false\ngem \"image_processing\", \">= 1.2\"\n\n# Other\ngem \"ostruct\"\ngem \"bcrypt\", \"~> 3.1\"\ngem \"jwt\"\ngem \"jbuilder\"\n\n# OAuth & API Security\ngem \"doorkeeper\"\ngem \"rack-attack\", \"~> 6.6\"\ngem \"faraday\"\ngem \"faraday-retry\"\ngem \"faraday-multipart\"\ngem \"inline_svg\"\ngem \"octokit\"\ngem \"pagy\"\ngem \"rails-settings-cached\"\ngem \"tzinfo-data\", platforms: %i[windows jruby]\ngem \"csv\"\ngem \"redcarpet\"\ngem \"stripe\"\ngem \"intercom-rails\"\ngem \"plaid\"\ngem \"rotp\", \"~> 6.3\"\ngem \"rqrcode\", \"~> 3.0\"\ngem \"activerecord-import\"\ngem \"rubyzip\", \"~> 2.3\"\n\n# State machines\ngem \"aasm\"\ngem \"after_commit_everywhere\", \"~> 1.0\"\n\n# AI\ngem \"ruby-openai\"\n\ngroup :development, :test do\n  gem \"debug\", platforms: %i[mri windows]\n  gem \"brakeman\", require: false\n  gem \"rubocop-rails-omakase\", require: false\n  gem \"i18n-tasks\"\n  gem \"erb_lint\"\n  gem \"dotenv-rails\"\nend\n\nif ENV[\"BENCHMARKING_ENABLED\"]\n  gem \"dotenv-rails\", groups: [ :production ]\nend\n\ngroup :development do\n  gem \"hotwire-livereload\"\n  gem \"letter_opener\"\n  gem \"ruby-lsp-rails\"\n  gem \"web-console\"\n  gem \"faker\"\n  gem \"benchmark-ips\"\n  gem \"stackprof\"\n  gem \"derailed_benchmarks\"\n  gem \"foreman\"\nend\n\ngroup :test do\n  gem \"capybara\"\n  gem \"selenium-webdriver\"\n  gem \"mocha\"\n  gem \"vcr\"\n  gem \"webmock\"\n  gem \"climate_control\"\n  gem \"simplecov\", require: false\nend\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/>."
  },
  {
    "path": "Procfile.dev",
    "content": "web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0\ncss: bundle exec bin/rails tailwindcss:watch 2>/dev/null\nworker: bundle exec sidekiq\n"
  },
  {
    "path": "README.md",
    "content": "\n<img width=\"1190\" alt=\"maybe_hero\" src=\"https://github.com/user-attachments/assets/5ed08763-a9ee-42b2-a436-e05038fcf573\" />\n\n# Maybe: The personal finance app for everyone\n\n> [!IMPORTANT]\n> This repository is no longer actively maintained. You can read more about this in our [final release](https://github.com/maybe-finance/maybe/releases/tag/v0.6.0).\n\n## Maybe Hosting\n\nMaybe is a fully working personal finance app that can be [self hosted with Docker](docs/hosting/docker.md).\n\n## Forking and Attribution\n\nThis repo is no longer maintained. You’re free to fork it under the AGPLv3. To stay compliant and avoid trademark issues:\n\n- Be sure to include the original [AGPLv3 license](https://github.com/maybe-finance/maybe/blob/main/LICENSE) and clearly state in your README that your fork is based on Maybe Finance but is **not affiliated with or endorsed by** Maybe Finance Inc.\n- \"Maybe\" is a trademark of Maybe Finance Inc. and therefore, use of it is NOT allowed in forked repositories (or the logo)\n\n## Local Development Setup\n\n**If you are trying to _self-host_ the Maybe app, stop here. You\nshould [read this guide to get started](docs/hosting/docker.md).**\n\nThe instructions below are for developers to get started with contributing to the app.\n\n### Requirements\n\n- See `.ruby-version` file for required Ruby version\n- PostgreSQL >9.3 (ideally, latest stable version)\n\nAfter cloning the repo, the basic setup commands are:\n\n```sh\ncd maybe\ncp .env.local.example .env.local\nbin/setup\nbin/dev\n\n# Optionally, load demo data\nrake demo_data:default\n```\n\nAnd visit http://localhost:3000 to see the app. You can use the following\ncredentials to log in (generated by DB seed):\n\n- Email: `user@maybe.local`\n- Password: `password`\n\nFor further instructions, see guides below.\n\n### Setup Guides\n\n- [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide)\n- [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide)\n- [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide)\n- Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more\n\n## Copyright & license\n\nMaybe is distributed under\nan [AGPLv3 license](https://github.com/maybe-finance/maybe/blob/main/LICENSE). \"\nMaybe\" is a trademark of Maybe Finance, Inc.\n"
  },
  {
    "path": "Rakefile",
    "content": "# Add your own tasks in files placed in lib/tasks ending in .rake,\n# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.\n\nrequire_relative \"config/application\"\n\nRails.application.load_tasks\n"
  },
  {
    "path": "app/assets/builds/.keep",
    "content": ""
  },
  {
    "path": "app/assets/tailwind/application.css",
    "content": "@import 'tailwindcss';\n\n@import \"./maybe-design-system.css\";\n\n@import \"./geist-font.css\";\n@import \"./geist-mono-font.css\";\n\n@plugin \"@tailwindcss/typography\";\n@plugin \"@tailwindcss/forms\";\n\n@import \"./simonweb_pickr.css\";\n\n@layer components {\n  .pcr-app{\n    position: static !important;\n    background: none !important;\n    box-shadow: none !important;\n    padding: 0 !important;\n    width: 100% !important;\n  }\n  .pcr-color-palette{\n      height: 12em !important;\n  }\n  .pcr-palette{\n      border-radius: 10px !important;\n  }\n  .pcr-palette:before{\n      border-radius: 10px !important;\n  }\n  .pcr-color-chooser{\n      height: 1.5em !important;\n  }\n  .pcr-picker{\n      height: 20px !important;\n      width: 20px !important;\n  }\n}\n\n.combobox {\n  .hw-combobox__main__wrapper,\n  .hw-combobox__input {\n    @apply bg-container text-primary w-full;\n  }\n\n  .hw-combobox__main__wrapper {\n    @apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none;\n  }\n\n  .hw-combobox__listbox {\n    @apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30;\n  }\n\n  .hw-combobox__label {\n    @apply block text-xs text-gray-500 peer-disabled:text-gray-400;\n  }\n  \n  .hw-combobox__option {\n    @apply bg-container hover:bg-container-hover;\n  }\n\n  .hw_combobox__pagination__wrapper {\n    @apply h-px;\n\n    &:only-child {\n      @apply bg-transparent;\n    }\n  }\n\n  --hw-border-color: rgba(0, 0, 0, 0.2);\n  --hw-handle-width: 20px;\n  --hw-handle-height: 20px;\n  --hw-handle-offset-right: 0px;\n}\n\n/* Typography */\n.prose {\n  @apply max-w-none text-primary; \n\n  a {\n    @apply text-link;\n  }\n\n  h2 {\n    @apply text-xl font-medium text-primary;\n  }\n\n  h3 {\n    @apply text-lg font-medium text-primary;\n  }\n\n  li {\n    @apply m-0 text-primary;\n  }\n\n  details {\n    @apply mb-4 rounded-xl mt-3.5;\n  }\n\n  summary {\n    @apply flex items-center gap-1;\n  }\n\n  video {\n    @apply m-0 rounded-b-xl;\n  }\n}\n\n.prose--github-release-notes {\n  .octicon {\n    @apply inline-block overflow-visible align-text-bottom fill-current;\n  }\n\n  .dropdown-caret {\n    @apply content-none border-4 border-b-0 border-transparent border-t-gray-500 size-0 inline-block;\n  }\n\n  .user-mention {\n    @apply font-bold;\n  }\n}\n\n.prose--ai-chat {\n  @apply break-words;\n\n  p, li {\n    @apply text-sm text-primary;\n  }\n\n  scrollbar-width: thin;\n  scrollbar-color: rgba(156, 163, 175, 0.5) transparent;\n\n  ::-webkit-scrollbar {\n    width: 6px;\n  }\n\n  ::-webkit-scrollbar-track {\n    background: transparent;\n  }\n\n  ::-webkit-scrollbar-thumb {\n    background-color: rgba(156, 163, 175, 0.5);\n    border-radius: 3px; \n  }\n}\n\n/* Custom scrollbar implementation for Windows browsers */\n.windows {\n  ::-webkit-scrollbar {\n    width: 4px;\n  }\n\n  ::-webkit-scrollbar-thumb {\n    background: #d6d6d6;\n    border-radius: 10px;\n  }\n\n  ::-webkit-scrollbar-thumb:hover {\n    background: #a6a6a6;\n  }  \n}\n\n.scrollbar {\n  &::-webkit-scrollbar {\n    width: 4px;\n  }\n\n  &::-webkit-scrollbar-thumb {\n    background: #d6d6d6;\n    border-radius: 10px;\n  }\n\n  &::-webkit-scrollbar-thumb:hover {\n    background: #a6a6a6;\n  }\n}"
  },
  {
    "path": "app/assets/tailwind/geist-font.css",
    "content": "\n\n/* Variable font */\n@font-face {\n  font-family: 'Geist';\n  src: url('./geist/Geist[wght].woff2') format('woff2-variations');\n  font-weight: 100 900;\n  font-style: normal;\n  font-display: swap;\n}\n\n/* Static fonts (fallback) */\n@supports not (font-variation-settings: normal) {\n  @font-face {\n    font-family: 'Geist';\n    src: url('./geist/Geist-Thin.woff2') format('woff2');\n    font-weight: 100;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist';\n    src: url('./geist/Geist-ExtraLight.woff2') format('woff2');\n    font-weight: 200;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist';\n    src: url('./geist/Geist-Light.woff2') format('woff2');\n    font-weight: 300;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist';\n    src: url('./geist/Geist-Regular.woff2') format('woff2');\n    font-weight: 400;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist';\n    src: url('./geist/Geist-Medium.woff2') format('woff2');\n    font-weight: 500;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist';\n    src: url('./geist/Geist-SemiBold.woff2') format('woff2');\n    font-weight: 600;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist';\n    src: url('./geist/Geist-Bold.woff2') format('woff2');\n    font-weight: 700;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist';\n    src: url('./geist/Geist-ExtraBold.woff2') format('woff2');\n    font-weight: 800;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist';\n    src: url('./geist/Geist-Black.woff2') format('woff2');\n    font-weight: 900;\n    font-style: normal;\n    font-display: swap;\n  }\n}\n"
  },
  {
    "path": "app/assets/tailwind/geist-mono-font.css",
    "content": "/* Variable font */\n@font-face {\n  font-family: 'Geist Mono';\n  src: url('./geist_mono/GeistMono[wght].woff2') format('woff2-variations');\n  font-weight: 100 950;\n  font-style: normal;\n  font-display: swap;\n}\n\n/* Static fonts (fallback) */\n@supports not (font-variation-settings: normal) {\n  @font-face {\n    font-family: 'Geist Mono';\n    src: url('./geist_mono/GeistMono-Thin.woff2') format('woff2');\n    font-weight: 100;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist Mono';\n    src: url('./geist_mono/GeistMono-UltraLight.woff2') format('woff2');\n    font-weight: 200;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist Mono';\n    src: url('./geist_mono/GeistMono-Light.woff2') format('woff2');\n    font-weight: 300;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist Mono';\n    src: url('./geist_mono/GeistMono-Regular.woff2') format('woff2');\n    font-weight: 400;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist Mono';\n    src: url('./geist_mono/GeistMono-Medium.woff2') format('woff2');\n    font-weight: 500;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist Mono';\n    src: url('./geist_mono/GeistMono-SemiBold.woff2') format('woff2');\n    font-weight: 600;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist Mono';\n    src: url('./geist_mono/GeistMono-Bold.woff2') format('woff2');\n    font-weight: 700;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist Mono';\n    src: url('./geist_mono/GeistMono-Black.woff2') format('woff2');\n    font-weight: 900;\n    font-style: normal;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: 'Geist Mono';\n    src: url('./geist_mono/GeistMono-UltraBlack.woff2') format('woff2');\n    font-weight: 950;\n    font-style: normal;\n    font-display: swap;\n  }\n}\n"
  },
  {
    "path": "app/assets/tailwind/maybe-design-system/background-utils.css",
    "content": "@utility bg-surface {\n  @apply bg-gray-50;\n\n  @variant theme-dark {\n    @apply bg-black;\n  }\n}\n\n@utility bg-surface-hover {\n  @apply bg-gray-100;\n\n  @variant theme-dark {\n    @apply bg-gray-900;\n  }\n}\n\n@utility bg-surface-inset {\n  @apply bg-gray-100;\n\n  @variant theme-dark {\n    @apply bg-gray-800;\n  }\n}\n\n@utility bg-surface-inset-hover {\n  @apply bg-gray-200;\n\n  @variant theme-dark {\n    @apply bg-gray-800;\n  }\n}\n\n@utility bg-container {\n  @apply bg-white;\n\n  @variant theme-dark {\n    @apply bg-gray-900;\n  }\n}\n\n@utility bg-container-hover {\n  @apply bg-gray-50;\n\n  @variant theme-dark {\n    @apply bg-gray-800;\n  }\n}\n\n@utility bg-container-inset {\n  @apply bg-gray-50;\n\n  @variant theme-dark {\n    @apply bg-gray-800;\n  }\n}\n\n@utility bg-container-inset-hover {\n  @apply bg-gray-100;\n\n  @variant theme-dark {\n    @apply bg-gray-700;\n  }\n}\n\n@utility bg-inverse {\n  @apply bg-gray-800;\n\n  @variant theme-dark {\n    @apply bg-white;\n  }\n}\n\n@utility bg-inverse-hover {\n  @apply bg-gray-700;\n\n  @variant theme-dark {\n    @apply bg-gray-100;\n  }\n}\n\n@utility bg-overlay {\n  background-color: --alpha(var(--color-gray-100) / 50%);\n\n  @variant theme-dark {\n    background-color: var(--color-alpha-black-900);\n  }\n}\n\n@utility bg-loader {\n  @apply bg-surface-inset animate-pulse;\n}\n"
  },
  {
    "path": "app/assets/tailwind/maybe-design-system/border-utils.css",
    "content": "/* Custom shadow borders used for surfaces / containers  */\n@utility shadow-border-xs {\n  box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50);\n\n  @variant theme-dark {\n    box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-white-50);\n  }\n}\n\n@utility shadow-border-sm {\n  box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50);\n\n  @variant theme-dark {\n    box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-white-50);\n  }\n}\n\n@utility shadow-border-md {\n  box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50);\n\n  @variant theme-dark {\n    box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-white-50);\n  }\n}\n\n@utility shadow-border-lg {\n  box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50);\n\n  @variant theme-dark {\n    box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-white-50);\n  }\n}\n\n@utility shadow-border-xl {\n  box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50);\n\n  @variant theme-dark {\n    box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-white-50);\n  }\n}\n\n@utility border-primary {\n  @apply border-alpha-black-300;\n\n  @variant theme-dark {\n    @apply border-alpha-white-400;\n  }\n}\n\n@utility border-secondary {\n  @apply border-alpha-black-200;\n\n  @variant theme-dark {\n    @apply border-alpha-white-300;\n  }\n}\n\n@utility border-tertiary {\n  @apply border-alpha-black-100;\n\n  @variant theme-dark {\n    @apply border-alpha-white-200;\n  }\n}\n\n@utility border-divider {\n  @apply border-tertiary;\n}\n\n@utility border-subdued {\n  @apply border-alpha-black-50;\n\n  @variant theme-dark {\n    @apply border-alpha-white-100;\n  }\n}\n\n@utility border-solid {\n  @apply border-black;\n\n  @variant theme-dark {\n    @apply border-white;\n  }\n}\n\n@utility border-destructive {\n  @apply border-red-500;\n\n  @variant theme-dark {\n    @apply border-red-400;\n  }\n}\n"
  },
  {
    "path": "app/assets/tailwind/maybe-design-system/component-utils.css",
    "content": "/* Button Backgrounds */\n@utility button-bg-primary {\n  @apply bg-gray-900;\n  /* Maps to fg-primary light */\n\n  @variant theme-dark {\n    @apply bg-white;\n    /* Maps to fg-primary dark */\n  }\n}\n\n@utility button-bg-primary-hover {\n  @apply bg-gray-800;\n  /* Maps to fg-primary-variant light */\n\n  @variant theme-dark {\n    @apply bg-gray-50;\n    /* Maps to fg-primary-variant dark */\n  }\n}\n\n@utility button-bg-secondary {\n  @apply bg-gray-50; /* Maps to fg-secondary light */\n\n  @variant theme-dark {\n    @apply bg-gray-700; /* Maps to fg-secondary dark */\n  }\n}\n\n@utility button-bg-secondary-hover {\n  @apply bg-gray-100; /* Maps to fg-secondary-variant light */\n\n  @variant theme-dark {\n    @apply bg-gray-600; /* Maps to fg-secondary-variant dark */\n  }\n}\n\n@utility button-bg-disabled {\n  @apply bg-gray-50;\n\n  @variant theme-dark {\n    @apply bg-gray-700;\n  }\n}\n\n@utility button-bg-destructive {\n  @apply bg-red-500;\n\n  @variant theme-dark {\n    @apply bg-red-400;\n  }\n}\n\n@utility button-bg-destructive-hover {\n  @apply bg-red-600;\n\n  @variant theme-dark {\n    @apply bg-red-500;\n  }\n}\n\n@utility button-bg-ghost-hover {\n  @apply bg-gray-50;\n\n  @variant theme-dark {\n    @apply bg-gray-800 fg-inverse;\n  }\n}\n\n@utility button-bg-outline-hover {\n  @apply bg-gray-100;\n\n  @variant theme-dark {\n    @apply bg-gray-700;\n  }\n}\n\n/* Tab Styles */\n@utility tab-item-active {\n  @apply bg-white;\n\n  @variant theme-dark {\n    @apply bg-gray-700;\n  }\n}\n\n@utility tab-item-hover {\n  @apply bg-gray-200;\n\n  @variant theme-dark {\n    @apply bg-gray-800;\n  }\n}\n\n@utility tab-bg-group {\n  @apply bg-gray-50;\n\n  @variant theme-dark {\n    @apply bg-alpha-black-700;\n  }\n}\n\n@utility bg-nav-indicator {\n  @apply bg-black;\n\n  @variant theme-dark {\n    @apply bg-white;\n  }\n}"
  },
  {
    "path": "app/assets/tailwind/maybe-design-system/foreground-utils.css",
    "content": "@utility fg-gray {\n  @apply text-gray-500;\n\n  @variant theme-dark {\n    @apply text-gray-400;\n  }\n}\n\n@utility fg-contrast {\n  @apply text-gray-400;\n\n  @variant theme-dark {\n    @apply text-gray-500;\n  }\n}\n\n@utility fg-inverse {\n  @apply text-white;\n\n  @variant theme-dark {\n    @apply text-gray-900;\n  }\n}\n\n@utility fg-primary {\n  @apply text-gray-900;\n\n  @variant theme-dark {\n    @apply text-white;\n  }\n}\n\n@utility fg-primary-variant {\n  @apply text-gray-800;\n\n  @variant theme-dark {\n    @apply text-gray-50;\n  }\n}\n\n@utility fg-secondary {\n  @apply text-gray-50;\n\n  @variant theme-dark {\n    @apply text-gray-700;\n  }\n}\n\n@utility fg-secondary-variant {\n  @apply text-gray-100;\n\n  @variant theme-dark {\n    @apply text-gray-600;\n  }\n}\n\n@utility fg-subdued {\n  @apply text-gray-400;\n\n  @variant theme-dark {\n    @apply text-gray-500;\n  }\n}"
  },
  {
    "path": "app/assets/tailwind/maybe-design-system/text-utils.css",
    "content": "@utility text-primary {\n  @apply text-gray-900;\n\n  @variant theme-dark {\n   @apply text-white;\n  }\n}\n\n@utility text-inverse {\n  @apply text-white;\n\n  @variant theme-dark {\n    @apply text-gray-900;\n  }\n}\n\n@utility text-secondary {\n  @apply text-gray-500;\n\n  @variant theme-dark {\n    @apply text-gray-400;\n  }\n}\n\n@utility text-subdued {\n  @apply text-gray-400;\n\n  @variant theme-dark {\n    @apply text-gray-600;\n  }\n}\n\n@utility text-link {\n  @apply text-blue-600;\n\n  @variant theme-dark {\n    @apply text-blue-500;\n  }\n}"
  },
  {
    "path": "app/assets/tailwind/maybe-design-system.css",
    "content": "/* \n  This file contains all of the Figma design tokens, components, etc. that \n  are used globally across the app. \n\n  One-off styling (3rd party overrides, etc.) should be done in the application.css file.\n*/\n\n@import './maybe-design-system/background-utils.css';\n@import './maybe-design-system/foreground-utils.css';\n@import './maybe-design-system/text-utils.css';\n@import './maybe-design-system/border-utils.css';\n@import './maybe-design-system/component-utils.css';\n\n@custom-variant theme-dark (&:where([data-theme=dark], [data-theme=dark] *));\n\n@theme {\n  /* Font families */\n  --font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n  --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n\n  /* Base colors */\n  --color-white: #ffffff;\n  --color-black: #0B0B0B;\n  --color-success: var(--color-green-600);\n  --color-warning: var(--color-yellow-600);\n  --color-destructive: var(--color-red-600);\n  --color-shadow: --alpha(var(--color-black) / 6%);\n\n  /* Colors used in Stimulus controllers with SVGs (easier to define light/dark mode here than toggle within the controllers) */\n  /* See @layer base block below for dark mode overrides */\n  --budget-unused-fill: var(--color-gray-200);\n  --budget-unallocated-fill: var(--color-gray-50);\n\n  /* Gray scale */\n  --color-gray-25: #FAFAFA;\n  --color-gray-50: #F7F7F7;\n  --color-gray-100: #F0F0F0;\n  --color-gray-200: #E7E7E7;\n  --color-gray-300: #CFCFCF;\n  --color-gray-400: #9E9E9E;\n  --color-gray-500: #737373;\n  --color-gray-600: #5C5C5C;\n  --color-gray-700: #363636;\n  --color-gray-800: #242424;\n  --color-gray-900: #171717;\n  --color-gray: var(--color-gray-500);\n  --color-gray-tint-5: --alpha(var(--color-gray-500) / 5%);\n  --color-gray-tint-10: --alpha(var(--color-gray-500) / 10%);\n\n  /* Alpha colors */\n  --color-alpha-white-25: --alpha(var(--color-white) / 3%);\n  --color-alpha-white-50: --alpha(var(--color-white) / 5%);\n  --color-alpha-white-100: --alpha(var(--color-white) / 8%);\n  --color-alpha-white-200: --alpha(var(--color-white) / 10%);\n  --color-alpha-white-300: --alpha(var(--color-white) / 15%);\n  --color-alpha-white-400: --alpha(var(--color-white) / 20%);\n  --color-alpha-white-500: --alpha(var(--color-white) / 30%);\n  --color-alpha-white-600: --alpha(var(--color-white) / 40%);\n  --color-alpha-white-700: --alpha(var(--color-white) / 50%);\n  --color-alpha-white-800: --alpha(var(--color-white) / 70%);\n  --color-alpha-white-900: --alpha(var(--color-white) / 85%);\n\n  --color-alpha-black-25: --alpha(var(--color-black) / 3%);\n  --color-alpha-black-50: --alpha(var(--color-black) / 5%);\n  --color-alpha-black-100: --alpha(var(--color-black) / 8%);\n  --color-alpha-black-200: --alpha(var(--color-black) / 10%);\n  --color-alpha-black-300: --alpha(var(--color-black) / 15%);\n  --color-alpha-black-400: --alpha(var(--color-black) / 20%);\n  --color-alpha-black-500: --alpha(var(--color-black) / 30%);\n  --color-alpha-black-600: --alpha(var(--color-black) / 40%);\n  --color-alpha-black-700: --alpha(var(--color-black) / 50%);\n  --color-alpha-black-800: --alpha(var(--color-black) / 70%);\n  --color-alpha-black-900: --alpha(var(--color-black) / 85%);\n\n  /* Red scale */\n  --color-red-25: #FFFBFB;\n  --color-red-50: #FFF1F0;\n  --color-red-100: #FFDEDB;\n  --color-red-200: #FEB9B3;\n  --color-red-300: #F88C86;\n  --color-red-400: #ED4E4E;\n  --color-red-500: #F13636;\n  --color-red-600: #EC2222;\n  --color-red-700: #C91313;\n  --color-red-800: #A40E0E;\n  --color-red-900: #7E0707;\n  --color-red-tint-5: --alpha(var(--color-red-500) / 5%);\n  --color-red-tint-10: --alpha(var(--color-red-500) / 10%);\n\n  /* Green scale */\n  --color-green-25: #F6FEF9;\n  --color-green-50: #ECFDF3;\n  --color-green-100: #D1FADF;\n  --color-green-200: #A6F4C5;\n  --color-green-300: #6CE9A6;\n  --color-green-400: #32D583;\n  --color-green-500: #12B76A;\n  --color-green-600: #10A861;\n  --color-green-700: #078C52;\n  --color-green-800: #05603A;\n  --color-green-900: #054F31;\n  --color-green-tint-5: --alpha(var(--color-green-500) / 5%);\n  --color-green-tint-10: --alpha(var(--color-green-500) / 10%);\n\n  /* Yellow scale */\n  --color-yellow-25: #FFFCF5;\n  --color-yellow-50: #FFFAEB;\n  --color-yellow-100: #FEF0C7;\n  --color-yellow-200: #FEDF89;\n  --color-yellow-300: #FEC84B;\n  --color-yellow-400: #FDB022;\n  --color-yellow-500: #F79009;\n  --color-yellow-600: #DC6803;\n  --color-yellow-700: #B54708;\n  --color-yellow-800: #93370D;\n  --color-yellow-900: #7A2E0E;\n  --color-yellow-tint-5: --alpha(var(--color-yellow-500) / 5%);\n  --color-yellow-tint-10: --alpha(var(--color-yellow-500) / 10%);\n\n  /* Cyan scale */\n  --color-cyan-25: #F5FEFF;\n  --color-cyan-50: #ECFDFF;\n  --color-cyan-100: #CFF9FE;\n  --color-cyan-200: #A5F0FC;\n  --color-cyan-300: #67E3F9;\n  --color-cyan-400: #22CCEE;\n  --color-cyan-500: #06AED4;\n  --color-cyan-600: #088AB2;\n  --color-cyan-700: #0E7090;\n  --color-cyan-800: #155B75;\n  --color-cyan-900: #155B75;\n  --color-cyan-tint-5: --alpha(var(--color-cyan-500) / 5%);\n  --color-cyan-tint-10: --alpha(var(--color-cyan-500) / 10%);\n\n  /* Blue scale */\n  --color-blue-25: #F5FAFF;\n  --color-blue-50: #EFF8FF;\n  --color-blue-100: #D1E9FF;\n  --color-blue-200: #B2DDFF;\n  --color-blue-300: #84CAFF;\n  --color-blue-400: #53B1FD;\n  --color-blue-500: #2E90FA;\n  --color-blue-600: #1570EF;\n  --color-blue-700: #175CD3;\n  --color-blue-800: #1849A9;\n  --color-blue-900: #194185;\n  --color-blue-tint-5: --alpha(var(--color-blue-500) / 5%);\n  --color-blue-tint-10: --alpha(var(--color-blue-500) / 10%);\n\n  /* Indigo scale */\n  --color-indigo-25: #F5F8FF;\n  --color-indigo-50: #EFF4FF;\n  --color-indigo-100: #E0EAFF;\n  --color-indigo-200: #C7D7FE;\n  --color-indigo-300: #A4BCFD;\n  --color-indigo-400: #8098F9;\n  --color-indigo-500: #6172F3;\n  --color-indigo-600: #444CE7;\n  --color-indigo-700: #3538CD;\n  --color-indigo-800: #2D31A6;\n  --color-indigo-900: #2D3282;\n  --color-indigo-tint-5: --alpha(var(--color-indigo-500) / 5%);\n  --color-indigo-tint-10: --alpha(var(--color-indigo-500) / 10%);\n\n  /* Violet scale */\n  --color-violet-25: #FBFAFF;\n  --color-violet-50: #F5F3FF;\n  --color-violet-100: #ECE9FE;\n  --color-violet-200: #DDD6FE;\n  --color-violet-300: #C3B5FD;\n  --color-violet-400: #A48AFB;\n  --color-violet-500: #875BF7;\n  --color-violet-600: #7839EE;\n  --color-violet-700: #6927DA;\n  --color-violet-tint-5: --alpha(var(--color-violet-500) / 5%);\n  --color-violet-tint-10: --alpha(var(--color-violet-500) / 10%);\n\n  /* Fuchsia scale */\n  --color-fuchsia-25: #FEFAFF;\n  --color-fuchsia-50: #FDF4FF;\n  --color-fuchsia-100: #FBE8FF;\n  --color-fuchsia-200: #F6D0FE;\n  --color-fuchsia-300: #EEAAFD;\n  --color-fuchsia-400: #E478FA;\n  --color-fuchsia-500: #D444F1;\n  --color-fuchsia-600: #BA24D5;\n  --color-fuchsia-700: #9F1AB1;\n  --color-fuchsia-800: #821890;\n  --color-fuchsia-900: #6F1877;\n  --color-fuchsia-tint-5: --alpha(var(--color-fuchsia-500) / 5%);\n  --color-fuchsia-tint-10: --alpha(var(--color-fuchsia-500) / 10%);\n\n  /* Pink scale */\n  --color-pink-25: #FFFAFC;\n  --color-pink-50: #FEF0F7;\n  --color-pink-100: #FFD1E2;\n  --color-pink-200: #FFB1CE;\n  --color-pink-300: #FD8FBA;\n  --color-pink-400: #F86BA7;\n  --color-pink-500: #F23E94;\n  --color-pink-600: #D5327F;\n  --color-pink-700: #BA256B;\n  --color-pink-800: #9E1958;\n  --color-pink-900: #840B45;\n  --color-pink-tint-5: --alpha(var(--color-pink-500) / 5%);\n  --color-pink-tint-10: --alpha(var(--color-pink-500) / 10%);\n\n  /* Orange scale */\n  --color-orange-25: #FFF9F5;\n  --color-orange-50: #FFF4ED;\n  --color-orange-100: #FFE6D5;\n  --color-orange-200: #FFD6AE;\n  --color-orange-300: #FF9C66;\n  --color-orange-400: #FF692E;\n  --color-orange-500: #FF4405;\n  --color-orange-600: #E62E05;\n  --color-orange-700: #BC1B06;\n  --color-orange-800: #97180C;\n  --color-orange-900: #771A0D;\n  --color-orange-tint-5: --alpha(var(--color-orange-500) / 5%);\n  --color-orange-tint-10: --alpha(var(--color-orange-500) / 10%);\n\n  /* Border radius overrides */\n  --border-radius-md: 8px;\n  --border-radius-lg: 10px;\n\n  --shadow-xs: 0px 1px 2px 0px --alpha(var(--color-black) / 6%);\n  --shadow-sm: 0px 1px 6px 0px --alpha(var(--color-black) / 6%);\n  --shadow-md: 0px 4px 8px -2px --alpha(var(--color-black) / 6%);\n  --shadow-lg: 0px 12px 16px -4px --alpha(var(--color-black) / 6%);\n  --shadow-xl: 0px 20px 24px -4px --alpha(var(--color-black) / 6%);\n\n  --animate-stroke-fill: stroke-fill 3s 300ms forwards;\n\n  @keyframes stroke-fill {\n    0% {\n      stroke-dashoffset: 43.9822971503;\n    }\n\n    100% {\n      stroke-dashoffset: 0;\n    }\n  } \n}\n\n/* Specific override for strong tags in prose under dark mode */\n.prose:where([data-theme=dark], [data-theme=dark] *) strong {\n  color: theme(colors.white) !important;\n}\n\n@layer base {\n  [data-theme=\"dark\"] { \n    --color-success: var(--color-green-500);\n    --color-warning: var(--color-yellow-400);\n    --color-destructive: var(--color-red-400);\n    --color-shadow: --alpha(var(--color-white) / 8%);\n\n    /* Dark mode overrides for colors used in Stimulus controllers with SVGs */\n    --budget-unused-fill: var(--color-gray-500);\n    --budget-unallocated-fill: var(--color-gray-700);\n\n    --shadow-xs: 0px 1px 2px 0px --alpha(var(--color-white) / 8%);\n    --shadow-sm: 0px 1px 6px 0px --alpha(var(--color-white) / 8%);\n    --shadow-md: 0px 4px 8px -2px --alpha(var(--color-white) / 8%);\n    --shadow-lg: 0px 12px 16px -4px --alpha(var(--color-white) / 8%);\n    --shadow-xl: 0px 20px 24px -4px --alpha(var(--color-white) / 8%);\n  }\n\n  html {\n    padding-top: env(safe-area-inset-top);\n    padding-bottom: env(safe-area-inset-bottom);\n  }\n\n  button {\n    @apply cursor-pointer focus-visible:outline-gray-900;\n  }\n\n  hr {\n    @apply text-gray-200;\n  }\n\n  /* We control the sizing through DialogComponent, so reset this value */\n  dialog:modal {\n    max-width: 100dvw;\n    max-height: 100dvh;\n  }\n\n  details>summary::-webkit-details-marker {\n    @apply hidden;\n  }\n\n  details>summary {\n    @apply list-none;\n  }\n\n  input[type='radio'] {\n    @apply border-gray-300 text-indigo-600 focus:ring-indigo-600;\n    /* Default light mode */\n\n    @variant theme-dark {\n      /* Dark mode radio button base and checked styles */\n      @apply border-gray-600 bg-gray-700 checked:bg-blue-500 focus:ring-blue-500 focus:ring-offset-gray-800;\n    }\n  }\n}\n\n@layer components {\n  /* Forms */\n  .form-field {\n    @apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full;\n    @apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200;\n    @apply transition-all duration-300;\n\n    @variant theme-dark {\n      @apply focus-within:ring-alpha-white-300;\n    }\n\n    /* Add styles for multiple select within form fields */\n    select[multiple] {\n      @apply py-2 pr-2 space-y-0.5 overflow-y-auto;\n\n      option {\n        @apply py-2 rounded-md;\n      }\n\n      option:checked {\n        @apply after:content-['\\2713'] bg-container-inset after:text-gray-500 after:ml-2;\n      }\n\n      option:active,\n      option:focus {\n        @apply bg-container-inset;\n      }\n    }\n  }\n\n  /* New form field structure components */\n  .form-field__header {\n    @apply flex items-center justify-between gap-2;\n  }\n\n  .form-field__body {\n    @apply flex flex-col gap-1;\n  }\n\n  .form-field__actions {\n    @apply flex items-center gap-1;\n  }\n\n  .form-field__label {\n    @apply block text-xs text-secondary peer-disabled:text-subdued;\n  }\n\n  .form-field__input {\n    @apply text-primary border-none bg-container text-sm opacity-100 w-full p-0;\n    @apply focus:opacity-100 focus:outline-hidden focus:ring-0;\n    @apply placeholder-shown:opacity-50;\n    @apply disabled:text-subdued;\n    @apply text-ellipsis overflow-hidden whitespace-nowrap;\n    @apply transition-opacity duration-300;\n    @apply placeholder:text-subdued;\n\n    @variant theme-dark {\n      &::-webkit-calendar-picker-indicator {\n        filter: invert(1);\n        cursor: pointer;\n      }\n    }\n  }\n  \n  select.form-field__input {\n    @apply pr-10 appearance-none;\n    background-image: url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e\");\n    background-position: right -0.15rem center;\n    background-repeat: no-repeat;\n    background-size: 1.25rem 1.25rem;\n  }\n\n  .form-field__radio {\n    @apply text-primary;\n  }\n\n  .form-field__submit {\n    @apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover;\n  }\n\n  /* Checkboxes */\n  .checkbox {\n    &[type='checkbox'] {\n      @apply rounded-sm;\n      @apply transition-colors duration-300;\n    }\n  }\n\n  .checkbox--light {\n    &[type='checkbox'] {\n      @apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;\n    }\n\n    &[type='checkbox']:disabled {\n      @apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;\n    }\n\n    @variant theme-dark {\n      &[type='checkbox'] {\n        @apply ring-gray-900 checked:text-white;\n        background-color: var(--color-gray-600);\n      }\n  \n      &[type='checkbox']:disabled {\n        @apply cursor-not-allowed opacity-80 ring-gray-600;\n      }\n  \n      &[type='checkbox']:checked {\n        background-image: url(\"data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e\");\n        background-color: var(--color-gray-600);\n      }\n    }\n  }\n\n  .checkbox--dark {\n    &[type='checkbox'] {\n      @apply ring-gray-900 checked:text-white;\n    }\n\n    &[type='checkbox']:disabled {\n      @apply cursor-not-allowed opacity-80 ring-gray-600;\n    }\n\n    &[type='checkbox']:checked {\n      background-image: url(\"data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e\");\n    }\n  }\n\n  /* Tooltips */\n  .tooltip {\n    @apply hidden absolute;\n  }\n\n  .qrcode svg path {\n    fill: var(--color-black);\n    @variant theme-dark {\n      fill: var(--color-white);\n    }\n  }\n}"
  },
  {
    "path": "app/assets/tailwind/simonweb_pickr.css",
    "content": "/*! Pickr 1.9.1 MIT | https://github.com/Simonwep/pickr */\n.pickr{position:relative;overflow:visible;transform:translateY(0)}.pickr *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr .pcr-button{position:relative;height:2em;width:2em;padding:.5em;cursor:pointer;font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Roboto\",\"Helvetica Neue\",Arial,sans-serif;border-radius:.15em;background:url(\"data:image/svg+xml;utf8, <svg xmlns=\\\"http://www.w3.org/2000/svg\\\" viewBox=\\\"0 0 50 50\\\" stroke=\\\"%2342445A\\\" stroke-width=\\\"5px\\\" stroke-linecap=\\\"round\\\"><path d=\\\"M45,45L5,5\\\"></path><path d=\\\"M45,5L5,45\\\"></path></svg>\") no-repeat center;background-size:0;transition:all .3s}.pickr .pcr-button::before{position:absolute;content:\"\";top:0;left:0;width:100%;height:100%;background:url(\"data:image/svg+xml;utf8, <svg xmlns=\\\"http://www.w3.org/2000/svg\\\" viewBox=\\\"0 0 2 2\\\"><path fill=\\\"white\\\" d=\\\"M1,0H2V1H1V0ZM0,1H1V2H0V1Z\\\"/><path fill=\\\"gray\\\" d=\\\"M0,0H1V1H0V0ZM1,1H2V2H1V1Z\\\"/></svg>\");background-size:.5em;border-radius:.15em;z-index:-1}.pickr .pcr-button::before{z-index:initial}.pickr .pcr-button::after{position:absolute;content:\"\";top:0;left:0;height:100%;width:100%;transition:background .3s;background:var(--pcr-color);border-radius:.15em}.pickr .pcr-button.clear{background-size:70%}.pickr .pcr-button.clear::before{opacity:0}.pickr .pcr-button.clear:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-button.disabled{cursor:not-allowed}.pickr *,.pcr-app *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr input:focus,.pickr input.pcr-active,.pickr button:focus,.pickr button.pcr-active,.pcr-app input:focus,.pcr-app input.pcr-active,.pcr-app button:focus,.pcr-app button.pcr-active{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-palette,.pickr .pcr-slider,.pcr-app .pcr-palette,.pcr-app .pcr-slider{transition:box-shadow .3s}.pickr .pcr-palette:focus,.pickr .pcr-slider:focus,.pcr-app .pcr-palette:focus,.pcr-app .pcr-slider:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(0,0,0,.25)}.pcr-app{position:fixed;display:flex;flex-direction:column;z-index:10000;border-radius:.1em;background:#fff;opacity:0;visibility:hidden;transition:opacity .3s,visibility 0s .3s;font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Roboto\",\"Helvetica Neue\",Arial,sans-serif;box-shadow:0 .15em 1.5em 0 rgba(0,0,0,.1),0 0 1em 0 rgba(0,0,0,.03);left:0;top:0}.pcr-app.visible{transition:opacity .3s;visibility:visible;opacity:1}.pcr-app .pcr-swatches{display:flex;flex-wrap:wrap;margin-top:.75em}.pcr-app .pcr-swatches.pcr-last{margin:0}@supports(display: grid){.pcr-app .pcr-swatches{display:grid;align-items:center;grid-template-columns:repeat(auto-fit, 1.75em)}}.pcr-app .pcr-swatches>button{font-size:1em;position:relative;width:calc(1.75em - 5px);height:calc(1.75em - 5px);border-radius:.15em;cursor:pointer;margin:2.5px;flex-shrink:0;justify-self:center;transition:all .15s;overflow:hidden;background:rgba(0,0,0,0);z-index:1}.pcr-app .pcr-swatches>button::before{position:absolute;content:\"\";top:0;left:0;width:100%;height:100%;background:url(\"data:image/svg+xml;utf8, <svg xmlns=\\\"http://www.w3.org/2000/svg\\\" viewBox=\\\"0 0 2 2\\\"><path fill=\\\"white\\\" d=\\\"M1,0H2V1H1V0ZM0,1H1V2H0V1Z\\\"/><path fill=\\\"gray\\\" d=\\\"M0,0H1V1H0V0ZM1,1H2V2H1V1Z\\\"/></svg>\");background-size:6px;border-radius:.15em;z-index:-1}.pcr-app .pcr-swatches>button::after{content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background:var(--pcr-color);border:1px solid rgba(0,0,0,.05);border-radius:.15em;box-sizing:border-box}.pcr-app .pcr-swatches>button:hover{filter:brightness(1.05)}.pcr-app .pcr-swatches>button:not(.pcr-active){box-shadow:none}.pcr-app .pcr-interaction{display:flex;flex-wrap:wrap;align-items:center;margin:0 -0.2em 0 -0.2em}.pcr-app .pcr-interaction>*{margin:0 .2em}.pcr-app .pcr-interaction input{letter-spacing:.07em;font-size:.75em;text-align:center;cursor:pointer;color:#75797e;background:#f1f3f4;border-radius:.15em;transition:all .15s;padding:.45em .5em;margin-top:.75em}.pcr-app .pcr-interaction input:hover{filter:brightness(0.975)}.pcr-app .pcr-interaction input:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(66,133,244,.75)}.pcr-app .pcr-interaction .pcr-result{color:#75797e;text-align:left;flex:1 1 8em;min-width:8em;transition:all .2s;border-radius:.15em;background:#f1f3f4;cursor:text}.pcr-app .pcr-interaction .pcr-result::-moz-selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-result::selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-type.active{color:#fff;background:#4285f4}.pcr-app .pcr-interaction .pcr-save,.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{color:#fff;width:auto}.pcr-app .pcr-interaction .pcr-save,.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{color:#fff}.pcr-app .pcr-interaction .pcr-save:hover,.pcr-app .pcr-interaction .pcr-cancel:hover,.pcr-app .pcr-interaction .pcr-clear:hover{filter:brightness(0.925)}.pcr-app .pcr-interaction .pcr-save{background:#4285f4}.pcr-app .pcr-interaction .pcr-clear,.pcr-app .pcr-interaction .pcr-cancel{background:#f44250}.pcr-app .pcr-interaction .pcr-clear:focus,.pcr-app .pcr-interaction .pcr-cancel:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(244,66,80,.75)}.pcr-app .pcr-selection .pcr-picker{position:absolute;height:18px;width:18px;border:2px solid #fff;border-radius:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none}.pcr-app .pcr-selection .pcr-color-palette,.pcr-app .pcr-selection .pcr-color-chooser,.pcr-app .pcr-selection .pcr-color-opacity{position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none;display:flex;flex-direction:column;cursor:grab;cursor:-webkit-grab}.pcr-app .pcr-selection .pcr-color-palette:active,.pcr-app .pcr-selection .pcr-color-chooser:active,.pcr-app .pcr-selection .pcr-color-opacity:active{cursor:grabbing;cursor:-webkit-grabbing}.pcr-app[data-theme=monolith]{width:14.25em;max-width:95vw;padding:.8em}.pcr-app[data-theme=monolith] .pcr-selection{display:flex;flex-direction:column;justify-content:space-between;flex-grow:1}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-preview{position:relative;z-index:1;width:100%;height:1em;display:flex;flex-direction:row;justify-content:space-between;margin-bottom:.5em}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-preview::before{position:absolute;content:\"\";top:0;left:0;width:100%;height:100%;background:url(\"data:image/svg+xml;utf8, <svg xmlns=\\\"http://www.w3.org/2000/svg\\\" viewBox=\\\"0 0 2 2\\\"><path fill=\\\"white\\\" d=\\\"M1,0H2V1H1V0ZM0,1H1V2H0V1Z\\\"/><path fill=\\\"gray\\\" d=\\\"M0,0H1V1H0V0ZM1,1H2V2H1V1Z\\\"/></svg>\");background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-preview .pcr-last-color{cursor:pointer;transition:background-color .3s,box-shadow .3s;border-radius:.15em 0 0 .15em;z-index:2}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-preview .pcr-current-color{border-radius:0 .15em .15em 0}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-preview .pcr-last-color,.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-preview .pcr-current-color{background:var(--pcr-color);width:50%;height:100%}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-palette{width:100%;height:8em;z-index:1}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-palette .pcr-palette{border-radius:.15em;width:100%;height:100%}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-palette .pcr-palette::before{position:absolute;content:\"\";top:0;left:0;width:100%;height:100%;background:url(\"data:image/svg+xml;utf8, <svg xmlns=\\\"http://www.w3.org/2000/svg\\\" viewBox=\\\"0 0 2 2\\\"><path fill=\\\"white\\\" d=\\\"M1,0H2V1H1V0ZM0,1H1V2H0V1Z\\\"/><path fill=\\\"gray\\\" d=\\\"M0,0H1V1H0V0ZM1,1H2V2H1V1Z\\\"/></svg>\");background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-chooser,.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-opacity{height:.5em;margin-top:.75em}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-chooser .pcr-picker,.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-opacity .pcr-picker{top:50%;transform:translateY(-50%)}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-chooser .pcr-slider,.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-opacity .pcr-slider{flex-grow:1;border-radius:50em}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-chooser .pcr-slider{background:linear-gradient(to right, hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%), hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(0, 100%, 50%))}.pcr-app[data-theme=monolith] .pcr-selection .pcr-color-opacity .pcr-slider{background:linear-gradient(to right, transparent, black),url(\"data:image/svg+xml;utf8, <svg xmlns=\\\"http://www.w3.org/2000/svg\\\" viewBox=\\\"0 0 2 2\\\"><path fill=\\\"white\\\" d=\\\"M1,0H2V1H1V0ZM0,1H1V2H0V1Z\\\"/><path fill=\\\"gray\\\" d=\\\"M0,0H1V1H0V0ZM1,1H2V2H1V1Z\\\"/></svg>\");background-size:100%,.25em}"
  },
  {
    "path": "app/channels/application_cable/channel.rb",
    "content": "module ApplicationCable\n  class Channel < ActionCable::Channel::Base\n  end\nend\n"
  },
  {
    "path": "app/channels/application_cable/connection.rb",
    "content": "module ApplicationCable\n  class Connection < ActionCable::Connection::Base\n    rescue_from StandardError, with: :report_error\n\n    private\n      def report_error(e)\n        Sentry.capture_exception(e)\n      end\n  end\nend\n"
  },
  {
    "path": "app/components/DS/alert.html.erb",
    "content": "<div class=\"<%= container_classes %>\">\n  <%= helpers.icon icon_name, size: \"sm\", color: icon_color, class: \"shrink-0\" %>\n\n  <div class=\"flex-1 text-sm\">\n    <%= message %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/components/DS/alert.rb",
    "content": "class DS::Alert < DesignSystemComponent\n  def initialize(message:, variant: :info)\n    @message = message\n    @variant = variant\n  end\n\n  private\n    attr_reader :message, :variant\n\n    def container_classes\n      base_classes = \"flex items-start gap-3 p-4 rounded-lg border\"\n\n      variant_classes = case variant\n      when :info\n        \"bg-blue-50 text-blue-700 border-blue-200 theme-dark:bg-blue-900/20 theme-dark:text-blue-400 theme-dark:border-blue-800\"\n      when :success\n        \"bg-green-50 text-green-700 border-green-200 theme-dark:bg-green-900/20 theme-dark:text-green-400 theme-dark:border-green-800\"\n      when :warning\n        \"bg-yellow-50 text-yellow-700 border-yellow-200 theme-dark:bg-yellow-900/20 theme-dark:text-yellow-400 theme-dark:border-yellow-800\"\n      when :error, :destructive\n        \"bg-red-50 text-red-700 border-red-200 theme-dark:bg-red-900/20 theme-dark:text-red-400 theme-dark:border-red-800\"\n      end\n\n      \"#{base_classes} #{variant_classes}\"\n    end\n\n    def icon_name\n      case variant\n      when :info\n        \"info\"\n      when :success\n        \"check-circle\"\n      when :warning\n        \"alert-triangle\"\n      when :error, :destructive\n        \"x-circle\"\n      end\n    end\n\n    def icon_color\n      case variant\n      when :success\n        \"success\"\n      when :warning\n        \"warning\"\n      when :error, :destructive\n        \"destructive\"\n      else\n        \"blue-600\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/components/DS/button.html.erb",
    "content": "<%= container do %>\n  <% if icon && (icon_position != :right) %>\n    <%= helpers.icon(icon, size: size, color: icon_color, class: icon_classes) %>\n  <% end %>\n\n  <% unless icon_only? %>\n    <%= text %>\n  <% end %>\n\n  <% if icon && icon_position == :right %>\n    <%= helpers.icon(icon, size: size, color: icon_color) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/components/DS/button.rb",
    "content": "# frozen_string_literal: true\n\n# An extension to `button_to` helper.  All options are passed through to the `button_to` helper with some additional\n# options available.\nclass DS::Button < DS::Buttonish\n  attr_reader :confirm\n\n  def initialize(confirm: nil, **opts)\n    super(**opts)\n    @confirm = confirm\n  end\n\n  def container(&block)\n    if href.present?\n      button_to(href, **merged_opts, &block)\n    else\n      content_tag(:button, **merged_opts, &block)\n    end\n  end\n\n  private\n    def merged_opts\n      merged_opts = opts.dup || {}\n      extra_classes = merged_opts.delete(:class)\n      href = merged_opts.delete(:href)\n      data = merged_opts.delete(:data) || {}\n\n      if confirm.present?\n        data = data.merge(turbo_confirm: confirm.to_data_attribute)\n      end\n\n      if frame.present?\n        data = data.merge(turbo_frame: frame)\n      end\n\n      merged_opts.merge(\n        class: class_names(container_classes, extra_classes),\n        data: data\n      )\n    end\nend\n"
  },
  {
    "path": "app/components/DS/buttonish.rb",
    "content": "class DS::Buttonish < DesignSystemComponent\n  VARIANTS = {\n    primary: {\n      container_classes: \"text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400\",\n      icon_classes: \"fg-inverse\"\n    },\n    secondary: {\n      container_classes: \"text-primary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600\",\n      icon_classes: \"fg-primary\"\n    },\n    destructive: {\n      container_classes: \"text-inverse bg-red-500 theme-dark:bg-red-400 hover:bg-red-600 theme-dark:hover:bg-red-500 disabled:bg-red-200 theme-dark:disabled:bg-red-600\",\n      icon_classes: \"fg-white\"\n    },\n    outline: {\n      container_classes: \"text-primary border border-secondary bg-transparent hover:bg-surface-hover\",\n      icon_classes: \"fg-gray\"\n    },\n    outline_destructive: {\n      container_classes: \"text-destructive border border-secondary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700\",\n      icon_classes: \"fg-gray\"\n    },\n    ghost: {\n      container_classes: \"text-primary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700\",\n      icon_classes: \"fg-gray\"\n    },\n    icon: {\n      container_classes: \"hover:bg-gray-100 theme-dark:hover:bg-gray-700\",\n      icon_classes: \"fg-gray\"\n    },\n    icon_inverse: {\n      container_classes: \"bg-inverse hover:bg-inverse-hover\",\n      icon_classes: \"fg-inverse\"\n    }\n  }.freeze\n\n  SIZES = {\n    sm: {\n      container_classes: \"px-2 py-1\",\n      icon_container_classes: \"inline-flex items-center justify-center w-8 h-8\",\n      radius_classes: \"rounded-md\",\n      text_classes: \"text-sm\"\n    },\n    md: {\n      container_classes: \"px-3 py-2\",\n      icon_container_classes: \"inline-flex items-center justify-center w-9 h-9\",\n      radius_classes: \"rounded-lg\",\n      text_classes: \"text-sm\"\n    },\n    lg: {\n      container_classes: \"px-4 py-3\",\n      icon_container_classes: \"inline-flex items-center justify-center w-10 h-10\",\n      radius_classes: \"rounded-xl\",\n      text_classes: \"text-base\"\n    }\n  }.freeze\n\n  attr_reader :variant, :size, :href, :icon, :icon_position, :text, :full_width, :extra_classes, :frame, :opts\n\n  def initialize(variant: :primary, size: :md, href: nil, text: nil, icon: nil, icon_position: :left, full_width: false, frame: nil, **opts)\n    @variant = variant.to_s.underscore.to_sym\n    @size = size.to_sym\n    @href = href\n    @icon = icon\n    @icon_position = icon_position.to_sym\n    @text = text\n    @full_width = full_width\n    @extra_classes = opts.delete(:class)\n    @frame = frame\n    @opts = opts\n  end\n\n  def call\n    raise NotImplementedError, \"Buttonish is an abstract class and cannot be instantiated directly.\"\n  end\n\n  def container_classes(override_classes = nil)\n    class_names(\n      \"font-medium whitespace-nowrap\",\n      merged_base_classes,\n      full_width ? \"w-full justify-center\" : nil,\n      container_size_classes,\n      size_data.dig(:text_classes),\n      variant_data.dig(:container_classes)\n    )\n  end\n\n  def container_size_classes\n    icon_only? ? size_data.dig(:icon_container_classes) : size_data.dig(:container_classes)\n  end\n\n  def icon_color\n    # Map variant to icon color for the icon helper\n    case variant\n    when :primary, :icon_inverse\n      :white\n    when :destructive, :outline_destructive\n      :destructive\n    else\n      :default\n    end\n  end\n\n  def icon_classes\n    class_names(\n      variant_data.dig(:icon_classes)\n    )\n  end\n\n  def icon_only?\n    variant.in?([ :icon, :icon_inverse ])\n  end\n\n  private\n    def variant_data\n      self.class::VARIANTS.dig(variant)\n    end\n\n    def size_data\n      self.class::SIZES.dig(size)\n    end\n\n    # Make sure that user can override common classes like `hidden`\n    def merged_base_classes\n      base_display_classes = \"inline-flex items-center gap-1\"\n      base_radius_classes = size_data.dig(:radius_classes)\n\n      extra_classes_list = (extra_classes || \"\").split\n\n      has_display_override = extra_classes_list.any? { |c| permitted_display_override_classes.include?(c) }\n      has_radius_override = extra_classes_list.any? { |c| permitted_radius_override_classes.include?(c) }\n\n      base_classes = []\n\n      unless has_display_override\n        base_classes << base_display_classes\n      end\n\n      unless has_radius_override\n        base_classes << base_radius_classes\n      end\n\n      class_names(\n        base_classes,\n        extra_classes\n      )\n    end\n\n    def permitted_radius_override_classes\n      [ \"rounded-full\" ]\n    end\n\n    def permitted_display_override_classes\n      [ \"hidden\", \"flex\" ]\n    end\nend\n"
  },
  {
    "path": "app/components/DS/dialog.html.erb",
    "content": "<%= wrapper_element do %>\n  <%= tag.dialog class: \"w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay #{drawer? ? \"lg:p-3\" : \"lg:p-1\"}\", **merged_opts do %>\n    <%= tag.div class: dialog_outer_classes do %>\n      <%= tag.div class: dialog_inner_classes, data: { DS__dialog_target: \"content\" } do %>\n        <div class=\"grow overflow-y-auto py-4 space-y-4 flex flex-col\">\n          <% if header? %>\n            <%= header %>\n          <% end %>\n\n          <% if body? %>\n            <div class=\"px-4 grow\">\n              <%= body %>\n\n              <% if sections.any? %>\n                <div class=\"space-y-4\">\n                  <% sections.each do |section| %>\n                    <%= section %>\n                  <% end %>\n                </div>\n              <% end %>\n            </div>\n          <% end %>\n\n          <%# Optional, for customizing dialogs %>\n          <%= content %>\n        </div>\n\n        <% if actions? %>\n          <div class=\"flex items-center gap-2 justify-end p-4\">\n            <% actions.each do |action| %>\n              <%= action %>\n            <% end %>\n          </div>\n        <% end %>\n      <% end %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/components/DS/dialog.rb",
    "content": "class DS::Dialog < DesignSystemComponent\n  renders_one :header, ->(title: nil, subtitle: nil, hide_close_icon: false, **opts, &block) do\n    content_tag(:header, class: \"px-4 flex flex-col gap-2\", **opts) do\n      title_div = content_tag(:div, class: \"flex items-center justify-between gap-2\") do\n        title = content_tag(:h2, title, class: class_names(\"font-medium text-primary\", drawer? ? \"text-lg\" : \"\")) if title\n        close_icon = render DS::Button.new(variant: \"icon\", class: \"ml-auto\", icon: \"x\", tabindex: \"-1\", data: { action: \"DS--dialog#close\" }) unless hide_close_icon\n        safe_join([ title, close_icon ].compact)\n      end\n\n      subtitle = content_tag(:p, subtitle, class: \"text-sm text-secondary\") if subtitle\n\n      block_content = capture(&block) if block\n\n      safe_join([ title_div, subtitle, block_content ].compact)\n    end\n  end\n\n  renders_one :body\n\n  renders_many :actions, ->(cancel_action: false, **button_opts) do\n    merged_opts = if cancel_action\n      button_opts.merge(type: \"button\", data: { action: \"DS--dialog#close\" })\n    else\n      button_opts\n    end\n\n    render DS::Button.new(**merged_opts)\n  end\n\n  renders_many :sections, ->(title:, **disclosure_opts, &block) do\n    render DS::Disclosure.new(title: title, align: :right, **disclosure_opts) do\n      block.call\n    end\n  end\n\n  attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :opts\n\n  VARIANTS = %w[modal drawer].freeze\n  WIDTHS = {\n    sm: \"lg:max-w-[300px]\",\n    md: \"lg:max-w-[550px]\",\n    lg: \"lg:max-w-[700px]\",\n    full: \"lg:max-w-full\"\n  }.freeze\n\n  def initialize(variant: \"modal\", auto_open: true, reload_on_close: false, width: \"md\", frame: nil, disable_frame: false, **opts)\n    @variant = variant.to_sym\n    @auto_open = auto_open\n    @reload_on_close = reload_on_close\n    @width = width.to_sym\n    @frame = frame\n    @disable_frame = disable_frame\n    @opts = opts\n  end\n\n  def frame\n    @frame || variant\n  end\n\n  # Caller must \"opt-out\" of using the default turbo-frame based on the variant\n  def wrapper_element(&block)\n    if disable_frame\n      content_tag(:div, &block)\n    else\n      content_tag(\"turbo-frame\", id: frame, &block)\n    end\n  end\n\n  def dialog_outer_classes\n    variant_classes = if drawer?\n      \"items-end justify-end\"\n    else\n      \"items-center justify-center\"\n    end\n\n    class_names(\n      \"flex h-full w-full\",\n      variant_classes\n    )\n  end\n\n  def dialog_inner_classes\n    variant_classes = if drawer?\n      \"lg:w-[550px] h-full\"\n    else\n      class_names(\n        \"max-h-full\",\n        WIDTHS[width]\n      )\n    end\n\n    class_names(\n      \"flex flex-col bg-container rounded-xl shadow-border-xs mx-3 lg:mx-0 w-full overflow-hidden\",\n      variant_classes\n    )\n  end\n\n  def merged_opts\n    merged_opts = opts.dup\n    data = merged_opts.delete(:data) || {}\n\n    data[:controller] = [ \"DS--dialog\", \"hotkey\", data[:controller] ].compact.join(\" \")\n    data[:DS__dialog_auto_open_value] = auto_open\n    data[:DS__dialog_reload_on_close_value] = reload_on_close\n    data[:action] = [ \"mousedown->DS--dialog#clickOutside\", data[:action] ].compact.join(\" \")\n    data[:hotkey] = \"esc:DS--dialog#close\"\n    merged_opts[:data] = data\n\n    merged_opts\n  end\n\n  def drawer?\n    variant == :drawer\n  end\nend\n"
  },
  {
    "path": "app/components/DS/dialog_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"dialog\"\nexport default class extends Controller {\n  static targets = [\"content\"]\n\n  static values = {\n    autoOpen: { type: Boolean, default: false },\n    reloadOnClose: { type: Boolean, default: false },\n  };\n\n  connect() {\n    if (this.element.open) return;\n    if (this.autoOpenValue) {\n      this.element.showModal();\n    }\n  }\n  \n  // If the user clicks anywhere outside of the visible content, close the dialog\n  clickOutside(e) {\n    if (!this.contentTarget.contains(e.target)) {\n      this.close();\n    }\n  }\n\n  close() {\n    this.element.close();\n\n    if (this.reloadOnCloseValue) {\n      Turbo.visit(window.location.href);\n    }\n  }\n}\n"
  },
  {
    "path": "app/components/DS/disclosure.html.erb",
    "content": "<details class=\"group\" <%= \"open\" if open %>>\n  <%= tag.summary class: class_names(\n    \"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface\"\n  ) do %>\n    <% if summary_content? %>\n      <%= summary_content %>\n    <% else %>\n      <div class=\"flex items-center gap-3\">\n        <% if align == :left %>\n          <%= helpers.icon \"chevron-right\", class: \"group-open:transform group-open:rotate-90\" %>\n        <% end %>\n\n        <%= tag.span class: class_names(\"font-medium\", align == :left ? \"text-sm text-primary\" : \"text-xs uppercase text-secondary\") do %>\n          <%= title %>\n        <% end %>\n      </div>\n\n      <% if align == :right %>\n        <%= helpers.icon \"chevron-down\", class: \"group-open:transform group-open:rotate-180\" %>\n      <% end %>\n    <% end %>\n  <% end %>\n\n  <div class=\"mt-2\">\n    <%= content %>\n  </div>\n</details>\n"
  },
  {
    "path": "app/components/DS/disclosure.rb",
    "content": "class DS::Disclosure < DesignSystemComponent\n  renders_one :summary_content\n\n  attr_reader :title, :align, :open, :opts\n\n  def initialize(title: nil, align: \"right\", open: false, **opts)\n    @title = title\n    @align = align.to_sym\n    @open = open\n    @opts = opts\n  end\nend\n"
  },
  {
    "path": "app/components/DS/filled_icon.html.erb",
    "content": "<%= tag.div style: transparent? ? container_styles : nil,\n            class: container_classes do %>\n  <% if icon %>\n    <%= helpers.icon(icon, size: icon_size, color: \"current\") %>\n  <% elsif text %>\n    <%= tag.span text.first, class: text_classes %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/components/DS/filled_icon.rb",
    "content": "class DS::FilledIcon < DesignSystemComponent\n  attr_reader :icon, :text, :hex_color, :size, :rounded, :variant\n\n  VARIANTS = %i[default text surface container inverse].freeze\n\n  SIZES = {\n    sm: {\n      container_size: \"w-6 h-6\",\n      container_radius: \"rounded-md\",\n      icon_size: \"sm\",\n      text_size: \"text-xs\"\n    },\n    md: {\n      container_size: \"w-8 h-8\",\n      container_radius: \"rounded-lg\",\n      icon_size: \"md\",\n      text_size: \"text-xs\"\n    },\n    lg: {\n      container_size: \"w-9 h-9\",\n      container_radius: \"rounded-xl\",\n      icon_size: \"lg\",\n      text_size: \"text-sm\"\n    }\n  }.freeze\n\n  def initialize(variant: :default, icon: nil, text: nil, hex_color: nil, size: \"md\", rounded: false)\n    @variant = variant.to_sym\n    @icon = icon\n    @text = text\n    @hex_color = hex_color\n    @size = size.to_sym\n    @rounded = rounded\n  end\n\n  def container_classes\n    class_names(\n      \"flex justify-center items-center shrink-0\",\n      size_classes,\n      radius_classes,\n      transparent? ? \"border\" : solid_bg_class\n    )\n  end\n\n  def icon_size\n    SIZES[size][:icon_size]\n  end\n\n  def text_classes\n    class_names(\n      \"text-center font-medium uppercase\",\n      SIZES[size][:text_size]\n    )\n  end\n\n  def container_styles\n    <<~STYLE.strip\n      background-color: #{transparent_bg_color};\n      border-color: #{transparent_border_color};\n      color: #{custom_fg_color};\n    STYLE\n  end\n\n  def transparent?\n    variant.in?(%i[default text])\n  end\n\n  private\n    def solid_bg_class\n      case variant\n      when :surface\n        \"bg-surface-inset\"\n      when :container\n        \"bg-container-inset\"\n      when :inverse\n        \"bg-container\"\n      end\n    end\n\n    def size_classes\n      SIZES[size][:container_size]\n    end\n\n    def radius_classes\n      rounded ? \"rounded-full\" : SIZES[size][:container_radius]\n    end\n\n    def custom_fg_color\n      hex_color || \"var(--color-gray-500)\"\n    end\n\n    def transparent_bg_color\n      \"color-mix(in oklab, #{custom_fg_color} 10%, transparent)\"\n    end\n\n    def transparent_border_color\n      \"color-mix(in oklab, #{custom_fg_color} 10%, transparent)\"\n    end\nend\n"
  },
  {
    "path": "app/components/DS/link.html.erb",
    "content": "<%= link_to href, **merged_opts do %>\n  <% if icon && (icon_position != \"right\") %>\n    <%= helpers.icon(icon, size: size, color: icon_color) %>\n  <% end %>\n\n  <% unless icon_only? %>\n    <%= text %>\n  <% end %>\n\n  <% if icon && icon_position == \"right\" %>\n    <%= helpers.icon(icon, size: size, color: icon_color) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/components/DS/link.rb",
    "content": "# An extension to `link_to` helper.  All options are passed through to the `link_to` helper with some additional\n# options available.\nclass DS::Link < DS::Buttonish\n  attr_reader :frame\n\n  VARIANTS = VARIANTS.reverse_merge(\n    default: {\n      container_classes: \"\",\n      icon_classes: \"fg-gray\"\n    }\n  ).freeze\n\n  def merged_opts\n    merged_opts = opts.dup || {}\n    data = merged_opts.delete(:data) || {}\n\n    if frame\n      data = data.merge(turbo_frame: frame)\n    end\n\n    merged_opts.merge(\n      class: class_names(container_classes, extra_classes),\n      data: data\n    )\n  end\n\n  private\n    def container_size_classes\n      super unless variant == :default\n    end\nend\n"
  },
  {
    "path": "app/components/DS/menu.html.erb",
    "content": "<%= tag.div data: { controller: \"DS--menu\", DS__menu_placement_value: placement, DS__menu_offset_value: offset, testid: testid } do %>\n  <% if variant == :icon %>\n    <%= render DS::Button.new(variant: \"icon\", icon: icon_vertical ? \"more-vertical\" : \"more-horizontal\", data: { DS__menu_target: \"button\" }) %>\n  <% elsif variant == :button %>\n    <%= button %>\n  <% elsif variant == :avatar %>\n    <button data-DS--menu-target=\"button\">\n      <div class=\"w-9 h-9 cursor-pointer\">\n        <%= render \"settings/user_avatar\", avatar_url: avatar_url, initials: initials %>\n      </div>\n    </button>\n  <% end %>\n\n  <div data-DS--menu-target=\"content\" class=\"px-2 lg:px-0 max-w-full hidden z-50\">\n    <div class=\"mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg\">\n      <%= header %>\n\n      <%= tag.div class: class_names(\"py-1\" => !no_padding) do %>\n        <% items.each do |item| %>\n          <%= item %>\n        <% end %>\n\n        <%= custom_content %>\n      <% end %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/components/DS/menu.rb",
    "content": "# frozen_string_literal: true\n\nclass DS::Menu < DesignSystemComponent\n  attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid\n\n  renders_one :button, ->(**button_options, &block) do\n    options_with_target = button_options.merge(data: { DS__menu_target: \"button\" })\n\n    if block\n      content_tag(:button, **options_with_target, &block)\n    else\n      DS::Button.new(**options_with_target)\n    end\n  end\n\n  renders_one :header, ->(&block) do\n    content_tag(:div, class: \"border-b border-tertiary\", &block)\n  end\n\n  renders_one :custom_content\n\n  renders_many :items, DS::MenuItem\n\n  VARIANTS = %i[icon button avatar].freeze\n\n  def initialize(variant: \"icon\", avatar_url: nil, initials: nil, placement: \"bottom-end\", offset: 12, icon_vertical: false, no_padding: false, testid: nil)\n    @variant = variant.to_sym\n    @avatar_url = avatar_url\n    @initials = initials\n    @placement = placement\n    @offset = offset\n    @icon_vertical = icon_vertical\n    @no_padding = no_padding\n    @testid = testid\n\n    raise ArgumentError, \"Invalid variant: #{@variant}\" unless VARIANTS.include?(@variant)\n  end\nend\n"
  },
  {
    "path": "app/components/DS/menu_controller.js",
    "content": "import {\n  autoUpdate,\n  computePosition,\n  flip,\n  offset,\n  shift,\n} from \"@floating-ui/dom\";\nimport { Controller } from \"@hotwired/stimulus\";\n\n/**\n * A \"menu\" can contain arbitrary content including non-clickable items, links, buttons, and forms.\n */\nexport default class extends Controller {\n  static targets = [\"button\", \"content\"];\n\n  static values = {\n    show: Boolean,\n    placement: { type: String, default: \"bottom-end\" },\n    offset: { type: Number, default: 6 },\n  };\n\n  connect() {\n    this.show = this.showValue;\n    this.boundUpdate = this.update.bind(this);\n    this.addEventListeners();\n    this.startAutoUpdate();\n  }\n\n  disconnect() {\n    this.removeEventListeners();\n    this.stopAutoUpdate();\n    this.close();\n  }\n\n  addEventListeners() {\n    this.buttonTarget.addEventListener(\"click\", this.toggle);\n    this.element.addEventListener(\"keydown\", this.handleKeydown);\n    document.addEventListener(\"click\", this.handleOutsideClick);\n    document.addEventListener(\"turbo:load\", this.handleTurboLoad);\n  }\n\n  removeEventListeners() {\n    this.buttonTarget.removeEventListener(\"click\", this.toggle);\n    this.element.removeEventListener(\"keydown\", this.handleKeydown);\n    document.removeEventListener(\"click\", this.handleOutsideClick);\n    document.removeEventListener(\"turbo:load\", this.handleTurboLoad);\n  }\n\n  handleTurboLoad = () => {\n    if (!this.show) this.close();\n  };\n\n  handleOutsideClick = (event) => {\n    if (this.show && !this.element.contains(event.target)) this.close();\n  };\n\n  handleKeydown = (event) => {\n    if (event.key === \"Escape\") {\n      this.close();\n      this.buttonTarget.focus();\n    }\n  };\n\n  toggle = () => {\n    this.show = !this.show;\n    this.contentTarget.classList.toggle(\"hidden\", !this.show);\n    if (this.show) {\n      this.update();\n      this.focusFirstElement();\n    }\n  };\n\n  close() {\n    this.show = false;\n    this.contentTarget.classList.add(\"hidden\");\n  }\n\n  focusFirstElement() {\n    const focusableElements =\n      'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])';\n    const firstFocusableElement =\n      this.contentTarget.querySelectorAll(focusableElements)[0];\n    if (firstFocusableElement) {\n      firstFocusableElement.focus();\n    }\n  }\n\n  startAutoUpdate() {\n    if (!this._cleanup) {\n      this._cleanup = autoUpdate(\n        this.buttonTarget,\n        this.contentTarget,\n        this.boundUpdate,\n      );\n    }\n  }\n\n  stopAutoUpdate() {\n    if (this._cleanup) {\n      this._cleanup();\n      this._cleanup = null;\n    }\n  }\n\n  update() {\n    computePosition(this.buttonTarget, this.contentTarget, {\n      placement: this.placementValue,\n      middleware: [offset(this.offsetValue), flip(), shift({ padding: 5 })],\n    }).then(({ x, y }) => {\n      Object.assign(this.contentTarget.style, {\n        position: \"fixed\",\n        left: `${x}px`,\n        top: `${y}px`,\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "app/components/DS/menu_item.html.erb",
    "content": "<% if variant == :divider %>\n  <%= render \"shared/ruler\", classes: \"my-1\" %>\n<% else %>\n  <div class=\"px-1\">\n    <%= wrapper do %>\n      <% if icon %>\n        <%= helpers.icon(icon, color: destructive? ? :destructive : :default) %>\n      <% end %>\n      <%= tag.span(text, class: text_classes) %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/components/DS/menu_item.rb",
    "content": "class DS::MenuItem < DesignSystemComponent\n  VARIANTS = %i[link button divider].freeze\n\n  attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :opts\n\n  def initialize(variant:, text: nil, icon: nil, href: nil, method: :post, destructive: false, confirm: nil, frame: nil, **opts)\n    @variant = variant.to_sym\n    @text = text\n    @icon = icon\n    @href = href\n    @method = method.to_sym\n    @destructive = destructive\n    @confirm = confirm\n    @frame = frame\n    @opts = opts\n    raise ArgumentError, \"Invalid variant: #{@variant}\" unless VARIANTS.include?(@variant)\n  end\n\n  def wrapper(&block)\n    if variant == :button\n      button_to href, method: method, class: container_classes, **merged_opts, &block\n    elsif variant == :link\n      link_to href, class: container_classes, **merged_opts, &block\n    else\n      nil\n    end\n  end\n\n  def text_classes\n    [\n      \"text-sm\",\n      destructive? ? \"text-destructive\" : \"text-primary\"\n    ].join(\" \")\n  end\n\n  def destructive?\n    method == :delete || destructive\n  end\n\n  private\n    def container_classes\n      [\n        \"flex items-center gap-2 p-2 rounded-md w-full\",\n        destructive? ? \"hover:bg-red-tint-5 theme-dark:hover:bg-red-tint-10\" : \"hover:bg-container-hover\"\n      ].join(\" \")\n    end\n\n    def merged_opts\n      merged_opts = opts.dup || {}\n      data = merged_opts.delete(:data) || {}\n\n      if confirm.present?\n        data = data.merge(turbo_confirm: confirm.to_data_attribute)\n      end\n\n      if frame.present?\n        data = data.merge(turbo_frame: frame)\n      end\n\n      merged_opts.merge(data: data)\n    end\nend\n"
  },
  {
    "path": "app/components/DS/tab.rb",
    "content": "class DS::Tab < DesignSystemComponent\n  attr_reader :id, :label\n\n  def initialize(id:, label:)\n    @id = id\n    @label = label\n  end\n\n  def call\n    content\n  end\nend\n"
  },
  {
    "path": "app/components/DS/tabs/nav.rb",
    "content": "class DS::Tabs::Nav < DesignSystemComponent\n  erb_template <<~ERB\n    <%= tag.nav class: classes do %>\n      <% btns.each do |btn| %>\n        <%= btn %>\n      <% end %>\n    <% end %>\n  ERB\n\n  renders_many :btns, ->(id:, label:, classes: nil, &block) do\n    content_tag(\n      :button, label, id: id,\n      type: \"button\",\n      class: class_names(btn_classes, id == active_tab ? active_btn_classes : inactive_btn_classes, classes),\n      data: { id: id, action: \"DS--tabs#show\", DS__tabs_target: \"navBtn\" },\n      &block\n    )\n  end\n\n  attr_reader :active_tab, :classes, :active_btn_classes, :inactive_btn_classes, :btn_classes\n\n  def initialize(active_tab:, classes: nil, active_btn_classes: nil, inactive_btn_classes: nil, btn_classes: nil)\n    @active_tab = active_tab\n    @classes = classes\n    @active_btn_classes = active_btn_classes\n    @inactive_btn_classes = inactive_btn_classes\n    @btn_classes = btn_classes\n  end\nend\n"
  },
  {
    "path": "app/components/DS/tabs/panel.rb",
    "content": "class DS::Tabs::Panel < DesignSystemComponent\n  attr_reader :tab_id\n\n  def initialize(tab_id:)\n    @tab_id = tab_id\n  end\n\n  def call\n    content\n  end\nend\n"
  },
  {
    "path": "app/components/DS/tabs.html.erb",
    "content": "<%= tag.div data: {\n  controller: \"DS--tabs\",\n  testid: testid,\n  DS__tabs_session_key_value: session_key,\n  DS__tabs_url_param_key_value: url_param_key,\n  DS__tabs_nav_btn_active_class: active_btn_classes,\n  DS__tabs_nav_btn_inactive_class: inactive_btn_classes\n} do %>\n  <% if unstyled? %>\n    <%= content %>\n  <% else %>\n    <%= nav %>\n\n    <% panels.each do |panel| %>\n      <%= panel %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/components/DS/tabs.rb",
    "content": "class DS::Tabs < DesignSystemComponent\n  renders_one :nav, ->(classes: nil) do\n    DS::Tabs::Nav.new(\n      active_tab: active_tab,\n      active_btn_classes: active_btn_classes,\n      inactive_btn_classes: inactive_btn_classes,\n      btn_classes: base_btn_classes,\n      classes: unstyled? ? classes : class_names(nav_container_classes, classes)\n    )\n  end\n\n  renders_many :panels, ->(tab_id:, &block) do\n    content_tag(\n      :div,\n      class: (\"hidden\" unless tab_id == active_tab),\n      data: { id: tab_id, DS__tabs_target: \"panel\" },\n      &block\n    )\n  end\n\n  VARIANTS = {\n    default: {\n      active_btn_classes: \"bg-white theme-dark:bg-gray-700 text-primary shadow-sm\",\n      inactive_btn_classes: \"text-secondary hover:bg-surface-inset-hover\",\n      base_btn_classes: \"w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200\",\n      nav_container_classes: \"flex bg-surface-inset p-1 rounded-lg mb-4\"\n    }\n  }\n\n  attr_reader :active_tab, :url_param_key, :session_key, :variant, :testid\n\n  def initialize(active_tab:, url_param_key: nil, session_key: nil, variant: :default, active_btn_classes: \"\", inactive_btn_classes: \"\", testid: nil)\n    @active_tab = active_tab\n    @url_param_key = url_param_key\n    @session_key = session_key\n    @variant = variant.to_sym\n    @active_btn_classes = active_btn_classes\n    @inactive_btn_classes = inactive_btn_classes\n    @testid = testid\n  end\n\n  def active_btn_classes\n    unstyled? ? @active_btn_classes : VARIANTS.dig(variant, :active_btn_classes)\n  end\n\n  def inactive_btn_classes\n    unstyled? ? @inactive_btn_classes : VARIANTS.dig(variant, :inactive_btn_classes)\n  end\n\n  private\n    def unstyled?\n      variant == :unstyled\n    end\n\n    def base_btn_classes\n      unless unstyled?\n        VARIANTS.dig(variant, :base_btn_classes)\n      end\n    end\n\n    def nav_container_classes\n      unless unstyled?\n        VARIANTS.dig(variant, :nav_container_classes)\n      end\n    end\nend\n"
  },
  {
    "path": "app/components/DS/tabs_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"tabs--components\"\nexport default class extends Controller {\n  static classes = [\"navBtnActive\", \"navBtnInactive\"];\n  static targets = [\"panel\", \"navBtn\"];\n  static values = { sessionKey: String, urlParamKey: String };\n\n  show(e) {\n    const btn = e.target.closest(\"button\");\n    const selectedTabId = btn.dataset.id;\n\n    this.navBtnTargets.forEach((navBtn) => {\n      if (navBtn.dataset.id === selectedTabId) {\n        navBtn.classList.add(...this.navBtnActiveClasses);\n        navBtn.classList.remove(...this.navBtnInactiveClasses);\n      } else {\n        navBtn.classList.add(...this.navBtnInactiveClasses);\n        navBtn.classList.remove(...this.navBtnActiveClasses);\n      }\n    });\n\n    this.panelTargets.forEach((panel) => {\n      if (panel.dataset.id === selectedTabId) {\n        panel.classList.remove(\"hidden\");\n      } else {\n        panel.classList.add(\"hidden\");\n      }\n    });\n\n    if (this.urlParamKeyValue) {\n      const url = new URL(window.location.href);\n      url.searchParams.set(this.urlParamKeyValue, selectedTabId);\n      window.history.replaceState({}, \"\", url);\n    }\n\n    // Update URL with the selected tab\n    if (this.sessionKeyValue) {\n      this.#updateSessionPreference(selectedTabId);\n    }\n  } \n\n  #updateSessionPreference(selectedTabId) {\n    fetch(\"/current_session\", {\n      method: \"PUT\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n        \"X-CSRF-Token\": document.querySelector('[name=\"csrf-token\"]').content,\n        Accept: \"application/json\",\n      },\n      body: new URLSearchParams({\n        \"current_session[tab_key]\": this.sessionKeyValue,\n        \"current_session[tab_value]\": selectedTabId,\n      }).toString(),\n    });\n  }\n}\n"
  },
  {
    "path": "app/components/DS/toggle.html.erb",
    "content": "<div class=\"relative inline-block select-none\">\n  <%= hidden_field_tag name, unchecked_value, id: nil %>\n  <%= check_box_tag name, checked_value, checked, class: \"sr-only peer\", disabled: disabled, id: id, **opts %>\n  <%= label_tag name, \"&nbsp;\".html_safe, class: label_classes, for: id %>\n</div>\n"
  },
  {
    "path": "app/components/DS/toggle.rb",
    "content": "class DS::Toggle < DesignSystemComponent\n  attr_reader :id, :name, :checked, :disabled, :checked_value, :unchecked_value, :opts\n\n  def initialize(id:, name: nil, checked: false, disabled: false, checked_value: \"1\", unchecked_value: \"0\", **opts)\n    @id = id\n    @name = name\n    @checked = checked\n    @disabled = disabled\n    @checked_value = checked_value\n    @unchecked_value = unchecked_value\n    @opts = opts\n  end\n\n  def label_classes\n    class_names(\n       \"block w-9 h-5 cursor-pointer\",\n       \"rounded-full bg-gray-100 theme-dark:bg-gray-700\",\n       \"transition-colors duration-300\",\n       \"after:content-[''] after:block after:bg-white after:absolute after:rounded-full\",\n       \"after:top-0.5 after:left-0.5 after:w-4 after:h-4\",\n       \"after:transition-transform after:duration-300 after:ease-in-out\",\n       \"peer-checked:bg-green-600 peer-checked:after:translate-x-4\",\n       \"peer-disabled:opacity-70 peer-disabled:cursor-not-allowed\"\n    )\n  end\nend\n"
  },
  {
    "path": "app/components/DS/tooltip.html.erb",
    "content": "<span data-controller=\"DS--tooltip\" data-DS--tooltip-placement-value=\"<%= placement %>\" data-DS--tooltip-offset-value=\"<%= offset %>\" data-DS--tooltip-cross-axis-value=\"<%= cross_axis %>\" class=\"inline-flex\">\n  <%= helpers.icon icon_name, size: size, color: color %>\n\n  <div role=\"tooltip\" data-DS--tooltip-target=\"tooltip\" class=\"hidden absolute z-50 bg-gray-700 text-sm px-1.5 py-1 rounded-md\">\n    <div class=\"fg-inverse font-normal max-w-[200px]\">\n      <%= tooltip_content %>\n    </div>\n  </div>\n</span>\n"
  },
  {
    "path": "app/components/DS/tooltip.rb",
    "content": "class DS::Tooltip < ApplicationComponent\n  attr_reader :placement, :offset, :cross_axis, :icon_name, :size, :color\n\n  def initialize(text: nil, placement: \"top\", offset: 10, cross_axis: 0, icon: \"info\", size: \"sm\", color: \"default\")\n    @text = text\n    @placement = placement\n    @offset = offset\n    @cross_axis = cross_axis\n    @icon_name = icon\n    @size = size\n    @color = color\n  end\n\n  def tooltip_content\n    content? ? content : @text\n  end\nend\n"
  },
  {
    "path": "app/components/DS/tooltip_controller.js",
    "content": "import {\n  autoUpdate,\n  computePosition,\n  flip,\n  offset,\n  shift,\n} from \"@floating-ui/dom\";\nimport { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n  static targets = [\"tooltip\"];\n  static values = {\n    placement: { type: String, default: \"top\" },\n    offset: { type: Number, default: 10 },\n    crossAxis: { type: Number, default: 0 },\n  };\n\n  connect() {\n    this._cleanup = null;\n    this.boundUpdate = this.update.bind(this);\n    this.addEventListeners();\n  }\n\n  disconnect() {\n    this.removeEventListeners();\n    this.stopAutoUpdate();\n  }\n\n  addEventListeners() {\n    this.element.addEventListener(\"mouseenter\", this.show);\n    this.element.addEventListener(\"mouseleave\", this.hide);\n  }\n\n  removeEventListeners() {\n    this.element.removeEventListener(\"mouseenter\", this.show);\n    this.element.removeEventListener(\"mouseleave\", this.hide);\n  }\n\n  show = () => {\n    this.tooltipTarget.classList.remove(\"hidden\");\n    this.startAutoUpdate();\n    this.update();\n  };\n\n  hide = () => {\n    this.tooltipTarget.classList.add(\"hidden\");\n    this.stopAutoUpdate();\n  };\n\n  startAutoUpdate() {\n    if (!this._cleanup) {\n      const reference = this.element.querySelector(\"[data-icon]\");\n      this._cleanup = autoUpdate(\n        reference || this.element,\n        this.tooltipTarget,\n        this.boundUpdate\n      );\n    }\n  }\n\n  stopAutoUpdate() {\n    if (this._cleanup) {\n      this._cleanup();\n      this._cleanup = null;\n    }\n  }\n\n  update() {\n    const reference = this.element.querySelector(\"[data-icon]\");\n    computePosition(reference || this.element, this.tooltipTarget, {\n      placement: this.placementValue,\n      middleware: [\n        offset({\n          mainAxis: this.offsetValue,\n          crossAxis: this.crossAxisValue,\n        }),\n        flip(),\n        shift({ padding: 5 }),\n      ],\n    }).then(({ x, y }) => {\n      Object.assign(this.tooltipTarget.style, {\n        left: `${x}px`,\n        top: `${y}px`,\n      });\n    });\n  }\n}"
  },
  {
    "path": "app/components/UI/account/activity_date.html.erb",
    "content": "<%= tag.div id: id, data: { bulk_select_target: \"group\" }, class: \"bg-container-inset rounded-xl p-1 w-full\" do %>\n  <details class=\"group\">\n    <summary>\n      <div class=\"py-2 px-4 flex items-center justify-between font-medium text-xs text-secondary\">\n        <div class=\"flex pl-0.5 items-center gap-4\">\n          <%= check_box_tag \"#{date}_entries_selection\",\n                          class: [\"checkbox checkbox--light\", \"hidden\": entries.size == 0],\n                          id: \"selection_entry_#{date}\",\n                          data: { action: \"bulk-select#toggleGroupSelection\" } %>\n\n          <p class=\"uppercase space-x-1.5\">\n            <%= tag.span I18n.l(date, format: :long) %>\n            <span>&middot;</span>\n            <%= tag.span entries.size %>\n          </p>\n        </div>\n\n        <div class=\"flex items-center gap-4\">\n          <div class=\"flex items-center gap-2\">\n            <span class=\"font-medium\"><%= end_balance_money.format %></span>\n            <%= render DS::Tooltip.new(text: \"The end of day balance, after all transactions and adjustments\", placement: \"left\", size: \"sm\") %>\n          </div>\n          <%= helpers.icon \"chevron-down\", class: \"group-open:rotate-180\" %>\n        </div>\n      </div>\n    </summary>\n\n    <div class=\"p-4\">\n      <% if balance %>\n        <%= render UI::Account::BalanceReconciliation.new(balance: balance, account: account) %>\n      <% else %>\n        <p class=\"text-sm text-secondary\">No balance data available for this date</p>\n      <% end %>\n    </div>\n  </details>\n\n  <div class=\"bg-container shadow-border-xs rounded-lg\">\n    <% entries.each do |entry| %>\n      <%= render entry, view_ctx: \"account\" %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/components/UI/account/activity_date.rb",
    "content": "class UI::Account::ActivityDate < ApplicationComponent\n  attr_reader :account, :data\n\n  delegate :date, :entries, :balance, :transfers, to: :data\n\n  def initialize(account:, data:)\n    @account = account\n    @data = data\n  end\n\n  def id\n    dom_id(account, \"entries_#{date}\")\n  end\n\n  def broadcast_channel\n    account\n  end\n\n  def end_balance_money\n    balance&.end_balance_money || Money.new(0, account.currency)\n  end\n\n  def broadcast_refresh!\n    Turbo::StreamsChannel.broadcast_replace_to(\n      broadcast_channel,\n      target: id,\n      renderable: self,\n      layout: false\n    )\n  end\nend\n"
  },
  {
    "path": "app/components/UI/account/activity_feed.html.erb",
    "content": "<%= turbo_frame_tag dom_id(account, \"entries\") do %>\n  <div class=\"bg-container p-5 shadow-border-xs rounded-xl\">\n    <div class=\"flex items-center justify-between mb-4\" data-testid=\"activity-menu\">\n      <%= tag.h2 \"Activity\", class: \"font-medium text-lg\" %>\n\n      <% if account.manual? %>\n        <%= render DS::Menu.new(variant: \"button\") do |menu| %>\n          <% menu.with_button(text: \"New\", variant: \"secondary\", icon: \"plus\") %>\n\n          <% menu.with_item(\n              variant: \"link\",\n              text: \"New balance\",\n              icon: \"circle-dollar-sign\",\n              href: new_valuation_path(account_id: account.id),\n              data: { turbo_frame: :modal }) %>\n\n          <% unless account.crypto? %>\n            <% menu.with_item(\n                variant: \"link\",\n                text: \"New transaction\",\n                icon: \"credit-card\",\n                href: account.investment? ? new_trade_path(account_id: account.id) : new_transaction_path(account_id: account.id),\n                data: { turbo_frame: :modal }) %>\n          <% end %>\n        <% end %>\n      <% end %>\n    </div>\n\n    <div>\n      <%= form_with url: account_path(account),\n              id: \"entries-search\",\n              scope: :q,\n              method: :get,\n              data: { controller: \"auto-submit-form\" } do |form| %>\n        <div class=\"flex gap-2 mb-4\">\n          <div class=\"grow\">\n            <div class=\"flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900\">\n              <%= helpers.icon(\"search\") %>\n\n              <%= hidden_field_tag :account_id, account.id %>\n\n              <%= form.search_field :search,\n                            placeholder: \"Search entries by name\",\n                            value: search,\n                            class: \"form-field__input placeholder:text-sm placeholder:text-secondary\",\n                            \"data-auto-submit-form-target\": \"auto\" %>\n            </div>\n          </div>\n        </div>\n      <% end %>\n    </div>\n\n    <% if activity_dates.empty? %>\n      <p class=\"text-secondary text-sm p-4\">No entries yet</p>\n    <% else %>\n      <%= tag.div id: dom_id(account, \"entries_bulk_select\"),\n                data: {\n                  controller: \"bulk-select\",\n                  bulk_select_singular_label_value: \"entry\",\n                  bulk_select_plural_label_value: \"entries\"\n                } do %>\n        <div id=\"entry-selection-bar\" data-bulk-select-target=\"selectionBar\" class=\"flex justify-center hidden\">\n          <%= render \"entries/selection_bar\" %>\n        </div>\n\n        <div class=\"grid bg-container-inset rounded-xl grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-5 py-3 mb-4\">\n          <div class=\"pl-0.5 col-span-8 flex items-center gap-4\">\n            <%= check_box_tag \"selection_entry\",\n                              class: \"checkbox checkbox--light\",\n                              data: { action: \"bulk-select#togglePageSelection\" } %>\n            <p>Date</p>\n          </div>\n\n          <%= tag.p \"Amount\", class: \"col-span-4 justify-self-end\" %>\n        </div>\n\n        <div>\n          <div class=\"space-y-4\">\n            <% activity_dates.each do |activity_date_data| %>\n              <%= render UI::Account::ActivityDate.new(\n                    account: account,\n                    data: activity_date_data\n                  ) %>\n            <% end %>\n          </div>\n\n          <div class=\"p-4 bg-container rounded-bl-lg rounded-br-lg\">\n            <%= render \"shared/pagination\", pagy: pagy %>\n          </div>\n        </div>\n      <% end %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/components/UI/account/activity_feed.rb",
    "content": "class UI::Account::ActivityFeed < ApplicationComponent\n  attr_reader :feed_data, :pagy, :search\n\n  def initialize(feed_data:, pagy:, search: nil)\n    @feed_data = feed_data\n    @pagy = pagy\n    @search = search\n  end\n\n  def id\n    dom_id(account, :activity_feed)\n  end\n\n  def broadcast_channel\n    account\n  end\n\n  def broadcast_refresh!\n    Turbo::StreamsChannel.broadcast_replace_to(\n      broadcast_channel,\n      target: id,\n      renderable: self,\n      layout: false\n    )\n  end\n\n  def activity_dates\n    feed_data.entries_by_date\n  end\n\n  private\n    def account\n      feed_data.account\n    end\nend\n"
  },
  {
    "path": "app/components/UI/account/balance_reconciliation.html.erb",
    "content": "<div class=\"space-y-3\">\n  <% reconciliation_items.each_with_index do |item, index| %>\n    <% if item[:style] == :subtotal %>\n      <hr class=\"border border-primary\">\n    <% end %>\n\n    <dl class=\"flex gap-4 items-center text-sm text-primary\">\n      <dt class=\"flex items-center gap-2\">\n        <%= item[:label] %>\n        <%= render DS::Tooltip.new(text: item[:tooltip], placement: \"left\", size: \"sm\") %>\n      </dt>\n      <hr class=\"grow border-dashed <%= item[:style] == :final ? \"border-primary\" : \"border-secondary\" %>\">\n      <dd class=\"<%= item[:style] == :start || item[:style] == :final ? \"font-bold\" : item[:style] == :subtotal ? \"font-medium\" : \"\" %>\">\n        <%= item[:value].format %>\n      </dd>\n    </dl>\n\n    <% if item[:style] == :adjustment %>\n      <hr class=\"border border-primary\">\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/components/UI/account/balance_reconciliation.rb",
    "content": "class UI::Account::BalanceReconciliation < ApplicationComponent\n  attr_reader :balance, :account\n\n  def initialize(balance:, account:)\n    @balance = balance\n    @account = account\n  end\n\n  def reconciliation_items\n    case account.accountable_type\n    when \"Depository\", \"OtherAsset\", \"OtherLiability\"\n      default_items\n    when \"CreditCard\"\n      credit_card_items\n    when \"Investment\"\n      investment_items\n    when \"Loan\"\n      loan_items\n    when \"Property\", \"Vehicle\"\n      asset_items\n    when \"Crypto\"\n      crypto_items\n    else\n      default_items\n    end\n  end\n\n  private\n\n    def default_items\n      items = [\n        { label: \"Start balance\", value: balance.start_balance_money, tooltip: \"The account balance at the beginning of this day\", style: :start },\n        { label: \"Net cash flow\", value: net_cash_flow, tooltip: \"Net change in balance from all transactions during the day\", style: :flow }\n      ]\n\n      if has_adjustments?\n        items << { label: \"End balance\", value: end_balance_before_adjustments, tooltip: \"The calculated balance after all transactions\", style: :subtotal }\n        items << { label: \"Adjustments\", value: total_adjustments, tooltip: \"Manual reconciliations or other adjustments\", style: :adjustment }\n      end\n\n      items << { label: \"Final balance\", value: balance.end_balance_money, tooltip: \"The final account balance for the day\", style: :final }\n      items\n    end\n\n    def credit_card_items\n      items = [\n        { label: \"Start balance\", value: balance.start_balance_money, tooltip: \"The balance owed at the beginning of this day\", style: :start },\n        { label: \"Charges\", value: balance.cash_outflows_money, tooltip: \"New charges made during the day\", style: :flow },\n        { label: \"Payments\", value: balance.cash_inflows_money * -1, tooltip: \"Payments made to the card during the day\", style: :flow }\n      ]\n\n      if has_adjustments?\n        items << { label: \"End balance\", value: end_balance_before_adjustments, tooltip: \"The calculated balance after all transactions\", style: :subtotal }\n        items << { label: \"Adjustments\", value: total_adjustments, tooltip: \"Manual reconciliations or other adjustments\", style: :adjustment }\n      end\n\n      items << { label: \"Final balance\", value: balance.end_balance_money, tooltip: \"The final balance owed for the day\", style: :final }\n      items\n    end\n\n    def investment_items\n      items = [\n        { label: \"Start balance\", value: balance.start_balance_money, tooltip: \"The total portfolio value at the beginning of this day\", style: :start }\n      ]\n\n      # Change in brokerage cash (includes deposits, withdrawals, and cash from trades)\n      items << { label: \"Change in brokerage cash\", value: net_cash_flow, tooltip: \"Net change in cash from deposits, withdrawals, and trades\", style: :flow }\n\n      # Change in holdings from trading activity\n      items << { label: \"Change in holdings (buys/sells)\", value: net_non_cash_flow, tooltip: \"Impact on holdings from buying and selling securities\", style: :flow }\n\n      # Market price changes\n      items << { label: \"Change in holdings (market price activity)\", value: balance.net_market_flows_money, tooltip: \"Change in holdings value from market price movements\", style: :flow }\n\n      if has_adjustments?\n        items << { label: \"End balance\", value: end_balance_before_adjustments, tooltip: \"The calculated balance after all activity\", style: :subtotal }\n        items << { label: \"Adjustments\", value: total_adjustments, tooltip: \"Manual reconciliations or other adjustments\", style: :adjustment }\n      end\n\n      items << { label: \"Final balance\", value: balance.end_balance_money, tooltip: \"The final portfolio value for the day\", style: :final }\n      items\n    end\n\n    def loan_items\n      items = [\n        { label: \"Start principal\", value: balance.start_balance_money, tooltip: \"The principal balance at the beginning of this day\", style: :start },\n        { label: \"Net principal change\", value: net_non_cash_flow, tooltip: \"Principal payments and new borrowing during the day\", style: :flow }\n      ]\n\n      if has_adjustments?\n        items << { label: \"End principal\", value: end_balance_before_adjustments, tooltip: \"The calculated principal after all transactions\", style: :subtotal }\n        items << { label: \"Adjustments\", value: balance.non_cash_adjustments_money, tooltip: \"Manual reconciliations or other adjustments\", style: :adjustment }\n      end\n\n      items << { label: \"Final principal\", value: balance.end_balance_money, tooltip: \"The final principal balance for the day\", style: :final }\n      items\n    end\n\n    def asset_items # Property/Vehicle\n      items = [\n        { label: \"Start value\", value: balance.start_balance_money, tooltip: \"The asset value at the beginning of this day\", style: :start },\n        { label: \"Net value change\", value: net_total_flow, tooltip: \"All value changes including improvements and depreciation\", style: :flow }\n      ]\n\n      if has_adjustments?\n        items << { label: \"End value\", value: end_balance_before_adjustments, tooltip: \"The calculated value after all changes\", style: :subtotal }\n        items << { label: \"Adjustments\", value: total_adjustments, tooltip: \"Manual value adjustments or appraisals\", style: :adjustment }\n      end\n\n      items << { label: \"Final value\", value: balance.end_balance_money, tooltip: \"The final asset value for the day\", style: :final }\n      items\n    end\n\n    def crypto_items\n      items = [\n        { label: \"Start balance\", value: balance.start_balance_money, tooltip: \"The crypto holdings value at the beginning of this day\", style: :start }\n      ]\n\n      items << { label: \"Buys\", value: balance.cash_outflows_money * -1, tooltip: \"Crypto purchases during the day\", style: :flow } if balance.cash_outflows != 0\n      items << { label: \"Sells\", value: balance.cash_inflows_money, tooltip: \"Crypto sales during the day\", style: :flow } if balance.cash_inflows != 0\n      items << { label: \"Market changes\", value: balance.net_market_flows_money, tooltip: \"Value changes from market price movements\", style: :flow } if balance.net_market_flows != 0\n\n      if has_adjustments?\n        items << { label: \"End balance\", value: end_balance_before_adjustments, tooltip: \"The calculated balance after all activity\", style: :subtotal }\n        items << { label: \"Adjustments\", value: total_adjustments, tooltip: \"Manual reconciliations or other adjustments\", style: :adjustment }\n      end\n\n      items << { label: \"Final balance\", value: balance.end_balance_money, tooltip: \"The final crypto holdings value for the day\", style: :final }\n      items\n    end\n\n    def net_cash_flow\n      balance.cash_inflows_money - balance.cash_outflows_money\n    end\n\n    def net_non_cash_flow\n      balance.non_cash_inflows_money - balance.non_cash_outflows_money\n    end\n\n    def net_total_flow\n      net_cash_flow + net_non_cash_flow + balance.net_market_flows_money\n    end\n\n    def total_adjustments\n      balance.cash_adjustments_money + balance.non_cash_adjustments_money\n    end\n\n    def has_adjustments?\n      balance.cash_adjustments != 0 || balance.non_cash_adjustments != 0\n    end\n\n    def end_balance_before_adjustments\n      balance.end_balance_money - total_adjustments\n    end\nend\n"
  },
  {
    "path": "app/components/UI/account/chart.html.erb",
    "content": "<div id=\"<%= dom_id(account, :chart) %>\" class=\"bg-container shadow-border-xs rounded-xl space-y-2\">\n  <div class=\"flex justify-between flex-col-reverse lg:flex-row gap-2 px-4 pt-4 mb-2\">\n    <div class=\"space-y-2 w-full\">\n      <div class=\"flex items-center gap-1\">\n        <%= tag.p title, class: \"text-sm font-medium text-secondary\" %>\n\n        <% if account.investment? %>\n          <%= render \"investments/value_tooltip\", balance: account.balance_money, holdings: holdings_value_money, cash: account.cash_balance_money %>\n        <% end %>\n      </div>\n      <div class=\"flex flex-row gap-2 items-baseline\">\n        <%= tag.p view_balance_money.format, class: \"text-primary text-3xl font-medium truncate\" %>\n\n        <% if converted_balance_money %>\n          <%= tag.p converted_balance_money.format, class: \"text-sm font-medium text-secondary\" %>\n        <% end %>\n      </div>\n    </div>\n\n    <%= form_with url: account_path(account), method: :get, data: { controller: \"auto-submit-form\" } do |form| %>\n      <div class=\"flex items-center gap-2\">\n        <% if account.investment? %>\n          <%= form.select :chart_view,\n            [[\"Total value\", \"balance\"], [\"Holdings\", \"holdings_balance\"], [\"Cash\", \"cash_balance\"]],\n            { selected: view },\n            class: \"bg-container border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0\",\n            data: { \"auto-submit-form-target\": \"auto\" } %>\n        <% end %>\n\n        <%= form.select :period,\n                    Period.as_options,\n                    { selected: period.key },\n                    data: { \"auto-submit-form-target\": \"auto\" },\n                    class: \"bg-container border border-secondary rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0\" %>\n      </div>\n    <% end %>\n  </div>\n\n  <%= turbo_frame_tag dom_id(@account, :chart_details) do %>\n    <div class=\"px-4\">\n      <%= render partial: \"shared/trend_change\", locals: { trend: trend, comparison_label: period.comparison_label } %>\n    </div>\n\n    <div class=\"h-64 pb-4\">\n      <% if series.any? %>\n        <div\n        id=\"lineChart\"\n        class=\"w-full h-full\"\n        data-controller=\"time-series-chart\"\n        data-time-series-chart-data-value=\"<%= series.to_json %>\"></div>\n      <% else %>\n        <div class=\"w-full h-full flex items-center justify-center\">\n          <p class=\"text-secondary text-sm\">No data available</p>\n        </div>\n      <% end %>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/components/UI/account/chart.rb",
    "content": "class UI::Account::Chart < ApplicationComponent\n  attr_reader :account\n\n  def initialize(account:, period: nil, view: nil)\n    @account = account\n    @period = period\n    @view = view\n  end\n\n  def period\n    @period ||= Period.last_30_days\n  end\n\n  def holdings_value_money\n    account.balance_money - account.cash_balance_money\n  end\n\n  def view_balance_money\n    case view\n    when \"balance\"\n      account.balance_money\n    when \"holdings_balance\"\n      holdings_value_money\n    when \"cash_balance\"\n      account.cash_balance_money\n    end\n  end\n\n  def title\n    case account.accountable_type\n    when \"Investment\", \"Crypto\"\n      case view\n      when \"balance\"\n        \"Total account value\"\n      when \"holdings_balance\"\n        \"Holdings value\"\n      when \"cash_balance\"\n        \"Cash value\"\n      end\n    when \"Property\", \"Vehicle\"\n      \"Estimated #{account.accountable_type.humanize.downcase} value\"\n    when \"CreditCard\", \"OtherLiability\"\n      \"Debt balance\"\n    when \"Loan\"\n      \"Remaining principal balance\"\n    else\n      \"Balance\"\n    end\n  end\n\n  def foreign_currency?\n    account.currency != account.family.currency\n  end\n\n  def converted_balance_money\n    return nil unless foreign_currency?\n\n    account.balance_money.exchange_to(account.family.currency, fallback_rate: 1)\n  end\n\n  def view\n    @view ||= \"balance\"\n  end\n\n  def series\n    account.balance_series(period: period, view: view)\n  end\n\n  def trend\n    series.trend\n  end\nend\n"
  },
  {
    "path": "app/components/UI/account_page.html.erb",
    "content": "<%= turbo_stream_from account %>\n\n<%= turbo_frame_tag id do %>\n  <%= tag.div class: \"space-y-4 pb-32\" do %>\n    <%= render \"accounts/show/header\", account: account, title: title, subtitle: subtitle %>\n\n    <%= render UI::Account::Chart.new(account: account, period: chart_period, view: chart_view) %>\n\n    <div class=\"min-h-[800px]\" data-testid=\"account-details\">\n      <% if tabs.count > 1 %>\n        <%= render DS::Tabs.new(active_tab: active_tab, url_param_key: \"tab\") do |tabs_container| %>\n          <% tabs_container.with_nav(classes: \"max-w-fit\") do |nav| %>\n            <% tabs.each do |tab| %>\n              <% nav.with_btn(id: tab, label: tab.to_s.humanize, classes: \"px-6\") %>\n            <% end %>\n          <% end %>\n\n          <% tabs.each do |tab| %>\n            <% tabs_container.with_panel(tab_id: tab) do %>\n              <%= tab_content_for(tab) %>\n            <% end %>\n          <% end %>\n        <% end %>\n      <% else %>\n        <%= tab_content_for(tabs.first) %>\n      <% end %>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/components/UI/account_page.rb",
    "content": "class UI::AccountPage < ApplicationComponent\n  attr_reader :account, :chart_view, :chart_period\n\n  renders_one :activity_feed, ->(feed_data:, pagy:, search:) { UI::Account::ActivityFeed.new(feed_data: feed_data, pagy: pagy, search: search) }\n\n  def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil)\n    @account = account\n    @chart_view = chart_view\n    @chart_period = chart_period\n    @active_tab = active_tab\n  end\n\n  def id\n    dom_id(account, :container)\n  end\n\n  def broadcast_channel\n    account\n  end\n\n  def broadcast_refresh!\n    Turbo::StreamsChannel.broadcast_replace_to(broadcast_channel, target: id, renderable: self, layout: false)\n  end\n\n  def title\n    account.name\n  end\n\n  def subtitle\n    return nil unless account.property?\n\n    account.property.address\n  end\n\n  def active_tab\n    tabs.find { |tab| tab == @active_tab&.to_sym } || tabs.first\n  end\n\n  def tabs\n    case account.accountable_type\n    when \"Investment\"\n      [ :activity, :holdings ]\n    when \"Property\", \"Vehicle\", \"Loan\"\n      [ :activity, :overview ]\n    else\n      [ :activity ]\n    end\n  end\n\n  def tab_content_for(tab)\n    case tab\n    when :activity\n      activity_feed\n    when :holdings, :overview\n      # Accountable is responsible for implementing the partial in the correct folder\n      render \"#{account.accountable_type.downcase.pluralize}/tabs/#{tab}\", account: account\n    end\n  end\nend\n"
  },
  {
    "path": "app/components/application_component.rb",
    "content": "class ApplicationComponent < ViewComponent::Base\n  # These don't work as expected with helpers.turbo_frame_tag, etc., so we include them here\n  include Turbo::FramesHelper, Turbo::StreamsHelper\nend\n"
  },
  {
    "path": "app/components/design_system_component.rb",
    "content": "class DesignSystemComponent < ViewComponent::Base\nend\n"
  },
  {
    "path": "app/controllers/accountable_sparklines_controller.rb",
    "content": "class AccountableSparklinesController < ApplicationController\n  def show\n    @accountable = Accountable.from_type(params[:accountable_type]&.classify)\n\n    etag_key = cache_key\n\n    # Use HTTP conditional GET so the client receives 304 Not Modified when possible.\n    if stale?(etag: etag_key, last_modified: family.latest_sync_completed_at)\n      @series = Rails.cache.fetch(etag_key, expires_in: 24.hours) do\n        builder = Balance::ChartSeriesBuilder.new(\n          account_ids: account_ids,\n          currency: family.currency,\n          period: Period.last_30_days,\n          favorable_direction: @accountable.favorable_direction,\n          interval: \"1 day\"\n        )\n\n        builder.balance_series\n      end\n\n      render layout: false\n    end\n  end\n\n  private\n    def family\n      Current.family\n    end\n\n    def accountable\n      Accountable.from_type(params[:accountable_type]&.classify)\n    end\n\n    def account_ids\n      family.accounts.visible.where(accountable_type: accountable.name).pluck(:id)\n    end\n\n    def cache_key\n      family.build_cache_key(\"#{@accountable.name}_sparkline\", invalidate_on_data_updates: true)\n    end\nend\n"
  },
  {
    "path": "app/controllers/accounts_controller.rb",
    "content": "class AccountsController < ApplicationController\n  before_action :set_account, only: %i[sync sparkline toggle_active show destroy]\n  include Periodable\n\n  def index\n    @manual_accounts = family.accounts.manual.alphabetically\n    @plaid_items = family.plaid_items.ordered\n\n    render layout: \"settings\"\n  end\n\n  def sync_all\n    family.sync_later\n    redirect_to accounts_path, notice: \"Syncing accounts...\"\n  end\n\n  def show\n    @chart_view = params[:chart_view] || \"balance\"\n    @tab = params[:tab]\n    @q = params.fetch(:q, {}).permit(:search)\n    entries = @account.entries.search(@q).reverse_chronological\n\n    @pagy, @entries = pagy(entries, limit: params[:per_page] || \"10\")\n\n    @activity_feed_data = Account::ActivityFeedData.new(@account, @entries)\n  end\n\n  def sync\n    unless @account.syncing?\n      @account.sync_later\n    end\n\n    redirect_to account_path(@account)\n  end\n\n  def sparkline\n    etag_key = @account.family.build_cache_key(\"#{@account.id}_sparkline\", invalidate_on_data_updates: true)\n\n    # Short-circuit with 304 Not Modified when the client already has the latest version.\n    # We defer the expensive series computation until we know the content is stale.\n    if stale?(etag: etag_key, last_modified: @account.family.latest_sync_completed_at)\n      @sparkline_series = @account.sparkline_series\n      render layout: false\n    end\n  end\n\n  def toggle_active\n    if @account.active?\n      @account.disable!\n    elsif @account.disabled?\n      @account.enable!\n    end\n    redirect_to accounts_path\n  end\n\n  def destroy\n    if @account.linked?\n      redirect_to account_path(@account), alert: \"Cannot delete a linked account\"\n    else\n      @account.destroy_later\n      redirect_to accounts_path, notice: \"Account scheduled for deletion\"\n    end\n  end\n\n  private\n    def family\n      Current.family\n    end\n\n    def set_account\n      @account = family.accounts.find(params[:id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/api/v1/accounts_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass Api::V1::AccountsController < Api::V1::BaseController\n  include Pagy::Backend\n\n  # Ensure proper scope authorization for read access\n  before_action :ensure_read_scope\n\n  def index\n    # Test with Pagy pagination\n    family = current_resource_owner.family\n    accounts_query = family.accounts.visible.alphabetically\n\n    # Handle pagination with Pagy\n    @pagy, @accounts = pagy(\n      accounts_query,\n      page: safe_page_param,\n      limit: safe_per_page_param\n    )\n\n    @per_page = safe_per_page_param\n\n    # Rails will automatically use app/views/api/v1/accounts/index.json.jbuilder\n    render :index\n  rescue => e\n    Rails.logger.error \"AccountsController error: #{e.message}\"\n    Rails.logger.error e.backtrace.join(\"\\n\")\n\n    render json: {\n      error: \"internal_server_error\",\n      message: \"Error: #{e.message}\"\n    }, status: :internal_server_error\nend\n\n    private\n\n      def ensure_read_scope\n        authorize_scope!(:read)\n      end\n\n\n\n      def safe_page_param\n        page = params[:page].to_i\n        page > 0 ? page : 1\n      end\n\n      def safe_per_page_param\n        per_page = params[:per_page].to_i\n\n        # Default to 25, max 100\n        case per_page\n        when 1..100\n          per_page\n        else\n          25\n        end\n      end\nend\n"
  },
  {
    "path": "app/controllers/api/v1/auth_controller.rb",
    "content": "module Api\n  module V1\n    class AuthController < BaseController\n      include Invitable\n\n      skip_before_action :authenticate_request!\n      skip_before_action :check_api_key_rate_limit\n      skip_before_action :log_api_access\n\n      def signup\n        # Check if invite code is required\n        if invite_code_required? && params[:invite_code].blank?\n          render json: { error: \"Invite code is required\" }, status: :forbidden\n          return\n        end\n\n        # Validate invite code if provided\n        if params[:invite_code].present? && !InviteCode.exists?(token: params[:invite_code]&.downcase)\n          render json: { error: \"Invalid invite code\" }, status: :forbidden\n          return\n        end\n\n        # Validate password\n        password_errors = validate_password(params[:user][:password])\n        if password_errors.any?\n          render json: { errors: password_errors }, status: :unprocessable_entity\n          return\n        end\n\n        # Validate device info\n        unless valid_device_info?\n          render json: { error: \"Device information is required\" }, status: :bad_request\n          return\n        end\n\n        user = User.new(user_signup_params)\n\n        # Create family for new user\n        family = Family.new\n        user.family = family\n        user.role = :admin\n\n        if user.save\n          # Claim invite code if provided\n          InviteCode.claim!(params[:invite_code]) if params[:invite_code].present?\n\n          # Create device and OAuth token\n          device = create_or_update_device(user)\n          token_response = create_oauth_token_for_device(user, device)\n\n          render json: token_response.merge(\n            user: {\n              id: user.id,\n              email: user.email,\n              first_name: user.first_name,\n              last_name: user.last_name\n            }\n          ), status: :created\n        else\n          render json: { errors: user.errors.full_messages }, status: :unprocessable_entity\n        end\n      end\n\n      def login\n        user = User.find_by(email: params[:email])\n\n        if user&.authenticate(params[:password])\n          # Check MFA if enabled\n          if user.otp_required?\n            unless params[:otp_code].present? && user.verify_otp?(params[:otp_code])\n              render json: {\n                error: \"Two-factor authentication required\",\n                mfa_required: true\n              }, status: :unauthorized\n              return\n            end\n          end\n\n          # Validate device info\n          unless valid_device_info?\n            render json: { error: \"Device information is required\" }, status: :bad_request\n            return\n          end\n\n          # Create device and OAuth token\n          device = create_or_update_device(user)\n          token_response = create_oauth_token_for_device(user, device)\n\n          render json: token_response.merge(\n            user: {\n              id: user.id,\n              email: user.email,\n              first_name: user.first_name,\n              last_name: user.last_name\n            }\n          )\n        else\n          render json: { error: \"Invalid email or password\" }, status: :unauthorized\n        end\n      end\n\n      def refresh\n        # Find the refresh token\n        refresh_token = params[:refresh_token]\n\n        unless refresh_token.present?\n          render json: { error: \"Refresh token is required\" }, status: :bad_request\n          return\n        end\n\n        # Find the access token associated with this refresh token\n        access_token = Doorkeeper::AccessToken.by_refresh_token(refresh_token)\n\n        if access_token.nil? || access_token.revoked?\n          render json: { error: \"Invalid refresh token\" }, status: :unauthorized\n          return\n        end\n\n        # Create new access token\n        new_token = Doorkeeper::AccessToken.create!(\n          application: access_token.application,\n          resource_owner_id: access_token.resource_owner_id,\n          expires_in: 30.days.to_i,\n          scopes: access_token.scopes,\n          use_refresh_token: true\n        )\n\n        # Revoke old access token\n        access_token.revoke\n\n        # Update device last seen\n        user = User.find(access_token.resource_owner_id)\n        device = user.mobile_devices.find_by(device_id: params[:device][:device_id])\n        device&.update_last_seen!\n\n        render json: {\n          access_token: new_token.plaintext_token,\n          refresh_token: new_token.plaintext_refresh_token,\n          token_type: \"Bearer\",\n          expires_in: new_token.expires_in,\n          created_at: new_token.created_at.to_i\n        }\n      end\n\n      private\n\n        def user_signup_params\n          params.require(:user).permit(:email, :password, :first_name, :last_name)\n        end\n\n        def validate_password(password)\n          errors = []\n\n          if password.blank?\n            errors << \"Password can't be blank\"\n            return errors\n          end\n\n          errors << \"Password must be at least 8 characters\" if password.length < 8\n          errors << \"Password must include both uppercase and lowercase letters\" unless password.match?(/[A-Z]/) && password.match?(/[a-z]/)\n          errors << \"Password must include at least one number\" unless password.match?(/\\d/)\n          errors << \"Password must include at least one special character\" unless password.match?(/[!@#$%^&*(),.?\":{}|<>]/)\n\n          errors\n        end\n\n        def valid_device_info?\n          device = params[:device]\n          return false if device.nil?\n\n          required_fields = %w[device_id device_name device_type os_version app_version]\n          required_fields.all? { |field| device[field].present? }\n        end\n\n        def create_or_update_device(user)\n          # Handle both string and symbol keys\n          device_data = params[:device].permit(:device_id, :device_name, :device_type, :os_version, :app_version)\n\n          device = user.mobile_devices.find_or_initialize_by(device_id: device_data[:device_id])\n          device.update!(device_data.merge(last_seen_at: Time.current))\n          device\n        end\n\n        def create_oauth_token_for_device(user, device)\n          # Create OAuth application for this device if needed\n          oauth_app = device.create_oauth_application!\n\n          # Revoke any existing tokens for this device\n          device.revoke_all_tokens!\n\n          # Create new access token with 30-day expiration\n          access_token = Doorkeeper::AccessToken.create!(\n            application: oauth_app,\n            resource_owner_id: user.id,\n            expires_in: 30.days.to_i,\n            scopes: \"read_write\",\n            use_refresh_token: true\n          )\n\n          {\n            access_token: access_token.plaintext_token,\n            refresh_token: access_token.plaintext_refresh_token,\n            token_type: \"Bearer\",\n            expires_in: access_token.expires_in,\n            created_at: access_token.created_at.to_i\n          }\n        end\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/api/v1/base_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass Api::V1::BaseController < ApplicationController\n  include Doorkeeper::Rails::Helpers\n\n  # Skip regular session-based authentication for API\n  skip_authentication\n\n  # Skip CSRF protection for API endpoints\n  skip_before_action :verify_authenticity_token\n\n  # Skip onboarding requirements for API endpoints\n  skip_before_action :require_onboarding_and_upgrade\n\n  # Force JSON format for all API requests\n  before_action :force_json_format\n  # Use our custom authentication that supports both OAuth and API keys\n  before_action :authenticate_request!\n  before_action :check_api_key_rate_limit\n  before_action :log_api_access\n\n\n\n  # Override Doorkeeper's default behavior to return JSON instead of redirecting\n  def doorkeeper_unauthorized_render_options(error: nil)\n    { json: { error: \"unauthorized\", message: \"Access token is invalid, expired, or missing\" } }\n  end\n\n  # Error handling for common API errors\n  rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found\n  rescue_from Doorkeeper::Errors::DoorkeeperError, with: :handle_unauthorized\n  rescue_from ActionController::ParameterMissing, with: :handle_bad_request\n\n  private\n\n    # Force JSON format for all API requests\n    def force_json_format\n      request.format = :json\n    end\n\n    # Authenticate using either OAuth or API key\n    def authenticate_request!\n      return if authenticate_oauth\n      return if authenticate_api_key\n      render_unauthorized unless performed?\n    end\n\n    # Try OAuth authentication first\n    def authenticate_oauth\n      return false unless request.headers[\"Authorization\"].present?\n\n      # Manually verify the token (bypassing doorkeeper_authorize! which had scope issues)\n      token_string = request.authorization&.split(\" \")&.last\n      access_token = Doorkeeper::AccessToken.by_token(token_string)\n\n      # Check token validity and scope (read_write includes read access)\n      has_sufficient_scope = access_token&.scopes&.include?(\"read\") || access_token&.scopes&.include?(\"read_write\")\n\n      unless access_token && !access_token.expired? && has_sufficient_scope\n        render_json({ error: \"unauthorized\", message: \"Access token is invalid, expired, or missing required scope\" }, status: :unauthorized)\n        return false\n      end\n\n      # Set the doorkeeper_token for compatibility\n      @_doorkeeper_token = access_token\n\n      if doorkeeper_token&.resource_owner_id\n        @current_user = User.find_by(id: doorkeeper_token.resource_owner_id)\n\n        # If user doesn't exist, the token is invalid (user was deleted)\n        unless @current_user\n          Rails.logger.warn \"API OAuth Token Invalid: Access token resource_owner_id #{doorkeeper_token.resource_owner_id} does not exist\"\n          render_json({ error: \"unauthorized\", message: \"Access token is invalid - user not found\" }, status: :unauthorized)\n          return false\n        end\n      else\n        Rails.logger.warn \"API OAuth Token Invalid: Access token missing resource_owner_id\"\n        render_json({ error: \"unauthorized\", message: \"Access token is invalid - missing resource owner\" }, status: :unauthorized)\n        return false\n      end\n\n      @authentication_method = :oauth\n      setup_current_context_for_api\n      true\n    rescue Doorkeeper::Errors::DoorkeeperError => e\n      Rails.logger.warn \"API OAuth Error: #{e.message}\"\n      false\n    end\n\n    # Try API key authentication\n    def authenticate_api_key\n      api_key_value = request.headers[\"X-Api-Key\"]\n      return false unless api_key_value\n\n      @api_key = ApiKey.find_by_value(api_key_value)\n      return false unless @api_key && @api_key.active?\n\n      @current_user = @api_key.user\n      @api_key.update_last_used!\n      @authentication_method = :api_key\n      @rate_limiter = ApiRateLimiter.limit(@api_key)\n      setup_current_context_for_api\n      true\n    end\n\n    # Check rate limits for API key authentication\n    def check_api_key_rate_limit\n      return unless @authentication_method == :api_key && @rate_limiter\n\n      if @rate_limiter.rate_limit_exceeded?\n        usage_info = @rate_limiter.usage_info\n        render_rate_limit_exceeded(usage_info)\n        return false\n      end\n\n      # Increment request count for successful API key requests\n      @rate_limiter.increment_request_count!\n\n      # Add rate limit headers to response\n      add_rate_limit_headers(@rate_limiter.usage_info)\n    end\n\n    # Render rate limit exceeded response\n    def render_rate_limit_exceeded(usage_info)\n      response.headers[\"X-RateLimit-Limit\"] = usage_info[:rate_limit].to_s\n      response.headers[\"X-RateLimit-Remaining\"] = \"0\"\n      response.headers[\"X-RateLimit-Reset\"] = usage_info[:reset_time].to_s\n      response.headers[\"Retry-After\"] = usage_info[:reset_time].to_s\n\n      Rails.logger.warn \"API Rate Limit Exceeded: API Key #{@api_key.name} (User: #{@current_user.email}) - #{usage_info[:current_count]}/#{usage_info[:rate_limit]} requests\"\n\n      render_json({\n        error: \"rate_limit_exceeded\",\n        message: \"Rate limit exceeded. Try again in #{usage_info[:reset_time]} seconds.\",\n        details: {\n          limit: usage_info[:rate_limit],\n          current: usage_info[:current_count],\n          reset_in_seconds: usage_info[:reset_time]\n        }\n      }, status: :too_many_requests)\n    end\n\n    # Add rate limit headers to successful responses\n    def add_rate_limit_headers(usage_info)\n      response.headers[\"X-RateLimit-Limit\"] = usage_info[:rate_limit].to_s\n      response.headers[\"X-RateLimit-Remaining\"] = usage_info[:remaining].to_s\n      response.headers[\"X-RateLimit-Reset\"] = usage_info[:reset_time].to_s\n    end\n\n    # Render unauthorized response\n    def render_unauthorized\n      render_json({ error: \"unauthorized\", message: \"Access token or API key is invalid, expired, or missing\" }, status: :unauthorized)\n    end\n\n    # Returns the user that owns the access token or API key\n    def current_resource_owner\n      @current_user\n    end\n\n    # Get current scopes from either authentication method\n    def current_scopes\n      case @authentication_method\n      when :oauth\n        doorkeeper_token&.scopes&.to_a || []\n      when :api_key\n        @api_key&.scopes || []\n      else\n        []\n      end\n    end\n\n    # Check if the current authentication has the required scope\n    # Implements hierarchical scope checking where read_write includes read access\n    def authorize_scope!(required_scope)\n      scopes = current_scopes\n\n      case required_scope.to_s\n      when \"read\"\n        # Read access requires either \"read\" or \"read_write\" scope\n        has_access = scopes.include?(\"read\") || scopes.include?(\"read_write\")\n      when \"write\"\n        # Write access requires \"read_write\" scope\n        has_access = scopes.include?(\"read_write\")\n      else\n        # For any other scope, check exact match (backward compatibility)\n        has_access = scopes.include?(required_scope.to_s)\n      end\n\n      unless has_access\n        Rails.logger.warn \"API Insufficient Scope: User #{current_resource_owner&.email} attempted to access #{required_scope} but only has #{scopes}\"\n        render_json({ error: \"insufficient_scope\", message: \"This action requires the '#{required_scope}' scope\" }, status: :forbidden)\n        return false\n      end\n      true\n    end\n\n    # Consistent JSON response method\n    def render_json(data, status: :ok)\n      render json: data, status: status\n    end\n\n    # Error handlers\n    def handle_not_found(exception)\n      Rails.logger.warn \"API Record Not Found: #{exception.message}\"\n      render_json({ error: \"record_not_found\", message: \"The requested resource was not found\" }, status: :not_found)\n    end\n\n    def handle_unauthorized(exception)\n      Rails.logger.warn \"API Unauthorized: #{exception.message}\"\n      render_json({ error: \"unauthorized\", message: \"Access token is invalid or expired\" }, status: :unauthorized)\n    end\n\n    def handle_bad_request(exception)\n      Rails.logger.warn \"API Bad Request: #{exception.message}\"\n      render_json({ error: \"bad_request\", message: \"Required parameters are missing or invalid\" }, status: :bad_request)\n    end\n\n    # Log API access for monitoring and debugging\n    def log_api_access\n      return unless current_resource_owner\n\n      auth_info = case @authentication_method\n      when :oauth\n        \"OAuth Token\"\n      when :api_key\n        \"API Key: #{@api_key.name}\"\n      else\n        \"Unknown\"\n      end\n\n      Rails.logger.info \"API Request: #{request.method} #{request.path} - User: #{current_resource_owner.email} (Family: #{current_resource_owner.family_id}) - Auth: #{auth_info}\"\n    end\n\n    # Family-based access control helper (to be used by subcontrollers)\n    def ensure_current_family_access(resource)\n      return unless resource.respond_to?(:family_id)\n\n      unless resource.family_id == current_resource_owner.family_id\n        Rails.logger.warn \"API Forbidden: User #{current_resource_owner.email} attempted to access resource from family #{resource.family_id}\"\n        render_json({ error: \"forbidden\", message: \"Access denied to this resource\" }, status: :forbidden)\n        return false\n      end\n\n      true\n    end\n\n    # Manual doorkeeper_token accessor for compatibility with manual token verification\n    def doorkeeper_token\n      @_doorkeeper_token\n    end\n\n    # Set up Current context for API requests since we don't use session-based auth\n    def setup_current_context_for_api\n      # For API requests, we need to create a minimal session-like object\n      # or find/create an actual session for this user to make Current.user work\n      if @current_user\n        # Try to find an existing session for this user, or create a temporary one\n        session = @current_user.sessions.first\n        if session\n          Current.session = session\n        else\n          # Create a temporary session for this API request\n          # This won't be persisted but will allow Current.user to work\n          session = @current_user.sessions.build(\n            user_agent: request.user_agent,\n            ip_address: request.ip\n          )\n          Current.session = session\n        end\n      end\n    end\n\n    # Check if AI features are enabled for the current user\n    def require_ai_enabled\n      unless current_resource_owner&.ai_enabled?\n        render_json({ error: \"feature_disabled\", message: \"AI features are not enabled for this user\" }, status: :forbidden)\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/api/v1/chats_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass Api::V1::ChatsController < Api::V1::BaseController\n  include Pagy::Backend\n  before_action :require_ai_enabled\n  before_action :ensure_read_scope, only: [ :index, :show ]\n  before_action :ensure_write_scope, only: [ :create, :update, :destroy ]\n  before_action :set_chat, only: [ :show, :update, :destroy ]\n\n  def index\n    @pagy, @chats = pagy(Current.user.chats.ordered, items: 20)\n  end\n\n  def show\n    return unless @chat\n    @pagy, @messages = pagy(@chat.messages.ordered, items: 50)\n  end\n\n  def create\n    @chat = Current.user.chats.build(title: chat_params[:title])\n\n    if @chat.save\n      if chat_params[:message].present?\n        @message = @chat.messages.build(\n          content: chat_params[:message],\n          type: \"UserMessage\",\n          ai_model: chat_params[:model] || \"gpt-4\"\n        )\n\n        if @message.save\n          AssistantResponseJob.perform_later(@message)\n          render :show, status: :created\n        else\n          @chat.destroy\n          render json: { error: \"Failed to create initial message\", details: @message.errors.full_messages }, status: :unprocessable_entity\n        end\n      else\n        render :show, status: :created\n      end\n    else\n      render json: { error: \"Failed to create chat\", details: @chat.errors.full_messages }, status: :unprocessable_entity\n    end\n  end\n\n  def update\n    return unless @chat\n\n    if @chat.update(update_chat_params)\n      render :show\n    else\n      render json: { error: \"Failed to update chat\", details: @chat.errors.full_messages }, status: :unprocessable_entity\n    end\n  end\n\n  def destroy\n    return unless @chat\n    @chat.destroy\n    head :no_content\n  end\n\n  private\n\n    def ensure_read_scope\n      authorize_scope!(:read)\n    end\n\n    def ensure_write_scope\n      authorize_scope!(:write)\n    end\n\n    def set_chat\n      @chat = Current.user.chats.find(params[:id])\n    rescue ActiveRecord::RecordNotFound\n      render json: { error: \"Chat not found\" }, status: :not_found\n    end\n\n    def chat_params\n      params.permit(:title, :message, :model)\n    end\n\n    def update_chat_params\n      params.permit(:title)\n    end\nend\n"
  },
  {
    "path": "app/controllers/api/v1/messages_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass Api::V1::MessagesController < Api::V1::BaseController\n  before_action :require_ai_enabled\n  before_action :ensure_write_scope, only: [ :create, :retry ]\n  before_action :set_chat\n\n  def create\n    @message = @chat.messages.build(\n      content: message_params[:content],\n      type: \"UserMessage\",\n      ai_model: message_params[:model] || \"gpt-4\"\n    )\n\n    if @message.save\n      AssistantResponseJob.perform_later(@message)\n      render :show, status: :created\n    else\n      render json: { error: \"Failed to create message\", details: @message.errors.full_messages }, status: :unprocessable_entity\n    end\n  end\n\n  def retry\n    last_message = @chat.messages.ordered.last\n\n    if last_message&.type == \"AssistantMessage\"\n      new_message = @chat.messages.create!(\n        type: \"AssistantMessage\",\n        content: \"\",\n        ai_model: last_message.ai_model\n      )\n\n      AssistantResponseJob.perform_later(new_message)\n      render json: { message: \"Retry initiated\", message_id: new_message.id }, status: :accepted\n    else\n      render json: { error: \"No assistant message to retry\" }, status: :unprocessable_entity\n    end\n  end\n\n  private\n\n    def ensure_write_scope\n      authorize_scope!(:write)\n    end\n\n    def set_chat\n      @chat = Current.user.chats.find(params[:chat_id])\n    rescue ActiveRecord::RecordNotFound\n      render json: { error: \"Chat not found\" }, status: :not_found\n    end\n\n    def message_params\n      params.permit(:content, :model)\n    end\nend\n"
  },
  {
    "path": "app/controllers/api/v1/test_controller.rb",
    "content": "# frozen_string_literal: true\n\n# Test controller for API V1 Base Controller functionality\n# This controller is only used for testing the base controller behavior\nclass Api::V1::TestController < Api::V1::BaseController\n  def index\n    render_json({ message: \"test_success\", user: current_resource_owner&.email })\n  end\n\n  def not_found\n    # Trigger RecordNotFound error for testing error handling\n    raise ActiveRecord::RecordNotFound, \"Test record not found\"\n  end\n\n  def family_access\n    # Test family-based access control\n    # Create a mock resource that belongs to a different family\n    mock_resource = OpenStruct.new(family_id: 999)  # Different family ID\n\n    # Check family access - if it returns false, it already rendered the error\n    if ensure_current_family_access(mock_resource)\n      # If we get here, access was allowed\n      render_json({ family_id: current_resource_owner.family_id })\n    end\n  end\n\n  def scope_required\n    # Test scope authorization - require write scope\n    return unless authorize_scope!(\"write\")\n\n    render_json({\n      message: \"scope_authorized\",\n      scopes: current_scopes,\n      required_scope: \"write\"\n    })\n  end\n\n  def multiple_scopes_required\n    # Test read scope requirement\n    return unless authorize_scope!(\"read\")\n\n    render_json({\n      message: \"read_scope_authorized\",\n      scopes: current_scopes\n    })\n  end\nend\n"
  },
  {
    "path": "app/controllers/api/v1/transactions_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass Api::V1::TransactionsController < Api::V1::BaseController\n  include Pagy::Backend\n\n  # Ensure proper scope authorization for read vs write access\n  before_action :ensure_read_scope, only: [ :index, :show ]\n  before_action :ensure_write_scope, only: [ :create, :update, :destroy ]\n  before_action :set_transaction, only: [ :show, :update, :destroy ]\n\n  def index\n    family = current_resource_owner.family\n    transactions_query = family.transactions.visible\n\n    # Apply filters\n    transactions_query = apply_filters(transactions_query)\n\n    # Apply search\n    transactions_query = apply_search(transactions_query) if params[:search].present?\n\n    # Include necessary associations for efficient queries\n    transactions_query = transactions_query.includes(\n      { entry: :account },\n      :category, :merchant, :tags,\n      transfer_as_outflow: { inflow_transaction: { entry: :account } },\n      transfer_as_inflow: { outflow_transaction: { entry: :account } }\n    ).reverse_chronological\n\n    # Handle pagination with Pagy\n    @pagy, @transactions = pagy(\n      transactions_query,\n      page: safe_page_param,\n      limit: safe_per_page_param\n    )\n\n    # Make per_page available to the template\n    @per_page = safe_per_page_param\n\n    # Rails will automatically use app/views/api/v1/transactions/index.json.jbuilder\n    render :index\n\n  rescue => e\n    Rails.logger.error \"TransactionsController#index error: #{e.message}\"\n    Rails.logger.error e.backtrace.join(\"\\n\")\n\n    render json: {\n      error: \"internal_server_error\",\n      message: \"Error: #{e.message}\"\n    }, status: :internal_server_error\n  end\n\n  def show\n    # Rails will automatically use app/views/api/v1/transactions/show.json.jbuilder\n    render :show\n\n  rescue => e\n    Rails.logger.error \"TransactionsController#show error: #{e.message}\"\n    Rails.logger.error e.backtrace.join(\"\\n\")\n\n    render json: {\n      error: \"internal_server_error\",\n      message: \"Error: #{e.message}\"\n    }, status: :internal_server_error\n  end\n\n  def create\n    family = current_resource_owner.family\n\n    # Validate account_id is present\n    unless transaction_params[:account_id].present?\n      render json: {\n        error: \"validation_failed\",\n        message: \"Account ID is required\",\n        errors: [ \"Account ID is required\" ]\n      }, status: :unprocessable_entity\n      return\n    end\n\n    account = family.accounts.find(transaction_params[:account_id])\n    @entry = account.entries.new(entry_params_for_create)\n\n    if @entry.save\n      @entry.sync_account_later\n      @entry.lock_saved_attributes!\n      @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?\n\n      @transaction = @entry.transaction\n      render :show, status: :created\n    else\n      render json: {\n        error: \"validation_failed\",\n        message: \"Transaction could not be created\",\n        errors: @entry.errors.full_messages\n      }, status: :unprocessable_entity\n    end\n\n  rescue => e\n    Rails.logger.error \"TransactionsController#create error: #{e.message}\"\n    Rails.logger.error e.backtrace.join(\"\\n\")\n\n    render json: {\n      error: \"internal_server_error\",\n      message: \"Error: #{e.message}\"\n    }, status: :internal_server_error\nend\n\n  def update\n    if @entry.update(entry_params_for_update)\n      @entry.sync_account_later\n      @entry.lock_saved_attributes!\n      @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?\n\n      @transaction = @entry.transaction\n      render :show\n    else\n      render json: {\n        error: \"validation_failed\",\n        message: \"Transaction could not be updated\",\n        errors: @entry.errors.full_messages\n      }, status: :unprocessable_entity\n    end\n\n  rescue => e\n    Rails.logger.error \"TransactionsController#update error: #{e.message}\"\n    Rails.logger.error e.backtrace.join(\"\\n\")\n\n    render json: {\n      error: \"internal_server_error\",\n      message: \"Error: #{e.message}\"\n    }, status: :internal_server_error\n  end\n\n  def destroy\n    @entry.destroy!\n    @entry.sync_account_later\n\n    render json: {\n      message: \"Transaction deleted successfully\"\n    }, status: :ok\n\n  rescue => e\n    Rails.logger.error \"TransactionsController#destroy error: #{e.message}\"\n    Rails.logger.error e.backtrace.join(\"\\n\")\n\n    render json: {\n      error: \"internal_server_error\",\n      message: \"Error: #{e.message}\"\n    }, status: :internal_server_error\n  end\n\n  private\n\n    def set_transaction\n      family = current_resource_owner.family\n      @transaction = family.transactions.find(params[:id])\n      @entry = @transaction.entry\n    rescue ActiveRecord::RecordNotFound\n      render json: {\n        error: \"not_found\",\n        message: \"Transaction not found\"\n      }, status: :not_found\n    end\n\n    def ensure_read_scope\n      authorize_scope!(:read)\n    end\n\n    def ensure_write_scope\n      authorize_scope!(:write)\n    end\n\n    def apply_filters(query)\n      # Account filtering\n      if params[:account_id].present?\n        query = query.joins(:entry).where(entries: { account_id: params[:account_id] })\n      end\n\n      if params[:account_ids].present?\n        account_ids = Array(params[:account_ids])\n        query = query.joins(:entry).where(entries: { account_id: account_ids })\n      end\n\n      # Category filtering\n      if params[:category_id].present?\n        query = query.where(category_id: params[:category_id])\n      end\n\n      if params[:category_ids].present?\n        category_ids = Array(params[:category_ids])\n        query = query.where(category_id: category_ids)\n      end\n\n      # Merchant filtering\n      if params[:merchant_id].present?\n        query = query.where(merchant_id: params[:merchant_id])\n      end\n\n      if params[:merchant_ids].present?\n        merchant_ids = Array(params[:merchant_ids])\n        query = query.where(merchant_id: merchant_ids)\n      end\n\n      # Date range filtering\n      if params[:start_date].present?\n        query = query.joins(:entry).where(\"entries.date >= ?\", Date.parse(params[:start_date]))\n      end\n\n      if params[:end_date].present?\n        query = query.joins(:entry).where(\"entries.date <= ?\", Date.parse(params[:end_date]))\n      end\n\n      # Amount filtering\n      if params[:min_amount].present?\n        min_amount = params[:min_amount].to_f\n        query = query.joins(:entry).where(\"entries.amount >= ?\", min_amount)\n      end\n\n      if params[:max_amount].present?\n        max_amount = params[:max_amount].to_f\n        query = query.joins(:entry).where(\"entries.amount <= ?\", max_amount)\n      end\n\n      # Tag filtering\n      if params[:tag_ids].present?\n        tag_ids = Array(params[:tag_ids])\n        query = query.joins(:tags).where(tags: { id: tag_ids })\n      end\n\n      # Transaction type filtering (income/expense)\n      if params[:type].present?\n        case params[:type].downcase\n        when \"income\"\n          query = query.joins(:entry).where(\"entries.amount < 0\")\n        when \"expense\"\n          query = query.joins(:entry).where(\"entries.amount > 0\")\n        end\n      end\n\n      query\n    end\n\n    def apply_search(query)\n      search_term = \"%#{params[:search]}%\"\n\n      query.joins(:entry)\n           .left_joins(:merchant)\n           .where(\n             \"entries.name ILIKE ? OR entries.notes ILIKE ? OR merchants.name ILIKE ?\",\n             search_term, search_term, search_term\n           )\nend\n\n    def transaction_params\n      params.require(:transaction).permit(\n        :account_id, :date, :amount, :name, :description, :notes, :currency,\n        :category_id, :merchant_id, :nature, tag_ids: []\n      )\n    end\n\n    def entry_params_for_create\n      entry_params = {\n        name: transaction_params[:name] || transaction_params[:description],\n        date: transaction_params[:date],\n        amount: calculate_signed_amount,\n        currency: transaction_params[:currency] || current_resource_owner.family.currency,\n        notes: transaction_params[:notes],\n        entryable_type: \"Transaction\",\n        entryable_attributes: {\n          category_id: transaction_params[:category_id],\n          merchant_id: transaction_params[:merchant_id],\n          tag_ids: transaction_params[:tag_ids] || []\n        }\n      }\n\n      entry_params.compact\n    end\n\n    def entry_params_for_update\n      entry_params = {\n        name: transaction_params[:name] || transaction_params[:description],\n        date: transaction_params[:date],\n        notes: transaction_params[:notes],\n        entryable_attributes: {\n          id: @entry.entryable_id,\n          category_id: transaction_params[:category_id],\n          merchant_id: transaction_params[:merchant_id],\n          tag_ids: transaction_params[:tag_ids]\n        }.compact_blank\n      }\n\n      # Only update amount if provided\n      if transaction_params[:amount].present?\n        entry_params[:amount] = calculate_signed_amount\n      end\n\n      entry_params.compact\n    end\n\n    def calculate_signed_amount\n      amount = transaction_params[:amount].to_f\n      nature = transaction_params[:nature]\n\n      case nature&.downcase\n      when \"income\", \"inflow\"\n        -amount.abs  # Income is negative\n      when \"expense\", \"outflow\"\n        amount.abs   # Expense is positive\n      else\n        amount       # Use as provided\n      end\n    end\n\n    def safe_page_param\n      page = params[:page].to_i\n      page > 0 ? page : 1\n    end\n\n    def safe_per_page_param\n      per_page = params[:per_page].to_i\n      case per_page\n      when 1..100\n        per_page\n      else\n        25  # Default\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/api/v1/usage_controller.rb",
    "content": "class Api::V1::UsageController < Api::V1::BaseController\n  # GET /api/v1/usage\n  def show\n    return unless authorize_scope!(:read)\n\n    case @authentication_method\n    when :api_key\n      usage_info = @rate_limiter.usage_info\n      render_json({\n        api_key: {\n          name: @api_key.name,\n          scopes: @api_key.scopes,\n          last_used_at: @api_key.last_used_at,\n          created_at: @api_key.created_at\n        },\n        rate_limit: {\n          tier: usage_info[:tier],\n          limit: usage_info[:rate_limit],\n          current_count: usage_info[:current_count],\n          remaining: usage_info[:remaining],\n          reset_in_seconds: usage_info[:reset_time],\n          reset_at: Time.current + usage_info[:reset_time].seconds\n        }\n      })\n    when :oauth\n      # For OAuth, we don't track detailed usage yet, but we can return basic info\n      render_json({\n        authentication_method: \"oauth\",\n        message: \"Detailed usage tracking is available for API key authentication\"\n      })\n    else\n      render_json({\n        error: \"invalid_authentication_method\",\n        message: \"Unable to determine usage information\"\n      }, status: :bad_request)\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/application_controller.rb",
    "content": "class ApplicationController < ActionController::Base\n  include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable,\n          SelfHostable, StoreLocation, Impersonatable, Breadcrumbable,\n          FeatureGuardable, Notifiable\n\n  include Pagy::Backend\n\n  before_action :detect_os\n  before_action :set_default_chat\n  before_action :set_active_storage_url_options\n\n  private\n    def detect_os\n      user_agent = request.user_agent\n      @os = case user_agent\n      when /Windows/i then \"windows\"\n      when /Macintosh/i then \"mac\"\n      when /Linux/i then \"linux\"\n      when /Android/i then \"android\"\n      when /iPhone|iPad/i then \"ios\"\n      else \"\"\n      end\n    end\n\n    # By default, we show the user the last chat they interacted with\n    def set_default_chat\n      @last_viewed_chat = Current.user&.last_viewed_chat\n      @chat = @last_viewed_chat\n    end\n\n    def set_active_storage_url_options\n      ActiveStorage::Current.url_options = {\n        protocol: request.protocol,\n        host: request.host,\n        port: request.optional_port\n      }\n    end\nend\n"
  },
  {
    "path": "app/controllers/budget_categories_controller.rb",
    "content": "class BudgetCategoriesController < ApplicationController\n  before_action :set_budget\n\n  def index\n    @budget_categories = @budget.budget_categories.includes(:category)\n    render layout: \"wizard\"\n  end\n\n  def show\n    @recent_transactions = @budget.transactions\n\n    if params[:id] == BudgetCategory.uncategorized.id\n      @budget_category = @budget.uncategorized_budget_category\n      @recent_transactions = @recent_transactions.where(transactions: { category_id: nil })\n    else\n      @budget_category = Current.family.budget_categories.find(params[:id])\n      @recent_transactions = @recent_transactions.joins(\"LEFT JOIN categories ON categories.id = transactions.category_id\")\n                                                 .where(\"categories.id = ? OR categories.parent_id = ?\", @budget_category.category.id, @budget_category.category.id)\n    end\n\n    @recent_transactions = @recent_transactions.order(\"entries.date DESC, ABS(entries.amount) DESC\").take(3)\n  end\n\n  def update\n    @budget_category = Current.family.budget_categories.find(params[:id])\n\n    if @budget_category.update(budget_category_params)\n      respond_to do |format|\n        format.turbo_stream\n        format.html { redirect_to budget_budget_categories_path(@budget) }\n      end\n    else\n      render :index, status: :unprocessable_entity\n    end\n  end\n\n  private\n    def budget_category_params\n      params.require(:budget_category).permit(:budgeted_spending).tap do |params|\n        params[:budgeted_spending] = params[:budgeted_spending].presence || 0\n      end\n    end\n\n    def set_budget\n      start_date = Budget.param_to_date(params[:budget_month_year])\n      @budget = Current.family.budgets.find_by(start_date: start_date)\n    end\nend\n"
  },
  {
    "path": "app/controllers/budgets_controller.rb",
    "content": "class BudgetsController < ApplicationController\n  before_action :set_budget, only: %i[show edit update]\n\n  def index\n    redirect_to_current_month_budget\n  end\n\n  def show\n  end\n\n  def edit\n    render layout: \"wizard\"\n  end\n\n  def update\n    @budget.update!(budget_params)\n    redirect_to budget_budget_categories_path(@budget)\n  end\n\n  def picker\n    render partial: \"budgets/picker\", locals: {\n      family: Current.family,\n      year: params[:year].to_i || Date.current.year\n    }\n  end\n\n  private\n\n    def budget_create_params\n      params.require(:budget).permit(:start_date)\n    end\n\n    def budget_params\n      params.require(:budget).permit(:budgeted_spending, :expected_income)\n    end\n\n    def set_budget\n      start_date = Budget.param_to_date(params[:month_year])\n      @budget = Budget.find_or_bootstrap(Current.family, start_date: start_date)\n      raise ActiveRecord::RecordNotFound unless @budget\n    end\n\n    def redirect_to_current_month_budget\n      current_budget = Budget.find_or_bootstrap(Current.family, start_date: Date.current)\n      redirect_to budget_path(current_budget)\n    end\nend\n"
  },
  {
    "path": "app/controllers/categories_controller.rb",
    "content": "class CategoriesController < ApplicationController\n  before_action :set_category, only: %i[edit update destroy]\n  before_action :set_categories, only: %i[update edit]\n  before_action :set_transaction, only: :create\n\n  def index\n    @categories = Current.family.categories.alphabetically\n\n    render layout: \"settings\"\n  end\n\n  def new\n    @category = Current.family.categories.new color: Category::COLORS.sample\n    set_categories\n  end\n\n  def create\n    @category = Current.family.categories.new(category_params)\n\n    if @category.save\n      @transaction.update(category_id: @category.id) if @transaction\n\n      flash[:notice] = t(\".success\")\n\n      redirect_target_url = request.referer || categories_path\n      respond_to do |format|\n        format.html { redirect_back_or_to categories_path, notice: t(\".success\") }\n        format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }\n      end\n    else\n      set_categories\n      render :new, status: :unprocessable_entity\n    end\n  end\n\n  def edit\n  end\n\n  def update\n    if @category.update(category_params)\n      flash[:notice] = t(\".success\")\n\n      redirect_target_url = request.referer || categories_path\n      respond_to do |format|\n        format.html { redirect_back_or_to categories_path, notice: t(\".success\") }\n        format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }\n      end\n    else\n      render :edit, status: :unprocessable_entity\n    end\n  end\n\n  def destroy\n    @category.destroy\n\n    redirect_back_or_to categories_path, notice: t(\".success\")\n  end\n\n  def destroy_all\n    Current.family.categories.destroy_all\n    redirect_back_or_to categories_path, notice: \"All categories deleted\"\n  end\n\n  def bootstrap\n    Current.family.categories.bootstrap!\n\n    redirect_back_or_to categories_path, notice: t(\".success\")\n  end\n\n  private\n    def set_category\n      @category = Current.family.categories.find(params[:id])\n    end\n\n    def set_categories\n      @categories = unless @category.parent?\n        Current.family.categories.alphabetically.roots.where.not(id: @category.id)\n      else\n        []\n      end\n    end\n\n    def set_transaction\n      if params[:transaction_id].present?\n        @transaction = Current.family.transactions.find(params[:transaction_id])\n      end\n    end\n\n    def category_params\n      params.require(:category).permit(:name, :color, :parent_id, :classification, :lucide_icon)\n    end\nend\n"
  },
  {
    "path": "app/controllers/category/deletions_controller.rb",
    "content": "class Category::DeletionsController < ApplicationController\n  before_action :set_category\n  before_action :set_replacement_category, only: :create\n\n  def new\n  end\n\n  def create\n    @category.replace_and_destroy! @replacement_category\n\n    redirect_back_or_to transactions_path, notice: t(\".success\")\n  end\n\n  private\n    def set_category\n      @category = Current.family.categories.find(params[:category_id])\n    end\n\n    def set_replacement_category\n      if params[:replacement_category_id].present?\n        @replacement_category = Current.family.categories.find(params[:replacement_category_id])\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/category/dropdowns_controller.rb",
    "content": "class Category::DropdownsController < ApplicationController\n  before_action :set_from_params\n\n  def show\n    @categories = categories_scope.to_a.excluding(@selected_category).prepend(@selected_category).compact\n  end\n\n  private\n    def set_from_params\n      if params[:category_id]\n        @selected_category = categories_scope.find(params[:category_id])\n      end\n\n      if params[:transaction_id]\n        @transaction = Current.family.transactions.find(params[:transaction_id])\n      end\n    end\n\n    def categories_scope\n      Current.family.categories.alphabetically\n    end\nend\n"
  },
  {
    "path": "app/controllers/chats_controller.rb",
    "content": "class ChatsController < ApplicationController\n  include ActionView::RecordIdentifier\n\n  before_action :set_chat, only: [ :show, :edit, :update, :destroy ]\n\n  def index\n    @chat = nil # override application_controller default behavior of setting @chat to last viewed chat\n    @chats = Current.user.chats.order(created_at: :desc)\n  end\n\n  def show\n    set_last_viewed_chat(@chat)\n  end\n\n  def new\n    @chat = Current.user.chats.new(title: \"New chat #{Time.current.strftime(\"%Y-%m-%d %H:%M\")}\")\n  end\n\n  def create\n    @chat = Current.user.chats.start!(chat_params[:content], model: chat_params[:ai_model])\n    set_last_viewed_chat(@chat)\n    redirect_to chat_path(@chat, thinking: true)\n  end\n\n  def edit\n  end\n\n  def update\n    @chat.update!(chat_params)\n\n    respond_to do |format|\n      format.html { redirect_back_or_to chat_path(@chat), notice: \"Chat updated\" }\n      format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: \"chats/chat_title\", locals: { chat: @chat }) }\n    end\n  end\n\n  def destroy\n    @chat.destroy\n    clear_last_viewed_chat\n\n    redirect_to chats_path, notice: \"Chat was successfully deleted\"\n  end\n\n  def retry\n    @chat.retry_last_message!\n    redirect_to chat_path(@chat, thinking: true)\n  end\n\n  private\n    def set_chat\n      @chat = Current.user.chats.find(params[:id])\n    end\n\n    def set_last_viewed_chat(chat)\n      Current.user.update!(last_viewed_chat: chat)\n    end\n\n    def clear_last_viewed_chat\n      Current.user.update!(last_viewed_chat: nil)\n    end\n\n    def chat_params\n      params.require(:chat).permit(:title, :content, :ai_model)\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/accountable_resource.rb",
    "content": "module AccountableResource\n  extend ActiveSupport::Concern\n\n  included do\n    include Periodable\n\n    before_action :set_account, only: [ :show, :edit, :update ]\n    before_action :set_link_options, only: :new\n  end\n\n  class_methods do\n    def permitted_accountable_attributes(*attrs)\n      @permitted_accountable_attributes = attrs if attrs.any?\n      @permitted_accountable_attributes ||= [ :id ]\n    end\n  end\n\n  def new\n    @account = Current.family.accounts.build(\n      currency: Current.family.currency,\n      accountable: accountable_type.new\n    )\n  end\n\n  def show\n    @chart_view = params[:chart_view] || \"balance\"\n    @q = params.fetch(:q, {}).permit(:search)\n    entries = @account.entries.search(@q).reverse_chronological\n\n    @pagy, @entries = pagy(entries, limit: params[:per_page] || \"10\")\n  end\n\n  def edit\n  end\n\n  def create\n    @account = Current.family.accounts.create_and_sync(account_params.except(:return_to))\n    @account.lock_saved_attributes!\n\n    redirect_to account_params[:return_to].presence || @account, notice: t(\"accounts.create.success\", type: accountable_type.name.underscore.humanize)\n  end\n\n  def update\n    # Handle balance update if provided\n    if account_params[:balance].present?\n      result = @account.set_current_balance(account_params[:balance].to_d)\n      unless result.success?\n        @error_message = result.error_message\n        render :edit, status: :unprocessable_entity\n        return\n      end\n      @account.sync_later\n    end\n\n    # Update remaining account attributes\n    update_params = account_params.except(:return_to, :balance, :currency)\n    unless @account.update(update_params)\n      @error_message = @account.errors.full_messages.join(\", \")\n      render :edit, status: :unprocessable_entity\n      return\n    end\n\n    @account.lock_saved_attributes!\n    redirect_back_or_to account_path(@account), notice: t(\"accounts.update.success\", type: accountable_type.name.underscore.humanize)\n  end\n\n  private\n    def set_link_options\n      @show_us_link = Current.family.can_connect_plaid_us?\n      @show_eu_link = Current.family.can_connect_plaid_eu?\n    end\n\n    def accountable_type\n      controller_name.classify.constantize\n    end\n\n    def set_account\n      @account = Current.family.accounts.find(params[:id])\n    end\n\n    def account_params\n      params.require(:account).permit(\n        :name, :balance, :subtype, :currency, :accountable_type, :return_to,\n        accountable_attributes: self.class.permitted_accountable_attributes\n      )\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/authentication.rb",
    "content": "module Authentication\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :set_request_details\n    before_action :authenticate_user!\n    before_action :set_sentry_user\n  end\n\n  class_methods do\n    def skip_authentication(**options)\n      skip_before_action :authenticate_user!, **options\n      skip_before_action :set_sentry_user, **options\n    end\n  end\n\n  private\n    def authenticate_user!\n      if session_record = find_session_by_cookie\n        Current.session = session_record\n      else\n        if self_hosted_first_login?\n          redirect_to new_registration_url\n        else\n          redirect_to new_session_url\n        end\n      end\n    end\n\n    def find_session_by_cookie\n      cookie_value = cookies.signed[:session_token]\n\n      if cookie_value.present?\n        Session.find_by(id: cookie_value)\n      else\n        nil\n      end\n    end\n\n    def create_session_for(user)\n      session = user.sessions.create!\n      cookies.signed.permanent[:session_token] = { value: session.id, httponly: true }\n      session\n    end\n\n    def self_hosted_first_login?\n      Rails.application.config.app_mode.self_hosted? && User.count.zero?\n    end\n\n    def set_request_details\n      Current.user_agent = request.user_agent\n      Current.ip_address = request.ip\n    end\n\n    def set_sentry_user\n      return unless defined?(Sentry) && ENV[\"SENTRY_DSN\"].present?\n\n      if Current.user\n        Sentry.set_user(\n          id: Current.user.id,\n          email: Current.user.email,\n          username: Current.user.display_name,\n          ip_address: Current.ip_address\n        )\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/auto_sync.rb",
    "content": "module AutoSync\n  extend ActiveSupport::Concern\n\n  # included do\n  #   before_action :sync_family, if: :family_needs_auto_sync?\n  # end\n\n  private\n    def sync_family\n      Current.family.sync_later\n    end\n\n    def family_needs_auto_sync?\n      return false unless Current.family&.accounts&.active&.any?\n      return false if (Current.family.last_sync_created_at&.to_date || 1.day.ago) >= Date.current\n      return false unless Current.family.auto_sync_on_login\n\n      Rails.logger.info \"Auto-syncing family #{Current.family.id}, last sync was #{Current.family.last_sync_created_at}\"\n\n      true\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/breadcrumbable.rb",
    "content": "module Breadcrumbable\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :set_breadcrumbs\n  end\n\n  private\n    # The default, unless specific controller or action explicitly overrides\n    def set_breadcrumbs\n      @breadcrumbs = [ [ \"Home\", root_path ], [ controller_name.titleize, nil ] ]\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/entryable_resource.rb",
    "content": "module EntryableResource\n  extend ActiveSupport::Concern\n\n  included do\n    include StreamExtensions, ActionView::RecordIdentifier\n\n    before_action :set_entry, only: %i[show update destroy]\n  end\n\n  def show\n  end\n\n  def new\n    account = Current.family.accounts.find_by(id: params[:account_id])\n\n    @entry = Current.family.entries.new(\n      account: account,\n      currency: account ? account.currency : Current.family.currency,\n      entryable: entryable\n    )\n  end\n\n  def create\n    raise NotImplementedError, \"Entryable resources must implement #create\"\n  end\n\n  def update\n    raise NotImplementedError, \"Entryable resources must implement #update\"\n  end\n\n  def destroy\n    account = @entry.account\n    @entry.destroy!\n    @entry.sync_account_later\n\n    redirect_back_or_to account_path(account), notice: t(\"account.entries.destroy.success\")\n  end\n\n  private\n    def entryable\n      controller_name.classify.constantize.new\n    end\n\n    def set_entry\n      @entry = Current.family.entries.find(params[:id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/feature_guardable.rb",
    "content": "# Simple feature guard that renders a 403 Forbidden status with a message\n# when the feature is disabled.\n#\n# Example:\n#\n# class MessagesController < ApplicationController\n#   guard_feature unless: -> { Current.user.ai_enabled? }\n# end\n#\nmodule FeatureGuardable\n  extend ActiveSupport::Concern\n\n  class_methods do\n    def guard_feature(**options)\n      before_action :guard_feature, **options\n    end\n  end\n\n  private\n    def guard_feature\n      render plain: \"Feature disabled: #{controller_name}##{action_name}\", status: :forbidden\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/impersonatable.rb",
    "content": "module Impersonatable\n  extend ActiveSupport::Concern\n\n  included do\n    after_action :create_impersonation_session_log\n  end\n\n  private\n    def create_impersonation_session_log\n      return unless Current.session&.active_impersonator_session.present?\n\n      Current.session.active_impersonator_session.logs.create!(\n        controller: controller_name,\n        action: action_name,\n        path: request.fullpath,\n        method: request.method,\n        ip_address: request.ip,\n        user_agent: request.user_agent\n      )\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/invitable.rb",
    "content": "module Invitable\n  extend ActiveSupport::Concern\n\n  included do\n    helper_method :invite_code_required?\n  end\n\n  private\n    def invite_code_required?\n      return false if @invitation.present?\n      self_hosted? ? Setting.require_invite_for_signup : ENV[\"REQUIRE_INVITE_CODE\"] == \"true\"\n    end\n\n    def self_hosted?\n      Rails.application.config.app_mode.self_hosted?\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/localize.rb",
    "content": "module Localize\n  extend ActiveSupport::Concern\n\n  included do\n    around_action :switch_locale\n    around_action :switch_timezone\n  end\n\n  private\n    def switch_locale(&action)\n      locale = Current.family.try(:locale) || I18n.default_locale\n      I18n.with_locale(locale, &action)\n    end\n\n    def switch_timezone(&action)\n      timezone = Current.family.try(:timezone) || Time.zone\n      Time.use_zone(timezone, &action)\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/notifiable.rb",
    "content": "module Notifiable\n  extend ActiveSupport::Concern\n\n  included do\n    helper_method :render_flash_notifications\n    helper_method :flash_notification_stream_items\n  end\n\n  private\n    def render_flash_notifications\n      notifications = flash.flat_map { |type, data| resolve_notifications(type, data) }.compact\n\n      view_context.safe_join(\n        notifications.map { |notification| view_context.render(**notification) }\n      )\n    end\n\n    def flash_notification_stream_items\n      items = flash.flat_map do |type, data|\n        notifications = resolve_notifications(type, data)\n\n        if type == \"cta\"\n          notifications.map { |notification| turbo_stream.replace(\"cta\", **notification) }\n        else\n          notifications.map { |notification| turbo_stream.append(\"notification-tray\", **notification) }\n        end\n      end.compact\n\n      # If rendering flash notifications via stream, we mark them as used to avoid\n      # them being rendered again on the next page load\n      flash.clear\n\n      items\n    end\n\n    def resolve_cta(cta)\n      case cta[:type]\n      when \"category_rule\"\n        { partial: \"rules/category_rule_cta\", locals: { cta: } }\n      end\n    end\n\n    def resolve_notifications(type, data)\n      case type\n      when \"alert\"\n        [ { partial: \"shared/notifications/alert\", locals: { message: data } } ]\n      when \"cta\"\n        [ resolve_cta(data) ]\n      when \"notice\"\n        messages = Array(data)\n        messages.map { |message| { partial: \"shared/notifications/notice\", locals: { message: message } } }\n      else\n        []\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/onboardable.rb",
    "content": "module Onboardable\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :require_onboarding_and_upgrade\n  end\n\n  private\n    # First, we require onboarding, then once that's complete, we require an upgrade for non-subscribed users.\n    def require_onboarding_and_upgrade\n      return unless Current.user\n      return unless redirectable_path?(request.path)\n\n      if Current.user.needs_onboarding?\n        redirect_to onboarding_path\n      elsif Current.family.needs_subscription?\n        redirect_to trial_onboarding_path\n      elsif Current.family.upgrade_required?\n        redirect_to upgrade_subscription_path\n      end\n    end\n\n    def redirectable_path?(path)\n      return false if path.starts_with?(\"/settings\")\n      return false if path.starts_with?(\"/subscription\")\n      return false if path.starts_with?(\"/onboarding\")\n      return false if path.starts_with?(\"/users\")\n      return false if path.starts_with?(\"/api\")  # Exclude API endpoints from onboarding redirects\n\n      [\n        new_registration_path,\n        new_session_path,\n        new_password_reset_path,\n        new_email_confirmation_path\n      ].exclude?(path)\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/periodable.rb",
    "content": "module Periodable\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :set_period\n  end\n\n  private\n    def set_period\n      @period = Period.from_key(params[:period] || Current.user&.default_period)\n    rescue Period::InvalidKeyError\n      @period = Period.last_30_days\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/restore_layout_preferences.rb",
    "content": "module RestoreLayoutPreferences\n  extend ActiveSupport::Concern\n\n  included do\n    before_action :restore_active_tabs\n  end\n\n  private\n    def restore_active_tabs\n      last_selected_tab = Current.session&.get_preferred_tab(\"account_sidebar_tab\") || \"asset\"\n\n      @account_group_tab = account_group_tab_param || last_selected_tab\n    end\n\n    def valid_account_group_tabs\n      %w[asset liability all]\n    end\n\n    def account_group_tab_param\n      param_value = params[:account_sidebar_tab]\n      return nil unless param_value.in?(valid_account_group_tabs)\n      param_value\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/self_hostable.rb",
    "content": "module SelfHostable\n  extend ActiveSupport::Concern\n\n  included do\n    helper_method :self_hosted?, :self_hosted_first_login?\n\n    prepend_before_action :verify_self_host_config\n  end\n\n  private\n    def self_hosted?\n      Rails.configuration.app_mode.self_hosted?\n    end\n\n    def self_hosted_first_login?\n      self_hosted? && User.count.zero?\n    end\n\n    def verify_self_host_config\n      return unless self_hosted?\n\n      # Special handling for Redis configuration error page\n      if controller_name == \"pages\" && action_name == \"redis_configuration_error\"\n        # If Redis is now working, redirect to home\n        if redis_connected?\n          redirect_to root_path, notice: \"Redis is now configured properly! You can now setup your Maybe application.\"\n        end\n\n        return\n      end\n\n      unless redis_connected?\n        redirect_to redis_configuration_error_path\n      end\n    end\n\n    def redis_connected?\n      Redis.new.ping\n      true\n    rescue Redis::CannotConnectError\n      false\n    end\nend\n"
  },
  {
    "path": "app/controllers/concerns/store_location.rb",
    "content": "module StoreLocation\n  extend ActiveSupport::Concern\n\n  included do\n    helper_method :previous_path\n    before_action :store_return_to\n    after_action :clear_previous_path\n\n    rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found\n  end\n\n  def previous_path\n    session[:return_to] || fallback_path\n  end\n\nprivate\n  def handle_not_found\n    if request.fullpath == session[:return_to]\n      session.delete(:return_to)\n      redirect_to fallback_path\n    else\n      head :not_found\n    end\n  end\n\n  def store_return_to\n    if params[:return_to].present?\n      session[:return_to] = params[:return_to]\n    end\n  end\n\n  def clear_previous_path\n    if request.fullpath == session[:return_to]\n      session.delete(:return_to)\n    end\n  end\n\n  def fallback_path\n    root_path\n  end\nend\n"
  },
  {
    "path": "app/controllers/concerns/stream_extensions.rb",
    "content": "module StreamExtensions\n  extend ActiveSupport::Concern\n\n  def stream_redirect_to(path, notice: nil, alert: nil)\n    custom_stream_redirect(path, notice: notice, alert: alert)\n  end\n\n  def stream_redirect_back_or_to(path, notice: nil, alert: nil)\n    custom_stream_redirect(path, redirect_back: true, notice: notice, alert: alert)\n  end\n\n  private\n    def custom_stream_redirect(path, redirect_back: false, notice: nil, alert: nil)\n      flash[:notice] = notice if notice.present?\n      flash[:alert] = alert if alert.present?\n\n      redirect_target_url = redirect_back ? request.referer : path\n      render turbo_stream: turbo_stream.action(:redirect, redirect_target_url)\n    end\nend\n"
  },
  {
    "path": "app/controllers/cookie_sessions_controller.rb",
    "content": "class CookieSessionsController < ApplicationController\n  def update\n    save_kv_to_session(\n      cookie_session_params[:tab_key],\n      cookie_session_params[:tab_value]\n    )\n\n    redirect_back_or_to root_path\n  end\n\n  private\n    def cookie_session_params\n      params.require(:cookie_session).permit(:tab_key, :tab_value)\n    end\n\n    def save_kv_to_session(key, value)\n      raise \"Key must be a string\" unless key.is_a?(String)\n      raise \"Value must be a string\" unless value.is_a?(String)\n\n      session[\"custom_#{key}\"] = value\n    end\nend\n"
  },
  {
    "path": "app/controllers/credit_cards_controller.rb",
    "content": "class CreditCardsController < ApplicationController\n  include AccountableResource\n\n  permitted_accountable_attributes(\n    :id,\n    :available_credit,\n    :minimum_payment,\n    :apr,\n    :annual_fee,\n    :expiration_date\n  )\nend\n"
  },
  {
    "path": "app/controllers/cryptos_controller.rb",
    "content": "class CryptosController < ApplicationController\n  include AccountableResource\nend\n"
  },
  {
    "path": "app/controllers/currencies_controller.rb",
    "content": "class CurrenciesController < ApplicationController\n  def show\n    currency = Money::Currency.all_instances.find { |currency| currency.iso_code == params[:id] }\n    render json: currency.as_json.merge({ step: currency.step })\n  end\nend\n"
  },
  {
    "path": "app/controllers/current_sessions_controller.rb",
    "content": "class CurrentSessionsController < ApplicationController\n  def update\n    if session_params[:tab_key].present? && session_params[:tab_value].present?\n      Current.session.set_preferred_tab(session_params[:tab_key], session_params[:tab_value])\n    end\n\n    head :ok\n  end\n\n  private\n    def session_params\n      params.require(:current_session).permit(:tab_key, :tab_value)\n    end\nend\n"
  },
  {
    "path": "app/controllers/depositories_controller.rb",
    "content": "class DepositoriesController <  ApplicationController\n  include AccountableResource\nend\n"
  },
  {
    "path": "app/controllers/email_confirmations_controller.rb",
    "content": "class EmailConfirmationsController < ApplicationController\n  skip_before_action :set_request_details, only: :new\n  skip_authentication only: :new\n\n  def new\n    # Returns nil if the token is invalid OR expired\n    @user = User.find_by_token_for(:email_confirmation, params[:token])\n\n    if @user&.unconfirmed_email && @user&.update(\n      email: @user.unconfirmed_email,\n      unconfirmed_email: nil\n    )\n      redirect_to new_session_path, notice: t(\".success_login\")\n    else\n      redirect_to root_path, alert: t(\".invalid_token\")\n    end\n  end\nend\n"
  },
  {
    "path": "app/controllers/family_exports_controller.rb",
    "content": "class FamilyExportsController < ApplicationController\n  include StreamExtensions\n\n  before_action :require_admin\n  before_action :set_export, only: [ :download ]\n\n  def new\n    # Modal view for initiating export\n  end\n\n  def create\n    @export = Current.family.family_exports.create!\n    FamilyDataExportJob.perform_later(@export)\n\n    respond_to do |format|\n      format.html { redirect_to settings_profile_path, notice: \"Export started. You'll be able to download it shortly.\" }\n      format.turbo_stream {\n        stream_redirect_to settings_profile_path, notice: \"Export started. You'll be able to download it shortly.\"\n      }\n    end\n  end\n\n  def index\n    @exports = Current.family.family_exports.ordered.limit(10)\n    render layout: false # For turbo frame\n  end\n\n  def download\n    if @export.downloadable?\n      redirect_to @export.export_file, allow_other_host: true\n    else\n      redirect_to settings_profile_path, alert: \"Export not ready for download\"\n    end\n  end\n\n  private\n\n    def set_export\n      @export = Current.family.family_exports.find(params[:id])\n    end\n\n    def require_admin\n      unless Current.user.admin?\n        redirect_to root_path, alert: \"Access denied\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/family_merchants_controller.rb",
    "content": "class FamilyMerchantsController < ApplicationController\n  before_action :set_merchant, only: %i[edit update destroy]\n\n  def index\n    @breadcrumbs = [ [ \"Home\", root_path ], [ \"Merchants\", nil ] ]\n\n    @family_merchants = Current.family.merchants.alphabetically\n\n    render layout: \"settings\"\n  end\n\n  def new\n    @family_merchant = FamilyMerchant.new(family: Current.family)\n  end\n\n  def create\n    @family_merchant = FamilyMerchant.new(merchant_params.merge(family: Current.family))\n\n    if @family_merchant.save\n      respond_to do |format|\n        format.html { redirect_to family_merchants_path, notice: t(\".success\") }\n        format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }\n      end\n    else\n      render :new, status: :unprocessable_entity\n    end\n  end\n\n  def edit\n  end\n\n  def update\n    @family_merchant.update!(merchant_params)\n    respond_to do |format|\n      format.html { redirect_to family_merchants_path, notice: t(\".success\") }\n      format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }\n    end\n  end\n\n  def destroy\n    @family_merchant.destroy!\n    redirect_to family_merchants_path, notice: t(\".success\")\n  end\n\n  private\n    def set_merchant\n      @family_merchant = Current.family.merchants.find(params[:id])\n    end\n\n    def merchant_params\n      params.require(:family_merchant).permit(:name, :color)\n    end\nend\n"
  },
  {
    "path": "app/controllers/holdings_controller.rb",
    "content": "class HoldingsController < ApplicationController\n  before_action :set_holding, only: %i[show destroy]\n\n  def index\n    @account = Current.family.accounts.find(params[:account_id])\n  end\n\n  def show\n  end\n\n  def destroy\n    if @holding.account.plaid_account_id.present?\n      flash[:alert] = \"You cannot delete this holding\"\n    else\n      @holding.destroy_holding_and_entries!\n      flash[:notice] = t(\".success\")\n    end\n\n    respond_to do |format|\n      format.html { redirect_back_or_to account_path(@holding.account) }\n      format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, account_path(@holding.account)) }\n    end\n  end\n\n  private\n    def set_holding\n      @holding = Current.family.holdings.find(params[:id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/impersonation_sessions_controller.rb",
    "content": "class ImpersonationSessionsController < ApplicationController\n  before_action :require_super_admin!, only: [ :create, :join, :leave ]\n  before_action :set_impersonation_session, only: [ :approve, :reject, :complete ]\n\n  def create\n    Current.true_user.request_impersonation_for(session_params[:impersonated_id])\n    redirect_to root_path, notice: t(\".success\")\n  end\n\n  def join\n    @impersonation_session = Current.true_user.impersonator_support_sessions.find_by(id: params[:impersonation_session_id])\n    Current.session.update!(active_impersonator_session: @impersonation_session)\n    redirect_to root_path, notice: t(\".success\")\n  end\n\n  def leave\n    Current.session.update!(active_impersonator_session: nil)\n    redirect_to root_path, notice: t(\".success\")\n  end\n\n  def approve\n    raise_unauthorized! unless @impersonation_session.impersonated == Current.true_user\n\n    @impersonation_session.approve!\n    redirect_to root_path, notice: t(\".success\")\n  end\n\n  def reject\n    raise_unauthorized! unless @impersonation_session.impersonated == Current.true_user\n\n    @impersonation_session.reject!\n    redirect_to root_path, notice: t(\".success\")\n  end\n\n  def complete\n    @impersonation_session.complete!\n    redirect_to root_path, notice: t(\".success\")\n  end\n\n  private\n    def session_params\n      params.require(:impersonation_session).permit(:impersonated_id)\n    end\n\n    def set_impersonation_session\n      @impersonation_session =\n        Current.true_user.impersonated_support_sessions.find_by(id: params[:id]) ||\n        Current.true_user.impersonator_support_sessions.find_by(id: params[:id])\n    end\n\n    def require_super_admin!\n      raise_unauthorized! unless Current.true_user&.super_admin?\n    end\n\n    def raise_unauthorized!\n      raise ActionController::RoutingError.new(\"Not Found\")\n    end\nend\n"
  },
  {
    "path": "app/controllers/import/cleans_controller.rb",
    "content": "class Import::CleansController < ApplicationController\n  layout \"imports\"\n\n  before_action :set_import\n\n  def show\n    redirect_to import_configuration_path(@import), alert: \"Please configure your import before proceeding.\" unless @import.configured?\n\n    rows = @import.rows.ordered\n\n    if params[:view] == \"errors\"\n      rows = rows.reject { |row| row.valid? }\n    end\n\n    @pagy, @rows = pagy_array(rows, limit: params[:per_page] || \"10\")\n  end\n\n  private\n    def set_import\n      @import = Current.family.imports.find(params[:import_id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/import/configurations_controller.rb",
    "content": "class Import::ConfigurationsController < ApplicationController\n  layout \"imports\"\n\n  before_action :set_import\n\n  def show\n  end\n\n  def update\n    @import.update!(import_params)\n    @import.generate_rows_from_csv\n    @import.reload.sync_mappings\n\n    redirect_to import_clean_path(@import), notice: \"Import configured successfully.\"\n  end\n\n  private\n    def set_import\n      @import = Current.family.imports.find(params[:import_id])\n    end\n\n    def import_params\n      params.require(:import).permit(\n        :date_col_label,\n        :amount_col_label,\n        :name_col_label,\n        :category_col_label,\n        :tags_col_label,\n        :account_col_label,\n        :qty_col_label,\n        :ticker_col_label,\n        :exchange_operating_mic_col_label,\n        :price_col_label,\n        :entity_type_col_label,\n        :notes_col_label,\n        :currency_col_label,\n        :date_format,\n        :number_format,\n        :signage_convention,\n        :amount_type_strategy,\n        :amount_type_inflow_value,\n      )\n    end\nend\n"
  },
  {
    "path": "app/controllers/import/confirms_controller.rb",
    "content": "class Import::ConfirmsController < ApplicationController\n  layout \"imports\"\n\n  before_action :set_import\n\n  def show\n    if @import.mapping_steps.empty?\n      return redirect_to import_path(@import)\n    end\n\n    redirect_to import_clean_path(@import), alert: \"You have invalid data, please edit until all errors are resolved\" unless @import.cleaned?\n  end\n\n  private\n    def set_import\n      @import = Current.family.imports.find(params[:import_id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/import/mappings_controller.rb",
    "content": "class Import::MappingsController < ApplicationController\n  before_action :set_import\n\n  def update\n    mapping = @import.mappings.find(params[:id])\n\n    mapping.update! \\\n      create_when_empty: create_when_empty,\n      mappable: mappable,\n      value: mapping_params[:value]\n\n    redirect_back_or_to import_confirm_path(@import)\n  end\n\n  private\n    def mapping_params\n      params.require(:import_mapping).permit(:type, :key, :mappable_id, :mappable_type, :value)\n    end\n\n    def set_import\n      @import = Current.family.imports.find(params[:import_id])\n    end\n\n    def mappable\n      return nil unless mappable_class.present?\n\n      @mappable ||= mappable_class.find_by(id: mapping_params[:mappable_id], family: Current.family)\n    end\n\n    def create_when_empty\n      return false unless mapping_class.present?\n\n      mapping_params[:mappable_id] == mapping_class::CREATE_NEW_KEY\n    end\n\n    def mappable_class\n      mapping_params[:mappable_type]&.constantize\n    end\n\n    def mapping_class\n      mapping_params[:type]&.constantize\n    end\nend\n"
  },
  {
    "path": "app/controllers/import/rows_controller.rb",
    "content": "class Import::RowsController < ApplicationController\n  before_action :set_import_row\n\n  def update\n    @row.update_and_sync(row_params)\n\n    redirect_to import_row_path(@row.import, @row)\n  end\n\n  def show\n  end\n\n  private\n    def row_params\n      params.require(:import_row).permit(:type, :account, :date, :qty, :ticker, :price, :amount, :currency, :name, :category, :tags, :entity_type, :notes)\n    end\n\n    def set_import_row\n      @import = Current.family.imports.find(params[:import_id])\n      @row = @import.rows.find(params[:id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/import/uploads_controller.rb",
    "content": "class Import::UploadsController < ApplicationController\n  layout \"imports\"\n\n  before_action :set_import\n\n  def show\n  end\n\n  def sample_csv\n    send_data @import.csv_template.to_csv,\n      filename: \"#{@import.type.underscore.split('_').first}_sample.csv\",\n      type: \"text/csv\",\n      disposition: \"attachment\"\n  end\n\n  def update\n    if csv_valid?(csv_str)\n      @import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))\n      @import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep])\n      @import.save!(validate: false)\n\n      redirect_to import_configuration_path(@import, template_hint: true), notice: \"CSV uploaded successfully.\"\n    else\n      flash.now[:alert] = \"Must be valid CSV with headers and at least one row of data\"\n\n      render :show, status: :unprocessable_entity\n    end\n  end\n\n  private\n    def set_import\n      @import = Current.family.imports.find(params[:import_id])\n    end\n\n    def csv_str\n      @csv_str ||= upload_params[:csv_file]&.read || upload_params[:raw_file_str]\n    end\n\n    def csv_valid?(str)\n      begin\n        csv = Import.parse_csv_str(str, col_sep: upload_params[:col_sep])\n        return false if csv.headers.empty?\n        return false if csv.count == 0\n        true\n      rescue CSV::MalformedCSVError\n        false\n      end\n    end\n\n    def upload_params\n      params.require(:import).permit(:raw_file_str, :csv_file, :col_sep)\n    end\nend\n"
  },
  {
    "path": "app/controllers/imports_controller.rb",
    "content": "class ImportsController < ApplicationController\n  before_action :set_import, only: %i[show publish destroy revert apply_template]\n\n  def publish\n    @import.publish_later\n\n    redirect_to import_path(@import), notice: \"Your import has started in the background.\"\n  rescue Import::MaxRowCountExceededError\n    redirect_back_or_to import_path(@import), alert: \"Your import exceeds the maximum row count of #{@import.max_row_count}.\"\n  end\n\n  def index\n    @imports = Current.family.imports\n\n    render layout: \"settings\"\n  end\n\n  def new\n    @pending_import = Current.family.imports.ordered.pending.first\n  end\n\n  def create\n    account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))\n    import = Current.family.imports.create!(\n      type: import_params[:type],\n      account: account,\n      date_format: Current.family.date_format,\n    )\n\n    redirect_to import_upload_path(import)\n  end\n\n  def show\n    if !@import.uploaded?\n      redirect_to import_upload_path(@import), alert: \"Please finalize your file upload.\"\n    elsif !@import.publishable?\n      redirect_to import_confirm_path(@import), alert: \"Please finalize your mappings before proceeding.\"\n    end\n  end\n\n  def revert\n    @import.revert_later\n    redirect_to imports_path, notice: \"Import is reverting in the background.\"\n  end\n\n  def apply_template\n    if @import.suggested_template\n      @import.apply_template!(@import.suggested_template)\n      redirect_to import_configuration_path(@import), notice: \"Template applied.\"\n    else\n      redirect_to import_configuration_path(@import), alert: \"No template found, please manually configure your import.\"\n    end\n  end\n\n  def destroy\n    @import.destroy\n\n    redirect_to imports_path, notice: \"Your import has been deleted.\"\n  end\n\n  private\n    def set_import\n      @import = Current.family.imports.find(params[:id])\n    end\n\n    def import_params\n      params.require(:import).permit(:type)\n    end\nend\n"
  },
  {
    "path": "app/controllers/investments_controller.rb",
    "content": "class InvestmentsController < ApplicationController\n  include AccountableResource\nend\n"
  },
  {
    "path": "app/controllers/invitations_controller.rb",
    "content": "class InvitationsController < ApplicationController\n  skip_authentication only: :accept\n  def new\n    @invitation = Invitation.new\n  end\n\n  def create\n    unless Current.user.admin?\n      flash[:alert] = t(\".failure\")\n      redirect_to settings_profile_path\n      return\n    end\n\n    @invitation = Current.family.invitations.build(invitation_params)\n    @invitation.inviter = Current.user\n\n    if @invitation.save\n      InvitationMailer.invite_email(@invitation).deliver_later unless self_hosted?\n      flash[:notice] = t(\".success\")\n    else\n      flash[:alert] = t(\".failure\")\n    end\n\n    redirect_to settings_profile_path\n  end\n\n  def accept\n    @invitation = Invitation.find_by!(token: params[:id])\n\n    if @invitation.pending?\n      redirect_to new_registration_path(invitation: @invitation.token)\n    else\n      raise ActiveRecord::RecordNotFound\n    end\n  end\n\n  def destroy\n    unless Current.user.admin?\n      flash[:alert] = t(\"invitations.destroy.not_authorized\")\n      redirect_to settings_profile_path\n      return\n    end\n\n    @invitation = Current.family.invitations.find(params[:id])\n\n    if @invitation.destroy\n      flash[:notice] = t(\"invitations.destroy.success\")\n    else\n      flash[:alert] = t(\"invitations.destroy.failure\")\n    end\n\n    redirect_to settings_profile_path\n  end\n\n  private\n\n    def invitation_params\n      params.require(:invitation).permit(:email, :role)\n    end\nend\n"
  },
  {
    "path": "app/controllers/invite_codes_controller.rb",
    "content": "class InviteCodesController < ApplicationController\n  before_action :ensure_self_hosted\n\n  def index\n    @invite_codes = InviteCode.all\n  end\n\n  def create\n    raise StandardError, \"You are not allowed to generate invite codes\" unless Current.user.admin?\n    InviteCode.generate!\n    redirect_back_or_to invite_codes_path, notice: \"Code generated\"\n  end\n\n  private\n\n    def ensure_self_hosted\n      redirect_to root_path unless self_hosted?\n    end\nend\n"
  },
  {
    "path": "app/controllers/loans_controller.rb",
    "content": "class LoansController < ApplicationController\n  include AccountableResource\n\n  permitted_accountable_attributes(\n    :id, :rate_type, :interest_rate, :term_months, :initial_balance\n  )\nend\n"
  },
  {
    "path": "app/controllers/lookbooks_controller.rb",
    "content": "class LookbooksController < Lookbook::PreviewController\n  layout \"lookbooks\"\nend\n"
  },
  {
    "path": "app/controllers/messages_controller.rb",
    "content": "class MessagesController < ApplicationController\n  guard_feature unless: -> { Current.user.ai_enabled? }\n\n  before_action :set_chat\n\n  def create\n    @message = UserMessage.create!(\n      chat: @chat,\n      content: message_params[:content],\n      ai_model: message_params[:ai_model]\n    )\n\n    redirect_to chat_path(@chat, thinking: true)\n  end\n\n  private\n    def set_chat\n      @chat = Current.user.chats.find(params[:chat_id])\n    end\n\n    def message_params\n      params.require(:message).permit(:content, :ai_model)\n    end\nend\n"
  },
  {
    "path": "app/controllers/mfa_controller.rb",
    "content": "class MfaController < ApplicationController\n  layout :determine_layout\n  skip_authentication only: [ :verify, :verify_code ]\n\n  def new\n    redirect_to root_path if Current.user.otp_required?\n    Current.user.setup_mfa! unless Current.user.otp_secret.present?\n  end\n\n  def create\n    if Current.user.verify_otp?(params[:code])\n      Current.user.enable_mfa!\n      @backup_codes = Current.user.otp_backup_codes\n      render :backup_codes\n    else\n      Current.user.disable_mfa!\n      redirect_to new_mfa_path, alert: t(\".invalid_code\")\n    end\n  end\n\n  def verify\n    @user = User.find_by(id: session[:mfa_user_id])\n\n    if @user.nil?\n      redirect_to new_session_path\n    end\n  end\n\n  def verify_code\n    @user = User.find_by(id: session[:mfa_user_id])\n\n    if @user&.verify_otp?(params[:code])\n      session.delete(:mfa_user_id)\n      @session = create_session_for(@user)\n      redirect_to root_path\n    else\n      flash.now[:alert] = t(\".invalid_code\")\n      render :verify, status: :unprocessable_entity\n    end\n  end\n\n  def disable\n    Current.user.disable_mfa!\n    redirect_to settings_security_path, notice: t(\".success\")\n  end\n\n  private\n\n    def determine_layout\n      if action_name.in?(%w[verify verify_code])\n        \"auth\"\n      else\n        \"settings\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/onboardings_controller.rb",
    "content": "class OnboardingsController < ApplicationController\n  layout \"wizard\"\n\n  before_action :set_user\n  before_action :load_invitation\n\n  def show\n  end\n\n  def preferences\n  end\n\n  def trial\n  end\n\n  private\n    def set_user\n      @user = Current.user\n    end\n\n    def load_invitation\n      @invitation = Current.family.invitations.accepted.find_by(email: Current.user.email)\n    end\nend\n"
  },
  {
    "path": "app/controllers/other_assets_controller.rb",
    "content": "class OtherAssetsController < ApplicationController\n  include AccountableResource\nend\n"
  },
  {
    "path": "app/controllers/other_liabilities_controller.rb",
    "content": "class OtherLiabilitiesController < ApplicationController\n  include AccountableResource\nend\n"
  },
  {
    "path": "app/controllers/pages_controller.rb",
    "content": "class PagesController < ApplicationController\n  include Periodable\n\n  skip_authentication only: :redis_configuration_error\n\n  def dashboard\n    @balance_sheet = Current.family.balance_sheet\n    @accounts = Current.family.accounts.visible.with_attached_logo\n\n    period_param = params[:cashflow_period]\n    @cashflow_period = if period_param.present?\n      begin\n        Period.from_key(period_param)\n      rescue Period::InvalidKeyError\n        Period.last_30_days\n      end\n    else\n      Period.last_30_days\n    end\n\n    family_currency = Current.family.currency\n    income_totals = Current.family.income_statement.income_totals(period: @cashflow_period)\n    expense_totals = Current.family.income_statement.expense_totals(period: @cashflow_period)\n\n    @cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency)\n\n    @breadcrumbs = [ [ \"Home\", root_path ], [ \"Dashboard\", nil ] ]\n  end\n\n  def changelog\n    @release_notes = github_provider.fetch_latest_release_notes\n\n    # Fallback if no release notes are available\n    if @release_notes.nil?\n      @release_notes = {\n        avatar: \"https://github.com/maybe-finance.png\",\n        username: \"maybe-finance\",\n        name: \"Release notes unavailable\",\n        published_at: Date.current,\n        body: \"<p>Unable to fetch the latest release notes at this time. Please check back later or visit our <a href='https://github.com/maybe-finance/maybe/releases' target='_blank'>GitHub releases page</a> directly.</p>\"\n      }\n    end\n\n    render layout: \"settings\"\n  end\n\n  def feedback\n    render layout: \"settings\"\n  end\n\n  def redis_configuration_error\n    render layout: \"blank\"\n  end\n\n  private\n    def github_provider\n      Provider::Registry.get_provider(:github)\n    end\n\n    def build_cashflow_sankey_data(income_totals, expense_totals, currency_symbol)\n      nodes = []\n      links = []\n      node_indices = {} # Memoize node indices by a unique key: \"type_categoryid\"\n\n      # Helper to add/find node and return its index\n      add_node = ->(unique_key, display_name, value, percentage, color) {\n        node_indices[unique_key] ||= begin\n          nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color }\n          nodes.size - 1\n        end\n      }\n\n      total_income_val = income_totals.total.to_f.round(2)\n      total_expense_val = expense_totals.total.to_f.round(2)\n\n      # --- Create Central Cash Flow Node ---\n      cash_flow_idx = add_node.call(\"cash_flow_node\", \"Cash Flow\", total_income_val, 0, \"var(--color-success)\")\n\n      # --- Process Income Side (Top-level categories only) ---\n      income_totals.category_totals.each do |ct|\n        # Skip subcategories – only include root income categories\n        next if ct.category.parent_id.present?\n\n        val = ct.total.to_f.round(2)\n        next if val.zero?\n\n        percentage_of_total_income = total_income_val.zero? ? 0 : (val / total_income_val * 100).round(1)\n\n        node_display_name = ct.category.name\n        node_color = ct.category.color.presence || Category::COLORS.sample\n\n        current_cat_idx = add_node.call(\n          \"income_#{ct.category.id}\",\n          node_display_name,\n          val,\n          percentage_of_total_income,\n          node_color\n        )\n\n        links << {\n          source: current_cat_idx,\n          target: cash_flow_idx,\n          value: val,\n          color: node_color,\n          percentage: percentage_of_total_income\n        }\n      end\n\n      # --- Process Expense Side (Top-level categories only) ---\n      expense_totals.category_totals.each do |ct|\n        # Skip subcategories – only include root expense categories to keep Sankey shallow\n        next if ct.category.parent_id.present?\n\n        val = ct.total.to_f.round(2)\n        next if val.zero?\n\n        percentage_of_total_expense = total_expense_val.zero? ? 0 : (val / total_expense_val * 100).round(1)\n\n        node_display_name = ct.category.name\n        node_color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR\n\n        current_cat_idx = add_node.call(\n          \"expense_#{ct.category.id}\",\n          node_display_name,\n          val,\n          percentage_of_total_expense,\n          node_color\n        )\n\n        links << {\n          source: cash_flow_idx,\n          target: current_cat_idx,\n          value: val,\n          color: node_color,\n          percentage: percentage_of_total_expense\n        }\n      end\n\n      # --- Process Surplus ---\n      leftover = (total_income_val - total_expense_val).round(2)\n      if leftover.positive?\n        percentage_of_total_income_for_surplus = total_income_val.zero? ? 0 : (leftover / total_income_val * 100).round(1)\n        surplus_idx = add_node.call(\"surplus_node\", \"Surplus\", leftover, percentage_of_total_income_for_surplus, \"var(--color-success)\")\n        links << { source: cash_flow_idx, target: surplus_idx, value: leftover, color: \"var(--color-success)\", percentage: percentage_of_total_income_for_surplus }\n      end\n\n      # Update Cash Flow and Income node percentages (relative to total income)\n      if node_indices[\"cash_flow_node\"]\n        nodes[node_indices[\"cash_flow_node\"]][:percentage] = 100.0\n      end\n      # No primary income node anymore, percentages are on individual income cats relative to total_income_val\n\n      { nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency_symbol).symbol }\n    end\nend\n"
  },
  {
    "path": "app/controllers/password_resets_controller.rb",
    "content": "class PasswordResetsController < ApplicationController\n  skip_authentication\n\n  layout \"auth\"\n\n  before_action :set_user_by_token, only: %i[edit update]\n\n  def new\n  end\n\n  def create\n    if (user = User.find_by(email: params[:email]))\n      PasswordMailer.with(\n        user: user,\n        token: user.generate_token_for(:password_reset)\n      ).password_reset.deliver_later\n    end\n\n    redirect_to new_password_reset_path(step: \"pending\")\n  end\n\n  def edit\n    @user = User.new\n  end\n\n  def update\n    if @user.update(password_params)\n      redirect_to new_session_path, notice: t(\".success\")\n    else\n      render :edit, status: :unprocessable_entity\n    end\n  end\n\n  private\n\n    def set_user_by_token\n      @user = User.find_by_token_for(:password_reset, params[:token])\n      redirect_to new_password_reset_path, alert: t(\"password_resets.update.invalid_token\") unless @user.present?\n    end\n\n    def password_params\n      params.require(:user).permit(:password, :password_confirmation)\n    end\nend\n"
  },
  {
    "path": "app/controllers/passwords_controller.rb",
    "content": "class PasswordsController < ApplicationController\n  def edit\n  end\n\n  def update\n    if Current.user.update(password_params)\n      redirect_to root_path, notice: t(\".success\")\n    else\n      render :edit, status: :unprocessable_entity\n    end\n  end\n\n  private\n\n    def password_params\n      params.require(:user).permit(:password, :password_confirmation, :password_challenge).with_defaults(password_challenge: \"\")\n    end\nend\n"
  },
  {
    "path": "app/controllers/plaid_items_controller.rb",
    "content": "class PlaidItemsController < ApplicationController\n  before_action :set_plaid_item, only: %i[edit destroy sync]\n\n  def new\n    region = params[:region] == \"eu\" ? :eu : :us\n    webhooks_url = region == :eu ? plaid_eu_webhooks_url : plaid_us_webhooks_url\n\n    @link_token = Current.family.get_link_token(\n      webhooks_url: webhooks_url,\n      redirect_url: accounts_url,\n      accountable_type: params[:accountable_type] || \"Depository\",\n      region: region\n    )\n  end\n\n  def edit\n    webhooks_url = @plaid_item.plaid_region == \"eu\" ? plaid_eu_webhooks_url : plaid_us_webhooks_url\n\n    @link_token = @plaid_item.get_update_link_token(\n      webhooks_url: webhooks_url,\n      redirect_url: accounts_url,\n    )\n  end\n\n  def create\n    Current.family.create_plaid_item!(\n      public_token: plaid_item_params[:public_token],\n      item_name: item_name,\n      region: plaid_item_params[:region]\n    )\n\n    redirect_to accounts_path, notice: t(\".success\")\n  end\n\n  def destroy\n    @plaid_item.destroy_later\n    redirect_to accounts_path, notice: t(\".success\")\n  end\n\n  def sync\n    unless @plaid_item.syncing?\n      @plaid_item.sync_later\n    end\n\n    respond_to do |format|\n      format.html { redirect_back_or_to accounts_path }\n      format.json { head :ok }\n    end\n  end\n\n  private\n    def set_plaid_item\n      @plaid_item = Current.family.plaid_items.find(params[:id])\n    end\n\n    def plaid_item_params\n      params.require(:plaid_item).permit(:public_token, :region, metadata: {})\n    end\n\n    def item_name\n      plaid_item_params.dig(:metadata, :institution, :name)\n    end\n\n    def plaid_us_webhooks_url\n      return webhooks_plaid_url if Rails.env.production?\n\n      ENV.fetch(\"DEV_WEBHOOKS_URL\", root_url.chomp(\"/\")) + \"/webhooks/plaid\"\n    end\n\n    def plaid_eu_webhooks_url\n      return webhooks_plaid_eu_url if Rails.env.production?\n\n      ENV.fetch(\"DEV_WEBHOOKS_URL\", root_url.chomp(\"/\")) + \"/webhooks/plaid_eu\"\n    end\nend\n"
  },
  {
    "path": "app/controllers/properties_controller.rb",
    "content": "class PropertiesController < ApplicationController\n  include AccountableResource, StreamExtensions\n\n  before_action :set_property, only: [ :balances, :address, :update_balances, :update_address ]\n\n  def new\n    @account = Current.family.accounts.build(accountable: Property.new)\n  end\n\n  def create\n    @account = Current.family.accounts.create!(\n      property_params.merge(currency: Current.family.currency, balance: 0, status: \"draft\")\n    )\n\n    redirect_to balances_property_path(@account)\n  end\n\n  def update\n    if @account.update(property_params)\n      @success_message = \"Property details updated successfully.\"\n\n      if @account.active?\n        render :edit\n      else\n        redirect_to balances_property_path(@account)\n      end\n    else\n      @error_message = \"Unable to update property details.\"\n      render :edit, status: :unprocessable_entity\n    end\n  end\n\n  def edit\n  end\n\n  def balances\n  end\n\n  def update_balances\n    result = @account.set_current_balance(balance_params[:balance].to_d)\n\n    if result.success?\n      @success_message = \"Balance updated successfully.\"\n\n      if @account.active?\n        render :balances\n      else\n        redirect_to address_property_path(@account)\n      end\n    else\n      @error_message = result.error_message\n      render :balances, status: :unprocessable_entity\n    end\n  end\n\n  def address\n    @property = @account.property\n    @property.address ||= Address.new\n  end\n\n  def update_address\n    if @account.property.update(address_params)\n      if @account.draft?\n        @account.activate!\n\n        respond_to do |format|\n          format.html { redirect_to account_path(@account) }\n          format.turbo_stream { stream_redirect_to account_path(@account) }\n        end\n      else\n        @success_message = \"Address updated successfully.\"\n        render :address\n      end\n    else\n      @error_message = \"Unable to update address. Please check the required fields.\"\n      render :address, status: :unprocessable_entity\n    end\n  end\n\n  private\n    def balance_params\n      params.require(:account).permit(:balance, :currency)\n    end\n\n    def address_params\n      params.require(:property)\n            .permit(address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ])\n    end\n\n    def property_params\n      params.require(:account)\n            .permit(:name, :subtype, :accountable_type, accountable_attributes: [ :id, :year_built, :area_unit, :area_value ])\n    end\n\n    def set_property\n      @account = Current.family.accounts.find(params[:id])\n      @property = @account.property\n    end\nend\n"
  },
  {
    "path": "app/controllers/registrations_controller.rb",
    "content": "class RegistrationsController < ApplicationController\n  skip_authentication\n\n  layout \"auth\"\n\n  before_action :set_user, only: :create\n  before_action :set_invitation\n  before_action :claim_invite_code, only: :create, if: :invite_code_required?\n  before_action :validate_password_requirements, only: :create\n\n  def new\n    @user = User.new(email: @invitation&.email)\n  end\n\n  def create\n    if @invitation\n      @user.family = @invitation.family\n      @user.role = @invitation.role\n      @user.email = @invitation.email\n    else\n      family = Family.new\n      @user.family = family\n      @user.role = :admin\n    end\n\n    if @user.save\n      @invitation&.update!(accepted_at: Time.current)\n      @session = create_session_for(@user)\n      redirect_to root_path, notice: t(\".success\")\n    else\n      render :new, status: :unprocessable_entity, alert: t(\".failure\")\n    end\n  end\n\n  private\n\n    def set_invitation\n      token = params[:invitation]\n      token ||= params[:user][:invitation] if params[:user].present?\n      @invitation = Invitation.pending.find_by(token: token)\n    end\n\n    def set_user\n      @user = User.new user_params.except(:invite_code, :invitation)\n    end\n\n    def user_params(specific_param = nil)\n      params = self.params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code, :invitation)\n      specific_param ? params[specific_param] : params\n    end\n\n    def claim_invite_code\n      unless InviteCode.claim! params[:user][:invite_code]\n        redirect_to new_registration_path, alert: t(\"registrations.create.invalid_invite_code\")\n      end\n    end\n\n    def validate_password_requirements\n      password = user_params[:password]\n      return if password.blank? # Let Rails built-in validations handle blank passwords\n\n      if password.length < 8\n        @user.errors.add(:password, \"must be at least 8 characters\")\n      end\n\n      unless password.match?(/[A-Z]/) && password.match?(/[a-z]/)\n        @user.errors.add(:password, \"must include both uppercase and lowercase letters\")\n      end\n\n      unless password.match?(/\\d/)\n        @user.errors.add(:password, \"must include at least one number\")\n      end\n\n      unless password.match?(/[!@#$%^&*(),.?\":{}|<>]/)\n        @user.errors.add(:password, \"must include at least one special character\")\n      end\n\n      if @user.errors.present?\n        render :new, status: :unprocessable_entity\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/rules_controller.rb",
    "content": "class RulesController < ApplicationController\n  include StreamExtensions\n\n  before_action :set_rule, only: [  :edit, :update, :destroy, :apply, :confirm ]\n\n  def index\n    @sort_by = params[:sort_by] || \"name\"\n    @direction = params[:direction] || \"asc\"\n\n    allowed_columns = [ \"name\", \"updated_at\" ]\n    @sort_by = \"name\" unless allowed_columns.include?(@sort_by)\n    @direction = \"asc\" unless [ \"asc\", \"desc\" ].include?(@direction)\n\n    @rules = Current.family.rules.order(@sort_by => @direction)\n    render layout: \"settings\"\n  end\n\n  def new\n    @rule = Current.family.rules.build(\n      resource_type: params[:resource_type] || \"transaction\",\n    )\n  end\n\n  def create\n    @rule = Current.family.rules.build(rule_params)\n\n    if @rule.save\n      redirect_to confirm_rule_path(@rule)\n    else\n      render :new, status: :unprocessable_entity\n    end\n  end\n\n  def apply\n    @rule.update!(active: true)\n    @rule.apply_later(ignore_attribute_locks: true)\n    redirect_back_or_to rules_path, notice: \"#{@rule.resource_type.humanize} rule activated\"\n  end\n\n  def confirm\n  end\n\n  def edit\n  end\n\n  def update\n    if @rule.update(rule_params)\n      respond_to do |format|\n        format.html { redirect_back_or_to rules_path, notice: \"Rule updated\" }\n        format.turbo_stream { stream_redirect_back_or_to rules_path, notice: \"Rule updated\" }\n      end\n    else\n      render :edit, status: :unprocessable_entity\n    end\n  end\n\n  def destroy\n    @rule.destroy\n    redirect_to rules_path, notice: \"Rule deleted\"\n  end\n\n  def destroy_all\n    Current.family.rules.destroy_all\n    redirect_to rules_path, notice: \"All rules deleted\"\n  end\n\n  private\n    def set_rule\n      @rule = Current.family.rules.find(params[:id])\n    end\n\n    def rule_params\n      params.require(:rule).permit(\n        :resource_type, :effective_date, :active, :name,\n        conditions_attributes: [\n          :id, :condition_type, :operator, :value, :_destroy,\n          sub_conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy ]\n        ],\n        actions_attributes: [\n          :id, :action_type, :value, :_destroy\n        ]\n      )\n    end\nend\n"
  },
  {
    "path": "app/controllers/securities_controller.rb",
    "content": "class SecuritiesController < ApplicationController\n  def index\n    @securities = Security.search_provider(\n      params[:q],\n      country_code: params[:country_code] == \"US\" ? \"US\" : nil\n    )\n  end\nend\n"
  },
  {
    "path": "app/controllers/sessions_controller.rb",
    "content": "class SessionsController < ApplicationController\n  before_action :set_session, only: :destroy\n  skip_authentication only: %i[new create]\n\n  layout \"auth\"\n\n  def new\n  end\n\n  def create\n    if user = User.authenticate_by(email: params[:email], password: params[:password])\n      if user.otp_required?\n        session[:mfa_user_id] = user.id\n        redirect_to verify_mfa_path\n      else\n        @session = create_session_for(user)\n        redirect_to root_path\n      end\n    else\n      flash.now[:alert] = t(\".invalid_credentials\")\n      render :new, status: :unprocessable_entity\n    end\n  end\n\n  def destroy\n    @session.destroy\n    redirect_to new_session_path, notice: t(\".logout_successful\")\n  end\n\n  private\n    def set_session\n      @session = Current.user.sessions.find(params[:id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/settings/api_keys_controller.rb",
    "content": "# frozen_string_literal: true\n\nclass Settings::ApiKeysController < ApplicationController\n  layout \"settings\"\n\n  before_action :set_api_key, only: [ :show, :destroy ]\n\n  def show\n    @current_api_key = @api_key\n  end\n\n  def new\n    # Allow regeneration by not redirecting if user explicitly wants to create a new key\n    # Only redirect if user stumbles onto new page without explicit intent\n    redirect_to settings_api_key_path if Current.user.api_keys.active.exists? && !params[:regenerate]\n    @api_key = ApiKey.new\n  end\n\n  def create\n    @plain_key = ApiKey.generate_secure_key\n    @api_key = Current.user.api_keys.build(api_key_params)\n    @api_key.key = @plain_key\n\n    # Temporarily revoke existing keys for validation to pass\n    existing_keys = Current.user.api_keys.active\n    existing_keys.each { |key| key.update_column(:revoked_at, Time.current) }\n\n    if @api_key.save\n      flash[:notice] = \"Your API key has been created successfully\"\n      redirect_to settings_api_key_path\n    else\n      # Restore existing keys if new key creation failed\n      existing_keys.each { |key| key.update_column(:revoked_at, nil) }\n      render :new, status: :unprocessable_entity\n    end\n  end\n\n  def destroy\n    if @api_key&.revoke!\n      flash[:notice] = \"API key has been revoked successfully\"\n    else\n      flash[:alert] = \"Failed to revoke API key\"\n    end\n    redirect_to settings_api_key_path\n  end\n\n  private\n\n    def set_api_key\n      @api_key = Current.user.api_keys.active.first\n    end\n\n    def api_key_params\n      # Convert single scope value to array for storage\n      permitted_params = params.require(:api_key).permit(:name, :scopes)\n      if permitted_params[:scopes].present?\n        permitted_params[:scopes] = [ permitted_params[:scopes] ]\n      end\n      permitted_params\n    end\nend\n"
  },
  {
    "path": "app/controllers/settings/billings_controller.rb",
    "content": "class Settings::BillingsController < ApplicationController\n  layout \"settings\"\n\n  def show\n    @family = Current.family\n  end\nend\n"
  },
  {
    "path": "app/controllers/settings/hostings_controller.rb",
    "content": "class Settings::HostingsController < ApplicationController\n  layout \"settings\"\n\n  guard_feature unless: -> { self_hosted? }\n\n  before_action :ensure_admin, only: :clear_cache\n\n  def show\n    synth_provider = Provider::Registry.get_provider(:synth)\n    @synth_usage = synth_provider&.usage\n  end\n\n  def update\n    if hosting_params.key?(:require_invite_for_signup)\n      Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup]\n    end\n\n    if hosting_params.key?(:require_email_confirmation)\n      Setting.require_email_confirmation = hosting_params[:require_email_confirmation]\n    end\n\n    if hosting_params.key?(:synth_api_key)\n      Setting.synth_api_key = hosting_params[:synth_api_key]\n    end\n\n    redirect_to settings_hosting_path, notice: t(\".success\")\n  rescue ActiveRecord::RecordInvalid => error\n    flash.now[:alert] = t(\".failure\")\n    render :show, status: :unprocessable_entity\n  end\n\n  def clear_cache\n    DataCacheClearJob.perform_later(Current.family)\n    redirect_to settings_hosting_path, notice: t(\".cache_cleared\")\n  end\n\n  private\n    def hosting_params\n      params.require(:setting).permit(:require_invite_for_signup, :require_email_confirmation, :synth_api_key)\n    end\n\n    def ensure_admin\n      redirect_to settings_hosting_path, alert: t(\".not_authorized\") unless Current.user.admin?\n    end\nend\n"
  },
  {
    "path": "app/controllers/settings/preferences_controller.rb",
    "content": "class Settings::PreferencesController < ApplicationController\n  layout \"settings\"\n\n  def show\n    @user = Current.user\n  end\nend\n"
  },
  {
    "path": "app/controllers/settings/profiles_controller.rb",
    "content": "class Settings::ProfilesController < ApplicationController\n  layout \"settings\"\n\n  def show\n    @user = Current.user\n    @users = Current.family.users.order(:created_at)\n    @pending_invitations = Current.family.invitations.pending\n  end\n\n  def destroy\n    unless Current.user.admin?\n      flash[:alert] = t(\"settings.profiles.destroy.not_authorized\")\n      redirect_to settings_profile_path\n      return\n    end\n\n    @user = Current.family.users.find(params[:user_id])\n\n    if @user == Current.user\n      flash[:alert] = t(\"settings.profiles.destroy.cannot_remove_self\")\n      redirect_to settings_profile_path\n      return\n    end\n\n    if @user.destroy\n      # Also destroy the invitation associated with this user for this family\n      Current.family.invitations.find_by(email: @user.email)&.destroy\n      flash[:notice] = \"Member removed successfully.\"\n    else\n      flash[:alert] = \"Failed to remove member.\"\n    end\n\n    redirect_to settings_profile_path\n  end\nend\n"
  },
  {
    "path": "app/controllers/settings/securities_controller.rb",
    "content": "class Settings::SecuritiesController < ApplicationController\n  layout \"settings\"\n\n  def show\n  end\nend\n"
  },
  {
    "path": "app/controllers/subscriptions_controller.rb",
    "content": "class SubscriptionsController < ApplicationController\n  # Disables subscriptions for self hosted instances\n  guard_feature if: -> { self_hosted? }\n\n  # Upgrade page for unsubscribed users\n  def upgrade\n    if Current.family.subscription&.active?\n      redirect_to root_path, notice: \"You are already subscribed.\"\n    else\n      @plan = params[:plan] || \"annual\"\n      render layout: \"onboardings\"\n    end\n  end\n\n  def new\n    checkout_session = stripe.create_checkout_session(\n      plan: params[:plan],\n      family_id: Current.family.id,\n      family_email: Current.family.billing_email,\n      success_url: success_subscription_url + \"?session_id={CHECKOUT_SESSION_ID}\",\n      cancel_url: upgrade_subscription_url\n    )\n\n    Current.family.update!(stripe_customer_id: checkout_session.customer_id)\n\n    redirect_to checkout_session.url, allow_other_host: true, status: :see_other\n  end\n\n  # Only used for managing our \"offline\" trials.  Paid subscriptions are handled in success callback of checkout session\n  def create\n    if Current.family.can_start_trial?\n      Current.family.start_trial_subscription!\n      redirect_to root_path, notice: \"Welcome to Maybe!\"\n    else\n      redirect_to root_path, alert: \"You have already started or completed a trial. Please upgrade to continue.\"\n    end\n  end\n\n  def show\n    portal_session_url = stripe.create_billing_portal_session_url(\n      customer_id: Current.family.stripe_customer_id,\n      return_url: settings_billing_url\n    )\n\n    redirect_to portal_session_url, allow_other_host: true, status: :see_other\n  end\n\n  # Stripe redirects here after a successful checkout session and passes the session ID in the URL\n  def success\n    checkout_result = stripe.get_checkout_result(params[:session_id])\n\n    if checkout_result.success?\n      Current.family.start_subscription!(checkout_result.subscription_id)\n      redirect_to root_path, notice: \"Welcome to Maybe!  Your subscription has been created.\"\n    else\n      redirect_to root_path, alert: \"Something went wrong processing your subscription. Please contact us to get this fixed.\"\n    end\n  end\n\n  private\n    def stripe\n      @stripe ||= Provider::Registry.get_provider(:stripe)\n    end\nend\n"
  },
  {
    "path": "app/controllers/tag/deletions_controller.rb",
    "content": "class Tag::DeletionsController < ApplicationController\n  before_action :set_tag\n  before_action :set_replacement_tag, only: :create\n\n  def new\n  end\n\n  def create\n    @tag.replace_and_destroy! @replacement_tag\n    redirect_back_or_to tags_path, notice: t(\".deleted\")\n  end\n\n  private\n\n    def set_tag\n      @tag = Current.family.tags.find_by(id: params[:tag_id])\n    end\n\n    def set_replacement_tag\n      @replacement_tag = Current.family.tags.find_by(id: params[:replacement_tag_id])\n    end\nend\n"
  },
  {
    "path": "app/controllers/tags_controller.rb",
    "content": "class TagsController < ApplicationController\n  before_action :set_tag, only: %i[edit update destroy]\n\n  def index\n    @tags = Current.family.tags.alphabetically\n\n    render layout: \"settings\"\n  end\n\n  def new\n    @tag = Current.family.tags.new color: Tag::COLORS.sample\n  end\n\n  def create\n    @tag = Current.family.tags.new(tag_params)\n\n    if @tag.save\n      redirect_to tags_path, notice: t(\".created\")\n    else\n      redirect_to tags_path, alert: t(\".error\", error: @tag.errors.full_messages.to_sentence)\n    end\n  end\n\n  def edit\n  end\n\n  def update\n    @tag.update!(tag_params)\n    redirect_to tags_path, notice: t(\".updated\")\n  end\n\n  def destroy\n    @tag.destroy!\n    redirect_to tags_path, notice: t(\".deleted\")\n  end\n\n  def destroy_all\n    Current.family.tags.destroy_all\n    redirect_back_or_to tags_path, notice: \"All tags deleted\"\n  end\n\n  private\n\n    def set_tag\n      @tag = Current.family.tags.find(params[:id])\n    end\n\n    def tag_params\n      params.require(:tag).permit(:name, :color)\n    end\nend\n"
  },
  {
    "path": "app/controllers/trades_controller.rb",
    "content": "class TradesController < ApplicationController\n  include EntryableResource\n\n  # Defaults to a buy trade\n  def new\n    @account = Current.family.accounts.find_by(id: params[:account_id])\n    @model = Current.family.entries.new(\n      account: @account,\n      currency: @account ? @account.currency : Current.family.currency,\n      entryable: Trade.new\n    )\n  end\n\n  # Can create a trade, transaction (e.g. \"fees\"), or transfer (e.g. \"withdrawal\")\n  def create\n    @account = Current.family.accounts.find(params[:account_id])\n    @model = Trade::CreateForm.new(create_params.merge(account: @account)).create\n\n    if @model.persisted?\n      flash[:notice] = t(\"entries.create.success\")\n\n      respond_to do |format|\n        format.html { redirect_back_or_to account_path(@account) }\n        format.turbo_stream { stream_redirect_back_or_to account_path(@account) }\n      end\n    else\n      render :new, status: :unprocessable_entity\n    end\n  end\n\n  def update\n    if @entry.update(update_entry_params)\n      @entry.sync_account_later\n\n      respond_to do |format|\n        format.html { redirect_back_or_to account_path(@entry.account), notice: t(\"entries.update.success\") }\n        format.turbo_stream do\n          render turbo_stream: [\n            turbo_stream.replace(\n              \"header_entry_#{@entry.id}\",\n              partial: \"trades/header\",\n              locals: { entry: @entry }\n            ),\n            turbo_stream.replace(\"entry_#{@entry.id}\", partial: \"entries/entry\", locals: { entry: @entry })\n          ]\n        end\n      end\n    else\n      render :show, status: :unprocessable_entity\n    end\n  end\n\n  private\n    def entry_params\n      params.require(:entry).permit(\n        :name, :date, :amount, :currency, :excluded, :notes, :nature,\n        entryable_attributes: [ :id, :qty, :price ]\n      )\n    end\n\n    def create_params\n      params.require(:model).permit(\n        :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id\n      )\n    end\n\n    def update_entry_params\n      return entry_params unless entry_params[:entryable_attributes].present?\n\n      update_params = entry_params\n      update_params = update_params.merge(entryable_type: \"Trade\")\n\n      qty = update_params[:entryable_attributes][:qty]\n      price = update_params[:entryable_attributes][:price]\n\n      if qty.present? && price.present?\n        qty = update_params[:nature] == \"inflow\" ? -qty.to_d : qty.to_d\n        update_params[:entryable_attributes][:qty] = qty\n        update_params[:amount] = qty * price.to_d\n      end\n\n      update_params.except(:nature)\n    end\nend\n"
  },
  {
    "path": "app/controllers/transaction_categories_controller.rb",
    "content": "class TransactionCategoriesController < ApplicationController\n  include ActionView::RecordIdentifier\n\n  def update\n    @entry = Current.family.entries.transactions.find(params[:transaction_id])\n    @entry.update!(entry_params)\n\n    transaction = @entry.transaction\n\n    if needs_rule_notification?(transaction)\n      flash[:cta] = {\n        type: \"category_rule\",\n        category_id: transaction.category_id,\n        category_name: transaction.category.name\n      }\n    end\n\n    transaction.lock_saved_attributes!\n    @entry.lock_saved_attributes!\n\n    respond_to do |format|\n      format.html { redirect_back_or_to transaction_path(@entry) }\n      format.turbo_stream do\n        render turbo_stream: [\n          turbo_stream.replace(\n            dom_id(transaction, :category_menu),\n            partial: \"categories/menu\",\n            locals: { transaction: transaction }\n          ),\n          *flash_notification_stream_items\n        ]\n      end\n    end\n  end\n\n  private\n    def entry_params\n      params.require(:entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ])\n    end\n\n    def needs_rule_notification?(transaction)\n      return false if Current.user.rule_prompts_disabled\n\n      if Current.user.rule_prompt_dismissed_at.present?\n        time_since_last_rule_prompt = Time.current - Current.user.rule_prompt_dismissed_at\n        return false if time_since_last_rule_prompt < 1.day\n      end\n\n      transaction.saved_change_to_category_id? &&\n      transaction.eligible_for_category_rule?\n    end\nend\n"
  },
  {
    "path": "app/controllers/transactions/bulk_deletions_controller.rb",
    "content": "class Transactions::BulkDeletionsController < ApplicationController\n  def create\n    destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])\n    destroyed.map(&:account).uniq.each(&:sync_later)\n    redirect_back_or_to transactions_url, notice: \"#{destroyed.count} transaction#{destroyed.count == 1 ? \"\" : \"s\"} deleted\"\n  end\n\n  private\n    def bulk_delete_params\n      params.require(:bulk_delete).permit(entry_ids: [])\n    end\nend\n"
  },
  {
    "path": "app/controllers/transactions/bulk_updates_controller.rb",
    "content": "class Transactions::BulkUpdatesController < ApplicationController\n  def new\n  end\n\n  def create\n    updated = Current.family\n                     .entries\n                     .where(id: bulk_update_params[:entry_ids])\n                     .bulk_update!(bulk_update_params)\n\n    redirect_back_or_to transactions_path, notice: \"#{updated} transactions updated\"\n  end\n\n  private\n    def bulk_update_params\n      params.require(:bulk_update)\n            .permit(:date, :notes, :category_id, :merchant_id, entry_ids: [], tag_ids: [])\n    end\nend\n"
  },
  {
    "path": "app/controllers/transactions_controller.rb",
    "content": "class TransactionsController < ApplicationController\n  include EntryableResource\n\n  before_action :store_params!, only: :index\n\n  def new\n    super\n    @income_categories = Current.family.categories.incomes.alphabetically\n    @expense_categories = Current.family.categories.expenses.alphabetically\n  end\n\n  def index\n    @q = search_params\n    @search = Transaction::Search.new(Current.family, filters: @q)\n\n    base_scope = @search.transactions_scope\n                       .reverse_chronological\n                       .includes(\n                         { entry: :account },\n                         :category, :merchant, :tags,\n                         :transfer_as_inflow, :transfer_as_outflow\n                       )\n\n    @pagy, @transactions = pagy(base_scope, limit: per_page)\n  end\n\n  def clear_filter\n    updated_params = {\n      \"q\" => search_params,\n      \"page\" => params[:page],\n      \"per_page\" => params[:per_page]\n    }\n\n    q_params = updated_params[\"q\"] || {}\n\n    param_key = params[:param_key]\n    param_value = params[:param_value]\n\n    if q_params[param_key].is_a?(Array)\n      q_params[param_key].delete(param_value)\n      q_params.delete(param_key) if q_params[param_key].empty?\n    else\n      q_params.delete(param_key)\n    end\n\n    updated_params[\"q\"] = q_params.presence\n\n    # Add flag to indicate filters were explicitly cleared\n    updated_params[\"filter_cleared\"] = \"1\" if updated_params[\"q\"].blank?\n\n    Current.session.update!(prev_transaction_page_params: updated_params)\n\n    redirect_to transactions_path(updated_params)\n  end\n\n  def create\n    account = Current.family.accounts.find(params.dig(:entry, :account_id))\n    @entry = account.entries.new(entry_params)\n\n    if @entry.save\n      @entry.sync_account_later\n      @entry.lock_saved_attributes!\n      @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?\n\n      flash[:notice] = \"Transaction created\"\n\n      respond_to do |format|\n        format.html { redirect_back_or_to account_path(@entry.account) }\n        format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) }\n      end\n    else\n      render :new, status: :unprocessable_entity\n    end\n  end\n\n  def update\n    if @entry.update(entry_params)\n      transaction = @entry.transaction\n\n      if needs_rule_notification?(transaction)\n        flash[:cta] = {\n          type: \"category_rule\",\n          category_id: transaction.category_id,\n          category_name: transaction.category.name\n        }\n      end\n\n      @entry.sync_account_later\n      @entry.lock_saved_attributes!\n      @entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?\n\n      respond_to do |format|\n        format.html { redirect_back_or_to account_path(@entry.account), notice: \"Transaction updated\" }\n        format.turbo_stream do\n          render turbo_stream: [\n            turbo_stream.replace(\n              dom_id(@entry, :header),\n              partial: \"transactions/header\",\n              locals: { entry: @entry }\n            ),\n            turbo_stream.replace(@entry),\n            *flash_notification_stream_items\n          ]\n        end\n      end\n    else\n      render :show, status: :unprocessable_entity\n    end\n  end\n\n  private\n    def per_page\n      params[:per_page].to_i.positive? ? params[:per_page].to_i : 20\n    end\n\n    def needs_rule_notification?(transaction)\n      return false if Current.user.rule_prompts_disabled\n\n      if Current.user.rule_prompt_dismissed_at.present?\n        time_since_last_rule_prompt = Time.current - Current.user.rule_prompt_dismissed_at\n        return false if time_since_last_rule_prompt < 1.day\n      end\n\n      transaction.saved_change_to_category_id? && transaction.category_id.present? &&\n      transaction.eligible_for_category_rule?\n    end\n\n    def entry_params\n      entry_params = params.require(:entry).permit(\n        :name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,\n        entryable_attributes: [ :id, :category_id, :merchant_id, :kind, { tag_ids: [] } ]\n      )\n\n      nature = entry_params.delete(:nature)\n\n      if nature.present? && entry_params[:amount].present?\n        signed_amount = nature == \"inflow\" ? -entry_params[:amount].to_d : entry_params[:amount].to_d\n        entry_params = entry_params.merge(amount: signed_amount)\n      end\n\n      entry_params\n    end\n\n    def search_params\n      cleaned_params = params.fetch(:q, {})\n              .permit(\n                :start_date, :end_date, :search, :amount,\n                :amount_operator, :active_accounts_only,\n                accounts: [], account_ids: [],\n                categories: [], merchants: [], types: [], tags: []\n              )\n              .to_h\n              .compact_blank\n\n      cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?\n\n\n      cleaned_params\n    end\n\n    def store_params!\n      if should_restore_params?\n        params_to_restore = {}\n\n        params_to_restore[:q] = stored_params[\"q\"].presence || {}\n        params_to_restore[:page] = stored_params[\"page\"].presence || 1\n        params_to_restore[:per_page] = stored_params[\"per_page\"].presence || 50\n\n        redirect_to transactions_path(params_to_restore)\n      else\n        Current.session.update!(\n          prev_transaction_page_params: {\n            q: search_params,\n            page: params[:page],\n            per_page: params[:per_page]\n          }\n        )\n      end\n    end\n\n    def should_restore_params?\n      request.query_parameters.blank? && (stored_params[\"q\"].present? || stored_params[\"page\"].present? || stored_params[\"per_page\"].present?)\n    end\n\n    def stored_params\n      Current.session.prev_transaction_page_params\n    end\nend\n"
  },
  {
    "path": "app/controllers/transfer_matches_controller.rb",
    "content": "class TransferMatchesController < ApplicationController\n  before_action :set_entry\n\n  def new\n    @accounts = Current.family.accounts.visible.alphabetically.where.not(id: @entry.account_id)\n    @transfer_match_candidates = @entry.transaction.transfer_match_candidates\n  end\n\n  def create\n    @transfer = build_transfer\n    Transfer.transaction do\n      @transfer.save!\n      @transfer.outflow_transaction.update!(kind: Transfer.kind_for_account(@transfer.outflow_transaction.entry.account))\n      @transfer.inflow_transaction.update!(kind: \"funds_movement\")\n    end\n\n    @transfer.sync_account_later\n\n    redirect_back_or_to transactions_path, notice: \"Transfer created\"\n  end\n\n  private\n    def set_entry\n      @entry = Current.family.entries.find(params[:transaction_id])\n    end\n\n    def transfer_match_params\n      params.require(:transfer_match).permit(:method, :matched_entry_id, :target_account_id)\n    end\n\n    def build_transfer\n      if transfer_match_params[:method] == \"new\"\n        target_account = Current.family.accounts.find(transfer_match_params[:target_account_id])\n\n        missing_transaction = Transaction.new(\n          entry: target_account.entries.build(\n            amount: @entry.amount * -1,\n            currency: @entry.currency,\n            date: @entry.date,\n            name: \"Transfer to #{@entry.amount.negative? ? @entry.account.name : target_account.name}\",\n          )\n        )\n\n        transfer = Transfer.find_or_initialize_by(\n          inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.transaction,\n          outflow_transaction: @entry.amount.positive? ? @entry.transaction : missing_transaction\n        )\n        transfer.status = \"confirmed\"\n        transfer\n      else\n        target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id])\n\n        transfer = Transfer.find_or_initialize_by(\n          inflow_transaction: @entry.amount.negative? ? @entry.transaction : target_transaction.transaction,\n          outflow_transaction: @entry.amount.negative? ? target_transaction.transaction : @entry.transaction\n        )\n        transfer.status = \"confirmed\"\n        transfer\n      end\n    end\nend\n"
  },
  {
    "path": "app/controllers/transfers_controller.rb",
    "content": "class TransfersController < ApplicationController\n  include StreamExtensions\n\n  before_action :set_transfer, only: %i[show destroy update]\n\n  def new\n    @transfer = Transfer.new\n  end\n\n  def show\n    @categories = Current.family.categories.expenses\n  end\n\n  def create\n    @transfer = Transfer::Creator.new(\n      family: Current.family,\n      source_account_id: transfer_params[:from_account_id],\n      destination_account_id: transfer_params[:to_account_id],\n      date: transfer_params[:date],\n      amount: transfer_params[:amount].to_d\n    ).create\n\n    if @transfer.persisted?\n      success_message = \"Transfer created\"\n      respond_to do |format|\n        format.html { redirect_back_or_to transactions_path, notice: success_message }\n        format.turbo_stream { stream_redirect_back_or_to transactions_path, notice: success_message }\n      end\n    else\n      render :new, status: :unprocessable_entity\n    end\n  end\n\n  def update\n    Transfer.transaction do\n      update_transfer_status\n      update_transfer_details unless transfer_update_params[:status] == \"rejected\"\n    end\n\n    respond_to do |format|\n      format.html { redirect_back_or_to transactions_url, notice: t(\".success\") }\n      format.turbo_stream\n    end\n  end\n\n  def destroy\n    @transfer.destroy!\n    redirect_back_or_to transactions_url, notice: t(\".success\")\n  end\n\n  private\n    def set_transfer\n      # Finds the transfer and ensures the family owns it\n      @transfer = Transfer\n                    .where(id: params[:id])\n                    .where(inflow_transaction_id: Current.family.transactions.select(:id))\n                    .first\n    end\n\n    def transfer_params\n      params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)\n    end\n\n    def transfer_update_params\n      params.require(:transfer).permit(:notes, :status, :category_id)\n    end\n\n    def update_transfer_status\n      if transfer_update_params[:status] == \"rejected\"\n        @transfer.reject!\n      elsif transfer_update_params[:status] == \"confirmed\"\n        @transfer.confirm!\n      end\n    end\n\n    def update_transfer_details\n      @transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id])\n      @transfer.update!(notes: transfer_update_params[:notes])\n    end\nend\n"
  },
  {
    "path": "app/controllers/users_controller.rb",
    "content": "class UsersController < ApplicationController\n  before_action :set_user\n  before_action :ensure_admin, only: :reset\n\n  def update\n    @user = Current.user\n\n    if email_changed?\n      if @user.initiate_email_change(user_params[:email])\n        if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation\n          handle_redirect(t(\".success\"))\n        else\n          redirect_to settings_profile_path, notice: t(\".email_change_initiated\")\n        end\n      else\n        error_message = @user.errors.any? ? @user.errors.full_messages.to_sentence : t(\".email_change_failed\")\n        redirect_to settings_profile_path, alert: error_message\n      end\n    else\n      was_ai_enabled = @user.ai_enabled\n      @user.update!(user_params.except(:redirect_to, :delete_profile_image))\n      @user.profile_image.purge if should_purge_profile_image?\n\n      # Add a special notice if AI was just enabled\n      notice = if !was_ai_enabled && @user.ai_enabled\n        \"AI Assistant has been enabled successfully.\"\n      else\n        t(\".success\")\n      end\n\n      respond_to do |format|\n        format.html { handle_redirect(notice) }\n        format.json { head :ok }\n      end\n    end\n  end\n\n  def reset\n    FamilyResetJob.perform_later(Current.family)\n    redirect_to settings_profile_path, notice: t(\".success\")\n  end\n\n  def destroy\n    if @user.deactivate\n      Current.session.destroy\n      redirect_to root_path, notice: t(\".success\")\n    else\n      redirect_to settings_profile_path, alert: @user.errors.full_messages.to_sentence\n    end\n  end\n\n  def rule_prompt_settings\n    @user.update!(rule_prompt_settings_params)\n    redirect_back_or_to settings_profile_path\n  end\n\n  private\n    def handle_redirect(notice)\n      case user_params[:redirect_to]\n      when \"onboarding_preferences\"\n        redirect_to preferences_onboarding_path\n      when \"home\"\n        redirect_to root_path\n      when \"preferences\"\n        redirect_to settings_preferences_path, notice: notice\n      when \"goals\"\n        redirect_to goals_onboarding_path\n      when \"trial\"\n        redirect_to trial_onboarding_path\n      else\n        redirect_to settings_profile_path, notice: notice\n      end\n    end\n\n    def should_purge_profile_image?\n      user_params[:delete_profile_image] == \"1\" &&\n        user_params[:profile_image].blank?\n    end\n\n    def email_changed?\n      user_params[:email].present? && user_params[:email] != @user.email\n    end\n\n    def rule_prompt_settings_params\n      params.require(:user).permit(:rule_prompt_dismissed_at, :rule_prompts_disabled)\n    end\n\n    def user_params\n      params.require(:user).permit(\n        :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,\n        :show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at,\n        family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ],\n        goals: []\n      )\n    end\n\n    def set_user\n      @user = Current.user\n    end\n\n    def ensure_admin\n      redirect_to settings_profile_path, alert: I18n.t(\"users.reset.unauthorized\") unless Current.user.admin?\n    end\nend\n"
  },
  {
    "path": "app/controllers/valuations_controller.rb",
    "content": "class ValuationsController < ApplicationController\n  include EntryableResource, StreamExtensions\n\n  def confirm_create\n    @account = Current.family.accounts.find(params.dig(:entry, :account_id))\n    @entry = @account.entries.build(entry_params.merge(currency: @account.currency))\n\n    @reconciliation_dry_run = @entry.account.create_reconciliation(\n      balance: entry_params[:amount],\n      date: entry_params[:date],\n      dry_run: true\n    )\n\n    render :confirm_create\n  end\n\n  def confirm_update\n    @entry = Current.family.entries.find(params[:id])\n    @account = @entry.account\n    @entry.assign_attributes(entry_params.merge(currency: @account.currency))\n\n    @reconciliation_dry_run = @entry.account.update_reconciliation(\n      @entry,\n      balance: entry_params[:amount],\n      date: entry_params[:date],\n      dry_run: true\n    )\n\n    render :confirm_update\n  end\n\n  def create\n    account = Current.family.accounts.find(params.dig(:entry, :account_id))\n\n    result = account.create_reconciliation(\n      balance: entry_params[:amount],\n      date: entry_params[:date],\n    )\n\n    if result.success?\n      respond_to do |format|\n        format.html { redirect_back_or_to account_path(account), notice: \"Account updated\" }\n        format.turbo_stream { stream_redirect_back_or_to(account_path(account), notice: \"Account updated\") }\n      end\n    else\n      @error_message = result.error_message\n      render :new, status: :unprocessable_entity\n    end\n  end\n\n  def update\n    # Notes updating is independent of reconciliation, just a simple CRUD operation\n    @entry.update!(notes: entry_params[:notes]) if entry_params[:notes].present?\n\n    if entry_params[:date].present? && entry_params[:amount].present?\n      result = @entry.account.update_reconciliation(\n        @entry,\n        balance: entry_params[:amount],\n        date: entry_params[:date],\n      )\n    end\n\n    if result.nil? || result.success?\n      @entry.reload\n\n      respond_to do |format|\n        format.html { redirect_back_or_to account_path(@entry.account), notice: \"Entry updated\" }\n        format.turbo_stream do\n          render turbo_stream: [\n            turbo_stream.replace(\n              dom_id(@entry, :header),\n              partial: \"valuations/header\",\n              locals: { entry: @entry }\n            ),\n            turbo_stream.replace(@entry)\n          ]\n        end\n      end\n    else\n      @error_message = result.error_message\n      render :show, status: :unprocessable_entity\n    end\n  end\n\n  private\n    def entry_params\n      params.require(:entry).permit(:date, :amount, :notes)\n    end\nend\n"
  },
  {
    "path": "app/controllers/vehicles_controller.rb",
    "content": "class VehiclesController < ApplicationController\n  include AccountableResource\n\n  permitted_accountable_attributes(\n    :id, :make, :model, :year, :mileage_value, :mileage_unit\n  )\nend\n"
  },
  {
    "path": "app/controllers/webhooks_controller.rb",
    "content": "class WebhooksController < ApplicationController\n  skip_before_action :verify_authenticity_token\n  skip_authentication\n\n  def plaid\n    webhook_body = request.body.read\n    plaid_verification_header = request.headers[\"Plaid-Verification\"]\n\n    client = Provider::Registry.plaid_provider_for_region(:us)\n\n    client.validate_webhook!(plaid_verification_header, webhook_body)\n\n    PlaidItem::WebhookProcessor.new(webhook_body).process\n\n    render json: { received: true }, status: :ok\n  rescue => error\n    Sentry.capture_exception(error)\n    render json: { error: \"Invalid webhook: #{error.message}\" }, status: :bad_request\n  end\n\n  def plaid_eu\n    webhook_body = request.body.read\n    plaid_verification_header = request.headers[\"Plaid-Verification\"]\n\n    client = Provider::Registry.plaid_provider_for_region(:eu)\n\n    client.validate_webhook!(plaid_verification_header, webhook_body)\n\n    PlaidItem::WebhookProcessor.new(webhook_body).process\n\n    render json: { received: true }, status: :ok\n  rescue => error\n    Sentry.capture_exception(error)\n    render json: { error: \"Invalid webhook: #{error.message}\" }, status: :bad_request\n  end\n\n  def stripe\n    stripe_provider = Provider::Registry.get_provider(:stripe)\n\n    begin\n      webhook_body = request.body.read\n      sig_header = request.env[\"HTTP_STRIPE_SIGNATURE\"]\n\n      stripe_provider.process_webhook_later(webhook_body, sig_header)\n\n      head :ok\n    rescue JSON::ParserError => error\n      Sentry.capture_exception(error)\n      Rails.logger.error \"JSON parser error: #{error.message}\"\n      head :bad_request\n    rescue Stripe::SignatureVerificationError => error\n      Sentry.capture_exception(error)\n      Rails.logger.error \"Stripe signature verification error: #{error.message}\"\n      head :bad_request\n    end\n  end\nend\n"
  },
  {
    "path": "app/data_migrations/balance_component_migrator.rb",
    "content": "class BalanceComponentMigrator\n  def self.run\n    ActiveRecord::Base.transaction do\n      # Step 1: Update flows factor\n      ActiveRecord::Base.connection.execute <<~SQL\n        UPDATE balances SET\n          flows_factor = CASE WHEN a.classification = 'asset' THEN 1 ELSE -1 END\n        FROM accounts a\n        WHERE a.id = balances.account_id\n      SQL\n\n      # Step 2: Set start values using LOCF (Last Observation Carried Forward)\n      ActiveRecord::Base.connection.execute <<~SQL\n        UPDATE balances b1\n        SET\n          start_cash_balance = COALESCE(prev.cash_balance, 0),\n          start_non_cash_balance = COALESCE(prev.balance - prev.cash_balance, 0)\n        FROM balances b1_inner\n        LEFT JOIN LATERAL (\n          SELECT\n            b2.cash_balance,\n            b2.balance\n          FROM balances b2\n          WHERE b2.account_id = b1_inner.account_id\n          AND b2.currency = b1_inner.currency\n          AND b2.date < b1_inner.date\n          ORDER BY b2.date DESC\n          LIMIT 1\n        ) prev ON true\n        WHERE b1.id = b1_inner.id\n      SQL\n\n      # Step 3: Calculate net inflows\n      # A slight workaround to the fact that we can't easily derive inflows/outflows from our current data model, and\n      # the tradeoff not worth it since each new sync will fix it. So instead, we sum up *net* flows, and throw the signed\n      # amount in the \"inflows\" column, and zero-out the \"outflows\" column so our math works correctly with incomplete data.\n      ActiveRecord::Base.connection.execute <<~SQL\n        UPDATE balances SET\n          cash_inflows = (cash_balance - start_cash_balance) * flows_factor,\n          cash_outflows = 0,\n          non_cash_inflows = ((balance - cash_balance) - start_non_cash_balance) * flows_factor,\n          non_cash_outflows = 0,\n          net_market_flows = 0\n      SQL\n\n      # Verify data integrity\n      # All end_balance values should match the original balance\n      invalid_count = ActiveRecord::Base.connection.select_value(<<~SQL)\n        SELECT COUNT(*)\n        FROM balances b\n        WHERE ABS(b.balance - b.end_balance) > 0.0001\n      SQL\n\n      if invalid_count > 0\n        raise \"Data migration failed validation: #{invalid_count} balances have incorrect end_balance values\"\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "app/helpers/accounts_helper.rb",
    "content": "module AccountsHelper\n  def summary_card(title:, &block)\n    content = capture(&block)\n    render \"accounts/summary_card\", title: title, content: content\n  end\nend\n"
  },
  {
    "path": "app/helpers/application_helper.rb",
    "content": "module ApplicationHelper\n  include Pagy::Frontend\n\n  def styled_form_with(**options, &block)\n    options[:builder] = StyledFormBuilder\n    form_with(**options, &block)\n  end\n\n  def icon(key, size: \"md\", color: \"default\", custom: false, as_button: false, **opts)\n    extra_classes = opts.delete(:class)\n    sizes = { xs: \"w-3 h-3\", sm: \"w-4 h-4\", md: \"w-5 h-5\", lg: \"w-6 h-6\", xl: \"w-7 h-7\", \"2xl\": \"w-8 h-8\" }\n    colors = { default: \"fg-gray\", white: \"fg-inverse\", success: \"text-success\", warning: \"text-warning\", destructive: \"text-destructive\", current: \"text-current\" }\n\n    icon_classes = class_names(\n      \"shrink-0\",\n      sizes[size.to_sym],\n      colors[color.to_sym],\n      extra_classes\n    )\n\n    if custom\n      inline_svg_tag(\"#{key}.svg\", class: icon_classes, **opts)\n    elsif as_button\n      render DS::Button.new(variant: \"icon\", class: extra_classes, icon: key, size: size, type: \"button\", **opts)\n    else\n      lucide_icon(key, class: icon_classes, **opts)\n    end\n  end\n\n  # Convert alpha (0-1) to 8-digit hex (00-FF)\n  def hex_with_alpha(hex, alpha)\n    alpha_hex = (alpha * 255).round.to_s(16).rjust(2, \"0\")\n    \"#{hex}#{alpha_hex}\"\n  end\n\n  def title(page_title)\n    content_for(:title) { page_title }\n  end\n\n  def header_title(page_title)\n    content_for(:header_title) { page_title }\n  end\n\n  def header_description(page_description)\n    content_for(:header_description) { page_description }\n  end\n\n  def page_active?(path)\n    current_page?(path) || (request.path.start_with?(path) && path != \"/\")\n  end\n\n  # Wrapper around I18n.l to support custom date formats\n  def format_date(object, format = :default, options = {})\n    date = object.to_date\n\n    format_code = options[:format_code] || Current.family&.date_format\n\n    if format_code.present?\n      date.strftime(format_code)\n    else\n      I18n.l(date, format: format, **options)\n    end\n  end\n\n  def format_money(number_or_money, options = {})\n    return nil unless number_or_money\n\n    Money.new(number_or_money).format(options)\n  end\n\n  def totals_by_currency(collection:, money_method:, separator: \" | \", negate: false)\n    collection.group_by(&:currency)\n              .transform_values { |item| calculate_total(item, money_method, negate) }\n              .map { |_currency, money| format_money(money) }\n              .join(separator)\n  end\n\n  def show_super_admin_bar?\n    if params[:admin].present?\n      cookies.permanent[:admin] = params[:admin]\n    end\n\n    cookies[:admin] == \"true\"\n  end\n\n  # Renders Markdown text using Redcarpet\n  def markdown(text)\n    return \"\" if text.blank?\n\n    renderer = Redcarpet::Render::HTML.new(\n      hard_wrap: true,\n      link_attributes: { target: \"_blank\", rel: \"noopener noreferrer\" }\n    )\n\n    markdown = Redcarpet::Markdown.new(\n      renderer,\n      autolink: true,\n      tables: true,\n      fenced_code_blocks: true,\n      strikethrough: true,\n      superscript: true,\n      underline: true,\n      highlight: true,\n      quote: true,\n      footnotes: true\n    )\n\n    markdown.render(text).html_safe\n  end\n\n  private\n    def calculate_total(item, money_method, negate)\n      # Filter out transfer-type transactions from entries\n      # Only Entry objects have entryable transactions, Account objects don't\n      items = item.reject do |i|\n        i.is_a?(Entry) &&\n        i.entryable.is_a?(Transaction) &&\n        i.entryable.transfer?\n      end\n      total = items.sum(&money_method)\n      negate ? -total : total\n    end\nend\n"
  },
  {
    "path": "app/helpers/categories_helper.rb",
    "content": "module CategoriesHelper\n  def transfer_category\n    Category.new \\\n      name: \"Transfer\",\n      color: Category::TRANSFER_COLOR,\n      lucide_icon: \"arrow-right-left\"\n  end\n\n  def payment_category\n    Category.new \\\n      name: \"Payment\",\n      color: Category::PAYMENT_COLOR,\n      lucide_icon: \"arrow-right\"\n  end\n\n  def trade_category\n    Category.new \\\n      name: \"Trade\",\n      color: Category::TRADE_COLOR\n  end\n\n  def family_categories\n    [ Category.uncategorized ].concat(Current.family.categories.alphabetically)\n  end\nend\n"
  },
  {
    "path": "app/helpers/chats_helper.rb",
    "content": "module ChatsHelper\n  def chat_frame\n    :sidebar_chat\n  end\n\n  def chat_view_path(chat)\n    return new_chat_path if params[:chat_view] == \"new\"\n    return chats_path if chat.nil? || params[:chat_view] == \"all\"\n\n    chat.persisted? ? chat_path(chat) : new_chat_path\n  end\nend\n"
  },
  {
    "path": "app/helpers/custom_confirm.rb",
    "content": "# The shape of data expected by `confirm_dialog_controller.js` to override the\n# default browser confirm API via Turbo.\nclass CustomConfirm\n  class << self\n    def for_resource_deletion(resource_name, high_severity: false)\n      new(\n        destructive: true,\n        high_severity: high_severity,\n        title: \"Delete #{resource_name.titleize}?\",\n        body: \"Are you sure you want to delete #{resource_name.downcase}? This is not reversible.\",\n        btn_text: \"Delete #{resource_name.titleize}\"\n      )\n    end\n  end\n\n  def initialize(title: default_title, body: default_body, btn_text: default_btn_text, destructive: false, high_severity: false)\n    @title = title\n    @body = body\n    @btn_text = btn_text\n    @btn_variant = derive_btn_variant(destructive, high_severity)\n  end\n\n  def to_data_attribute\n    {\n      title: title,\n      body: body,\n      confirmText: btn_text,\n      variant: btn_variant\n    }\n  end\n\n  private\n    attr_reader :title, :body, :btn_text, :btn_variant\n\n    def derive_btn_variant(destructive, high_severity)\n      return \"primary\" unless destructive\n      high_severity ? \"destructive\" : \"outline-destructive\"\n    end\n\n    def default_title\n      \"Are you sure?\"\n    end\n\n    def default_body\n      \"This is not reversible.\"\n    end\n\n    def default_btn_text\n      \"Confirm\"\n    end\nend\n"
  },
  {
    "path": "app/helpers/entries_helper.rb",
    "content": "module EntriesHelper\n  def entries_by_date(entries, totals: false)\n    transfer_groups = entries.group_by do |entry|\n      # Only check for transfer if it's a transaction\n      next nil unless entry.entryable_type == \"Transaction\"\n      entry.entryable.transfer&.id\n    end\n\n    # For a more intuitive UX, we do not want to show the same transfer twice in the list\n    deduped_entries = transfer_groups.flat_map do |transfer_id, grouped_entries|\n      if transfer_id.nil? || grouped_entries.size == 1\n        grouped_entries\n      else\n        grouped_entries.reject do |e|\n          e.entryable_type == \"Transaction\" &&\n          e.entryable.transfer_as_inflow.present?\n        end\n      end\n    end\n\n    deduped_entries.group_by(&:date).sort.reverse_each.map do |date, grouped_entries|\n      content = capture do\n        yield grouped_entries\n      end\n\n      next if content.blank?\n\n      render partial: \"entries/entry_group\", locals: { date:, entries: grouped_entries, content:, totals: }\n    end.compact.join.html_safe\n  end\n\n  def entry_name_detailed(entry)\n    [\n      entry.date,\n      format_money(entry.amount_money),\n      entry.account.name,\n      entry.name\n    ].join(\" • \")\n  end\nend\n"
  },
  {
    "path": "app/helpers/imports_helper.rb",
    "content": "module ImportsHelper\n  def mapping_label(mapping_class)\n    {\n      \"Import::AccountTypeMapping\" => \"Account Type\",\n      \"Import::AccountMapping\" => \"Account\",\n      \"Import::CategoryMapping\" => \"Category\",\n      \"Import::TagMapping\" => \"Tag\"\n    }.fetch(mapping_class.name)\n  end\n\n  def import_col_label(key)\n    {\n      date: \"Date\",\n      amount: \"Amount\",\n      name: \"Name\",\n      currency: \"Currency\",\n      category: \"Category\",\n      tags: \"Tags\",\n      account: \"Account\",\n      notes: \"Notes\",\n      qty: \"Quantity\",\n      ticker: \"Ticker\",\n      exchange: \"Exchange\",\n      price: \"Price\",\n      entity_type: \"Type\"\n    }[key]\n  end\n\n  def dry_run_resource(key)\n    map = {\n      transactions: DryRunResource.new(label: \"Transactions\", icon: \"credit-card\", text_class: \"text-cyan-500\", bg_class: \"bg-cyan-500/5\"),\n      accounts: DryRunResource.new(label: \"Accounts\", icon: \"layers\", text_class: \"text-orange-500\", bg_class: \"bg-orange-500/5\"),\n      categories: DryRunResource.new(label: \"Categories\", icon: \"shapes\", text_class: \"text-blue-500\", bg_class: \"bg-blue-500/5\"),\n      tags: DryRunResource.new(label: \"Tags\", icon: \"tags\", text_class: \"text-violet-500\", bg_class: \"bg-violet-500/5\")\n    }\n\n    map[key]\n  end\n\n  def permitted_import_configuration_path(import)\n    if permitted_import_types.include?(import.type.underscore)\n      \"import/configurations/#{import.type.underscore}\"\n    else\n      raise \"Unknown import type: #{import.type}\"\n    end\n  end\n\n  def cell_class(row, field)\n    base = \"bg-container text-sm focus:ring-gray-900 theme-dark:focus:ring-gray-100 focus:border-solid w-full max-w-full disabled:text-subdued\"\n\n    row.valid? # populate errors\n\n    border = row.errors.key?(field) ? \"border-destructive\" : \"border-transparent\"\n\n    [ base, border ].join(\" \")\n  end\n\n  def cell_is_valid?(row, field)\n    row.valid? # populate errors\n    !row.errors.key?(field)\n  end\n\n  private\n    def permitted_import_types\n      %w[transaction_import trade_import account_import mint_import]\n    end\n\n    DryRunResource = Struct.new(:label, :icon, :text_class, :bg_class, keyword_init: true)\nend\n"
  },
  {
    "path": "app/helpers/languages_helper.rb",
    "content": "module LanguagesHelper\n  LANGUAGE_MAPPING = {\n    en: \"English\",\n    ru: \"Russian\",\n    ar: \"Arabic\",\n    bg: \"Bulgarian\",\n    'ca-CAT': \"Catalan (Catalonia)\",\n    ca: \"Catalan\",\n    'da-DK': \"Danish (Denmark)\",\n    'de-AT': \"German (Austria)\",\n    'de-CH': \"German (Switzerland)\",\n    de: \"German\",\n    ee: \"Ewe\",\n    'en-AU': \"English (Australia)\",\n    'en-BORK': \"English (Bork)\",\n    'en-CA': \"English (Canada)\",\n    'en-GB': \"English (United Kingdom)\",\n    'en-IND': \"English (India)\",\n    'en-KE': \"English (Kenya)\",\n    'en-MS': \"English (Malaysia)\",\n    'en-NEP': \"English (Nepal)\",\n    'en-NG': \"English (Nigeria)\",\n    'en-NZ': \"English (New Zealand)\",\n    'en-PAK': \"English (Pakistan)\",\n    'en-SG': \"English (Singapore)\",\n    'en-TH': \"English (Thailand)\",\n    'en-UG': \"English (Uganda)\",\n    'en-US': \"English (United States)\",\n    'en-ZA': \"English (South Africa)\",\n    'en-au-ocker': \"English (Australian Ocker)\",\n    'es-AR': \"Spanish (Argentina)\",\n    'es-MX': \"Spanish (Mexico)\",\n    es: \"Spanish\",\n    fa: \"Persian\",\n    'fi-FI': \"Finnish (Finland)\",\n    fr: \"French\",\n    'fr-CA': \"French (Canada)\",\n    'fr-CH': \"French (Switzerland)\",\n    he: \"Hebrew\",\n    hy: \"Armenian\",\n    id: \"Indonesian\",\n    it: \"Italian\",\n    ja: \"Japanese\",\n    ko: \"Korean\",\n    lt: \"Lithuanian\",\n    lv: \"Latvian\",\n    'mi-NZ': \"Maori (New Zealand)\",\n    'nb-NO': \"Norwegian Bokmål (Norway)\",\n    nl: \"Dutch\",\n    'no-NO': \"Norwegian (Norway)\",\n    pl: \"Polish\",\n    'pt-BR': \"Portuguese (Brazil)\",\n    pt: \"Portuguese\",\n    sk: \"Slovak\",\n    sv: \"Swedish\",\n    th: \"Thai\",\n    tr: \"Turkish\",\n    uk: \"Ukrainian\",\n    vi: \"Vietnamese\",\n    'zh-CN': \"Chinese (Simplified)\",\n    'zh-TW': \"Chinese (Traditional)\",\n    af: \"Afrikaans\",\n    az: \"Azerbaijani\",\n    be: \"Belarusian\",\n    bn: \"Bengali\",\n    bs: \"Bosnian\",\n    cs: \"Czech\",\n    cy: \"Welsh\",\n    da: \"Danish\",\n    'de-DE': \"German (Germany)\",\n    dz: \"Dzongkha\",\n    'el-CY': \"Greek (Cyprus)\",\n    el: \"Greek\",\n    'en-CY': \"English (Cyprus)\",\n    'en-IE': \"English (Ireland)\",\n    'en-IN': \"English (India)\",\n    'en-TT': \"English (Trinidad and Tobago)\",\n    eo: \"Esperanto\",\n    'es-419': \"Spanish (Latin America)\",\n    'es-CL': \"Spanish (Chile)\",\n    'es-CO': \"Spanish (Colombia)\",\n    'es-CR': \"Spanish (Costa Rica)\",\n    'es-EC': \"Spanish (Ecuador)\",\n    'es-ES': \"Spanish (Spain)\",\n    'es-NI': \"Spanish (Nicaragua)\",\n    'es-PA': \"Spanish (Panama)\",\n    'es-PE': \"Spanish (Peru)\",\n    'es-US': \"Spanish (United States)\",\n    'es-VE': \"Spanish (Venezuela)\",\n    et: \"Estonian\",\n    eu: \"Basque\",\n    fi: \"Finnish\",\n    'fr-FR': \"French (France)\",\n    fy: \"Western Frisian\",\n    gd: \"Scottish Gaelic\",\n    gl: \"Galician\",\n    'hi-IN': \"Hindi (India)\",\n    hi: \"Hindi\",\n    hr: \"Croatian\",\n    hu: \"Hungarian\",\n    is: \"Icelandic\",\n    'it-CH': \"Italian (Switzerland)\",\n    ka: \"Georgian\",\n    kk: \"Kazakh\",\n    km: \"Khmer\",\n    kn: \"Kannada\",\n    lb: \"Luxembourgish\",\n    lo: \"Lao\",\n    mg: \"Malagasy\",\n    mk: \"Macedonian\",\n    ml: \"Malayalam\",\n    mn: \"Mongolian\",\n    'mr-IN': \"Marathi (India)\",\n    ms: \"Malay\",\n    nb: \"Norwegian Bokmål\",\n    ne: \"Nepali\",\n    nn: \"Norwegian Nynorsk\",\n    oc: \"Occitan\",\n    or: \"Odia\",\n    pa: \"Punjabi\",\n    rm: \"Romansh\",\n    ro: \"Romanian\",\n    sc: \"Sardinian\",\n    sl: \"Slovenian\",\n    sq: \"Albanian\",\n    sr: \"Serbian\",\n    st: \"Southern Sotho\",\n    'sv-FI': \"Swedish (Finland)\",\n    'sv-SE': \"Swedish (Sweden)\",\n    sw: \"Swahili\",\n    ta: \"Tamil\",\n    te: \"Telugu\",\n    tl: \"Tagalog\",\n    tt: \"Tatar\",\n    ug: \"Uyghur\",\n    ur: \"Urdu\",\n    uz: \"Uzbek\",\n    wo: \"Wolof\"\n  }.freeze\n\n  EXCLUDED_LOCALES = [\n    # Test locales\n    \"en-BORK\",\n    \"en-au-ocker\",\n    # Duplicate locales\n    \"fr-FR\",\n    \"de-DE\",\n    \"hi-IN\",\n    \"sv-SE\",\n    \"ca-CAT\",\n    \"en-US\",\n    \"fi-FI\",\n    \"en-IND\"\n  ].freeze\n\n  COUNTRY_MAPPING = {\n    AF: \"🇦🇫 Afghanistan\",\n    AL: \"🇦🇱 Albania\",\n    DZ: \"🇩🇿 Algeria\",\n    AD: \"🇦🇩 Andorra\",\n    AO: \"🇦🇴 Angola\",\n    AG: \"🇦🇬 Antigua and Barbuda\",\n    AR: \"🇦🇷 Argentina\",\n    AM: \"🇦🇲 Armenia\",\n    AU: \"🇦🇺 Australia\",\n    AT: \"🇦🇹 Austria\",\n    AZ: \"🇦🇿 Azerbaijan\",\n    BS: \"🇧🇸 Bahamas\",\n    BH: \"🇧🇭 Bahrain\",\n    BD: \"🇧🇩 Bangladesh\",\n    BB: \"🇧🇧 Barbados\",\n    BY: \"🇧🇾 Belarus\",\n    BE: \"🇧🇪 Belgium\",\n    BZ: \"🇧🇿 Belize\",\n    BJ: \"🇧🇯 Benin\",\n    BT: \"🇧🇹 Bhutan\",\n    BO: \"🇧🇴 Bolivia\",\n    BA: \"🇧🇦 Bosnia and Herzegovina\",\n    BW: \"🇧🇼 Botswana\",\n    BR: \"🇧🇷 Brazil\",\n    BN: \"🇧🇳 Brunei\",\n    BG: \"🇧🇬 Bulgaria\",\n    BF: \"🇧🇫 Burkina Faso\",\n    BI: \"🇧🇮 Burundi\",\n    KH: \"🇰🇭 Cambodia\",\n    CM: \"🇨🇲 Cameroon\",\n    CA: \"🇨🇦 Canada\",\n    CV: \"🇨🇻 Cape Verde\",\n    CF: \"🇨🇫 Central African Republic\",\n    TD: \"🇹🇩 Chad\",\n    CL: \"🇨🇱 Chile\",\n    CN: \"🇨🇳 China\",\n    CO: \"🇨🇴 Colombia\",\n    KM: \"🇰🇲 Comoros\",\n    CG: \"🇨🇬 Congo\",\n    CD: \"🇨🇩 Congo, Democratic Republic of the\",\n    CR: \"🇨🇷 Costa Rica\",\n    CI: \"🇨🇮 Côte d'Ivoire\",\n    HR: \"🇭🇷 Croatia\",\n    CU: \"🇨🇺 Cuba\",\n    CY: \"🇨🇾 Cyprus\",\n    CZ: \"🇨🇿 Czech Republic\",\n    DK: \"🇩🇰 Denmark\",\n    DJ: \"🇩🇯 Djibouti\",\n    DM: \"🇩🇲 Dominica\",\n    DO: \"🇩🇴 Dominican Republic\",\n    EC: \"🇪🇨 Ecuador\",\n    EG: \"🇪🇬 Egypt\",\n    SV: \"🇸🇻 El Salvador\",\n    GQ: \"🇬🇶 Equatorial Guinea\",\n    ER: \"🇪🇷 Eritrea\",\n    EE: \"🇪🇪 Estonia\",\n    ET: \"🇪🇹 Ethiopia\",\n    FJ: \"🇫🇯 Fiji\",\n    FI: \"🇫🇮 Finland\",\n    FR: \"🇫🇷 France\",\n    GA: \"🇬🇦 Gabon\",\n    GM: \"🇬🇲 Gambia\",\n    GE: \"🇬🇪 Georgia\",\n    DE: \"🇩🇪 Germany\",\n    GH: \"🇬🇭 Ghana\",\n    GR: \"🇬🇷 Greece\",\n    GD: \"🇬🇩 Grenada\",\n    GT: \"🇬🇹 Guatemala\",\n    GN: \"🇬🇳 Guinea\",\n    GW: \"🇬🇼 Guinea-Bissau\",\n    GY: \"🇬🇾 Guyana\",\n    HT: \"🇭🇹 Haiti\",\n    HN: \"🇭🇳 Honduras\",\n    HU: \"🇭🇺 Hungary\",\n    IS: \"🇮🇸 Iceland\",\n    IN: \"🇮🇳 India\",\n    ID: \"🇮🇩 Indonesia\",\n    IR: \"🇮🇷 Iran\",\n    IQ: \"🇮🇶 Iraq\",\n    IE: \"🇮🇪 Ireland\",\n    IL: \"🇮🇱 Israel\",\n    IT: \"🇮🇹 Italy\",\n    JM: \"🇯🇲 Jamaica\",\n    JP: \"🇯🇵 Japan\",\n    JO: \"🇯🇴 Jordan\",\n    KZ: \"🇰🇿 Kazakhstan\",\n    KE: \"🇰🇪 Kenya\",\n    KI: \"🇰🇮 Kiribati\",\n    KP: \"🇰🇵 North Korea\",\n    KR: \"🇰🇷 South Korea\",\n    KW: \"🇰🇼 Kuwait\",\n    KG: \"🇰🇬 Kyrgyzstan\",\n    LA: \"🇱🇦 Laos\",\n    LV: \"🇱🇻 Latvia\",\n    LB: \"🇱🇧 Lebanon\",\n    LS: \"🇱🇸 Lesotho\",\n    LR: \"🇱🇷 Liberia\",\n    LY: \"🇱🇾 Libya\",\n    LI: \"🇱🇮 Liechtenstein\",\n    LT: \"🇱🇹 Lithuania\",\n    LU: \"🇱🇺 Luxembourg\",\n    MK: \"🇲🇰 North Macedonia\",\n    MG: \"🇲🇬 Madagascar\",\n    MW: \"🇲🇼 Malawi\",\n    MY: \"🇲🇾 Malaysia\",\n    MV: \"🇲🇻 Maldives\",\n    ML: \"🇲🇱 Mali\",\n    MT: \"🇲🇹 Malta\",\n    MH: \"🇲🇭 Marshall Islands\",\n    MR: \"🇲🇷 Mauritania\",\n    MU: \"🇲🇺 Mauritius\",\n    MX: \"🇲🇽 Mexico\",\n    FM: \"🇫🇲 Micronesia\",\n    MD: \"🇲🇩 Moldova\",\n    MC: \"🇲🇨 Monaco\",\n    MN: \"🇲🇳 Mongolia\",\n    ME: \"🇲🇪 Montenegro\",\n    MA: \"🇲🇦 Morocco\",\n    MZ: \"🇲🇿 Mozambique\",\n    MM: \"🇲🇲 Myanmar\",\n    NA: \"🇳🇦 Namibia\",\n    NR: \"🇳🇷 Nauru\",\n    NP: \"🇳🇵 Nepal\",\n    NL: \"🇳🇱 Netherlands\",\n    NZ: \"🇳🇿 New Zealand\",\n    NI: \"🇳🇮 Nicaragua\",\n    NE: \"🇳🇪 Niger\",\n    NG: \"🇳🇬 Nigeria\",\n    NO: \"🇳🇴 Norway\",\n    OM: \"🇴🇲 Oman\",\n    PK: \"🇵🇰 Pakistan\",\n    PW: \"🇵🇼 Palau\",\n    PA: \"🇵🇦 Panama\",\n    PG: \"🇵🇬 Papua New Guinea\",\n    PY: \"🇵🇾 Paraguay\",\n    PE: \"🇵🇪 Peru\",\n    PH: \"🇵🇭 Philippines\",\n    PL: \"🇵🇱 Poland\",\n    PT: \"🇵🇹 Portugal\",\n    QA: \"🇶🇦 Qatar\",\n    RO: \"🇷🇴 Romania\",\n    RU: \"🇷🇺 Russia\",\n    RW: \"🇷🇼 Rwanda\",\n    KN: \"🇰🇳 Saint Kitts and Nevis\",\n    LC: \"🇱🇨 Saint Lucia\",\n    VC: \"🇻🇨 Saint Vincent and the Grenadines\",\n    WS: \"🇼🇸 Samoa\",\n    SM: \"🇸🇲 San Marino\",\n    ST: \"🇸🇹 Sao Tome and Principe\",\n    SA: \"🇸🇦 Saudi Arabia\",\n    SN: \"🇸🇳 Senegal\",\n    RS: \"🇷🇸 Serbia\",\n    SC: \"🇸🇨 Seychelles\",\n    SL: \"🇸🇱 Sierra Leone\",\n    SG: \"🇸🇬 Singapore\",\n    SK: \"🇸🇰 Slovakia\",\n    SI: \"🇸🇮 Slovenia\",\n    SB: \"🇸🇧 Solomon Islands\",\n    SO: \"🇸🇴 Somalia\",\n    ZA: \"🇿🇦 South Africa\",\n    SS: \"🇸🇸 South Sudan\",\n    ES: \"🇪🇸 Spain\",\n    LK: \"🇱🇰 Sri Lanka\",\n    SD: \"🇸🇩 Sudan\",\n    SR: \"🇸🇷 Suriname\",\n    SE: \"🇸🇪 Sweden\",\n    CH: \"🇨🇭 Switzerland\",\n    SY: \"🇸🇾 Syria\",\n    TW: \"🇹🇼 Taiwan\",\n    TJ: \"🇹🇯 Tajikistan\",\n    TZ: \"🇹🇿 Tanzania\",\n    TH: \"🇹🇭 Thailand\",\n    TL: \"🇹🇱 Timor-Leste\",\n    TG: \"🇹🇬 Togo\",\n    TO: \"🇹🇴 Tonga\",\n    TT: \"🇹🇹 Trinidad and Tobago\",\n    TN: \"🇹🇳 Tunisia\",\n    TR: \"🇹🇷 Turkey\",\n    TM: \"🇹🇲 Turkmenistan\",\n    TV: \"🇹🇻 Tuvalu\",\n    UG: \"🇺🇬 Uganda\",\n    UA: \"🇺🇦 Ukraine\",\n    AE: \"🇦🇪 United Arab Emirates\",\n    GB: \"🇬🇧 United Kingdom\",\n    US: \"🇺🇸 United States\",\n    UY: \"🇺🇾 Uruguay\",\n    UZ: \"🇺🇿 Uzbekistan\",\n    VU: \"🇻🇺 Vanuatu\",\n    VA: \"🇻🇦 Vatican City\",\n    VE: \"🇻🇪 Venezuela\",\n    VN: \"🇻🇳 Vietnam\",\n    YE: \"🇾🇪 Yemen\",\n    ZM: \"🇿🇲 Zambia\",\n    ZW: \"🇿🇼 Zimbabwe\"\n  }.freeze\n\n  def country_options\n    COUNTRY_MAPPING.keys.map { |key| [ COUNTRY_MAPPING[key], key ] }\n  end\n\n  def language_options\n    I18n.available_locales\n      .reject { |locale| EXCLUDED_LOCALES.include?(locale.to_s) }\n      .map do |locale|\n        label = LANGUAGE_MAPPING[locale.to_sym] || locale.to_s.humanize\n        [ \"#{label} (#{locale})\", locale ]\n      end\n      .sort_by { |label, locale| label }\n  end\n\n  def timezone_options\n    ActiveSupport::TimeZone.all\n      .sort_by { |tz| [ tz.utc_offset, tz.name ] }\n      .map do |tz|\n        name = tz.name.split(\" - \").first.gsub(\" (US & Canada)\", \"\")\n        [ \"(#{tz.formatted_offset}) #{name}\", tz.tzinfo.identifier ]\n      end\n  end\nend\n"
  },
  {
    "path": "app/helpers/mfa_helper.rb",
    "content": "module MfaHelper\n  def generate_mfa_qr_code(provisioning_uri)\n    qr_code = RQRCode::QRCode.new(provisioning_uri).as_svg(\n      color: \"141414\",\n      module_size: 4,\n      standalone: true,\n      use_path: true,\n      svg_attributes: {\n        width: \"240\",\n        height: \"240\",\n        viewBox: \"0 0 65 65\"\n      }\n    )\n\n    # Whitelist specific SVG attributes and elements that we know are safe\n    sanitize qr_code,\n      tags: %w[svg g path rect],\n      attributes: %w[viewBox height width fill stroke stroke-width d x y class]\n  end\nend\n"
  },
  {
    "path": "app/helpers/settings_helper.rb",
    "content": "module SettingsHelper\n  SETTINGS_ORDER = [\n    { name: \"Account\", path: :settings_profile_path },\n    { name: \"Preferences\", path: :settings_preferences_path },\n    { name: \"Security\", path: :settings_security_path },\n    { name: \"Self hosting\", path: :settings_hosting_path, condition: :self_hosted? },\n    { name: \"API Key\", path: :settings_api_key_path },\n    { name: \"Billing\", path: :settings_billing_path, condition: :not_self_hosted? },\n    { name: \"Accounts\", path: :accounts_path },\n    { name: \"Imports\", path: :imports_path },\n    { name: \"Tags\", path: :tags_path },\n    { name: \"Categories\", path: :categories_path },\n    { name: \"Rules\", path: :rules_path },\n    { name: \"Merchants\", path: :family_merchants_path },\n    { name: \"What's new\", path: :changelog_path },\n    { name: \"Feedback\", path: :feedback_path }\n  ]\n\n  def adjacent_setting(current_path, offset)\n    visible_settings = SETTINGS_ORDER.select { |setting| setting[:condition].nil? || send(setting[:condition]) }\n    current_index = visible_settings.index { |setting| send(setting[:path]) == current_path }\n    return nil unless current_index\n\n    adjacent_index = current_index + offset\n    return nil if adjacent_index < 0 || adjacent_index >= visible_settings.size\n\n    adjacent = visible_settings[adjacent_index]\n\n    render partial: \"settings/settings_nav_link_large\", locals: {\n      path: send(adjacent[:path]),\n      direction: offset > 0 ? \"next\" : \"previous\",\n      title: adjacent[:name]\n    }\n  end\n\n  def settings_section(title:, subtitle: nil, &block)\n    content = capture(&block)\n    render partial: \"settings/section\", locals: { title: title, subtitle: subtitle, content: content }\n  end\n\n  def settings_nav_footer\n    previous_setting = adjacent_setting(request.path, -1)\n    next_setting = adjacent_setting(request.path, 1)\n\n    content_tag :div, class: \"hidden md:flex flex-row justify-between gap-4\" do\n      concat(previous_setting)\n      concat(next_setting)\n    end\n  end\n\n  def settings_nav_footer_mobile\n    previous_setting = adjacent_setting(request.path, -1)\n    next_setting = adjacent_setting(request.path, 1)\n\n    content_tag :div, class: \"md:hidden flex flex-col gap-4\" do\n      concat(previous_setting)\n      concat(next_setting)\n    end\n  end\n\n  private\n    def not_self_hosted?\n      !self_hosted?\n    end\nend\n"
  },
  {
    "path": "app/helpers/styled_form_builder.rb",
    "content": "class StyledFormBuilder < ActionView::Helpers::FormBuilder\n  class_attribute :text_field_helpers, default: field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]\n\n  text_field_helpers.each do |selector|\n    class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1\n      def #{selector}(method, options = {})\n        form_options = options.slice(:label, :label_tooltip, :inline, :container_class, :required)\n        html_options = options.except(:label, :label_tooltip, :inline, :container_class)\n\n        build_field(method, form_options, html_options) do |merged_options|\n          super(method, merged_options)\n        end\n      end\n    RUBY_EVAL\n  end\n\n  def radio_button(method, tag_value, options = {})\n    merged_options = { class: \"form-field__radio\" }.merge(options)\n    super(method, tag_value, merged_options)\n  end\n\n  def select(method, choices, options = {}, html_options = {})\n    field_options = normalize_options(options, html_options)\n\n    build_field(method, field_options, html_options) do |merged_html_options|\n      super(method, choices, options, merged_html_options)\n    end\n  end\n\n  def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})\n    field_options = normalize_options(options, html_options)\n\n    build_field(method, field_options, html_options) do |merged_html_options|\n      super(method, collection, value_method, text_method, options, merged_html_options)\n    end\n  end\n\n  def money_field(amount_method, options = {})\n    @template.render partial: \"shared/money_field\", locals: {\n      form: self,\n      amount_method:,\n      currency_method: options[:currency_method] || :currency,\n      **options\n    }\n  end\n\n  def toggle(method, options = {}, checked_value = \"1\", unchecked_value = \"0\")\n    field_id = field_id(method)\n    field_name = field_name(method)\n    checked = object ? object.send(method) : options[:checked]\n\n    @template.render(\n      DS::Toggle.new(\n        id: field_id,\n        name: field_name,\n        checked: checked,\n        disabled: options[:disabled],\n        checked_value: checked_value,\n        unchecked_value: unchecked_value,\n        **options\n      )\n    )\n  end\n\n  def submit(value = nil, options = {})\n    value, options = nil, value if value.is_a?(Hash)\n    value ||= submit_default_value\n\n    @template.render(\n      DS::Button.new(\n        text: value,\n        data: (options[:data] || {}).merge({ turbo_submits_with: \"Submitting...\" }),\n        full_width: true\n      )\n    )\n  end\n\n  private\n    def build_field(method, options = {}, html_options = {}, &block)\n      if options[:inline] || options[:label] == false\n        return yield({ class: \"form-field__input\" }.merge(html_options))\n      end\n\n      label_element = build_label(method, options)\n      field_element = yield({ class: \"form-field__input\" }.merge(html_options))\n\n      container_classes = [ \"form-field\", options[:container_class] ].compact\n\n      @template.tag.div class: container_classes do\n        if options[:label_tooltip]\n          @template.tag.div(class: \"form-field__header\") do\n            label_element +\n            @template.tag.div(class: \"form-field__actions\") do\n              build_tooltip(options[:label_tooltip])\n            end\n          end +\n          @template.tag.div(class: \"form-field__body\") do\n            field_element\n          end\n        else\n          @template.tag.div(class: \"form-field__body\") do\n            label_element + field_element\n          end\n        end\n      end\n    end\n\n    def normalize_options(options, html_options)\n      options.merge(required: options[:required] || html_options[:required])\n    end\n\n    def build_label(method, options)\n      return \"\".html_safe unless options[:label]\n\n      label_text = options[:label]\n\n      if options[:required]\n        label_text = @template.safe_join([\n          label_text == true ? method.to_s.humanize : label_text,\n          @template.tag.span(\"*\", class: \"text-red-500 ml-0.5\")\n        ])\n      end\n\n      return label(method, class: \"form-field__label\") if label_text == true\n      label(method, label_text, class: \"form-field__label\")\n    end\n\n    def build_tooltip(tooltip_text)\n      return nil unless tooltip_text\n\n      @template.tag.div(data: { controller: \"tooltip\" }) do\n        @template.safe_join([\n          @template.icon(\"help-circle\", size: \"sm\", color: \"default\", class: \"cursor-help\"),\n          @template.tag.div(tooltip_text, role: \"tooltip\", data: { tooltip_target: \"tooltip\" }, class: \"tooltip bg-gray-700 text-sm p-2 rounded w-64 text-white\")\n        ])\n      end\n    end\nend\n"
  },
  {
    "path": "app/helpers/transactions_helper.rb",
    "content": "module TransactionsHelper\n  def transaction_search_filters\n    [\n      { key: \"account_filter\", label: \"Account\", icon: \"layers\" },\n      { key: \"date_filter\", label: \"Date\", icon: \"calendar\" },\n      { key: \"type_filter\", label: \"Type\", icon: \"tag\" },\n      { key: \"amount_filter\", label: \"Amount\", icon: \"hash\" },\n      { key: \"category_filter\", label: \"Category\", icon: \"shapes\" },\n      { key: \"tag_filter\", label: \"Tag\", icon: \"tags\" },\n      { key: \"merchant_filter\", label: \"Merchant\", icon: \"store\" }\n    ]\n  end\n\n  def get_transaction_search_filter_partial_path(filter)\n    \"transactions/searches/filters/#{filter[:key]}\"\n  end\n\n  def get_default_transaction_search_filter\n    transaction_search_filters[0]\n  end\nend\n"
  },
  {
    "path": "app/javascript/application.js",
    "content": "// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails\nimport \"@hotwired/turbo-rails\";\nimport \"controllers\";\n\nTurbo.StreamActions.redirect = function () {\n  Turbo.visit(this.target);\n};\n"
  },
  {
    "path": "app/javascript/controllers/app_layout_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"dialog\"\nexport default class extends Controller {\n  static targets = [\"leftSidebar\", \"rightSidebar\", \"mobileSidebar\"];\n  static classes = [\n    \"expandedSidebar\",\n    \"collapsedSidebar\",\n    \"expandedTransition\",\n    \"collapsedTransition\",\n  ];\n\n  openMobileSidebar() {\n    this.mobileSidebarTarget.classList.remove(\"hidden\");\n  }\n\n  closeMobileSidebar() {\n    this.mobileSidebarTarget.classList.add(\"hidden\");\n  }\n\n  toggleLeftSidebar() {\n    const isOpen = this.leftSidebarTarget.classList.contains(\"w-full\");\n    this.#updateUserPreference(\"show_sidebar\", !isOpen);\n    this.#toggleSidebarWidth(this.leftSidebarTarget, isOpen);\n  }\n\n  toggleRightSidebar() {\n    const isOpen = this.rightSidebarTarget.classList.contains(\"w-full\");\n    this.#updateUserPreference(\"show_ai_sidebar\", !isOpen);\n    this.#toggleSidebarWidth(this.rightSidebarTarget, isOpen);\n  }\n\n  #toggleSidebarWidth(el, isCurrentlyOpen) {\n    if (isCurrentlyOpen) {\n      el.classList.remove(...this.expandedSidebarClasses);\n      el.classList.add(...this.collapsedSidebarClasses);\n    } else {\n      el.classList.add(...this.expandedSidebarClasses);\n      el.classList.remove(...this.collapsedSidebarClasses);\n    }\n  }\n\n  #updateUserPreference(field, value) {\n    fetch(`/users/${this.userIdValue}`, {\n      method: \"PATCH\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n        \"X-CSRF-Token\": document.querySelector('[name=\"csrf-token\"]').content,\n        Accept: \"application/json\",\n      },\n      body: new URLSearchParams({\n        [`user[${field}]`]: value,\n      }).toString(),\n    });\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/application.js",
    "content": "import { Application } from \"@hotwired/stimulus\";\n\nconst application = Application.start();\n\n// Configure Stimulus development experience\napplication.debug = false;\nwindow.Stimulus = application;\n\nTurbo.config.forms.confirm = (data) => {\n  const confirmDialogController =\n    application.getControllerForElementAndIdentifier(\n      document.getElementById(\"confirm-dialog\"),\n      \"confirm-dialog\",\n    );\n\n  return confirmDialogController.handleConfirm(data);\n};\n\nexport { application };\n"
  },
  {
    "path": "app/javascript/controllers/auto_submit_form_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n  // By default, auto-submit is \"opt-in\" to avoid unexpected behavior.  Each `auto` target\n  // will trigger a form submission when the configured event is triggered.\n  static targets = [\"auto\"];\n  static values = {\n    triggerEvent: { type: String, default: \"input\" },\n  };\n\n  connect() {\n    this.autoTargets.forEach((element) => {\n      const event = this.#getTriggerEvent(element);\n      element.addEventListener(event, this.handleInput);\n    });\n  }\n\n  disconnect() {\n    this.autoTargets.forEach((element) => {\n      const event = this.#getTriggerEvent(element);\n      element.removeEventListener(event, this.handleInput);\n    });\n  }\n\n  handleInput = (event) => {\n    const target = event.target;\n\n    clearTimeout(this.timeout);\n    this.timeout = setTimeout(() => {\n      this.element.requestSubmit();\n    }, this.#debounceTimeout(target));\n  };\n\n  #getTriggerEvent(element) {\n    // Check if element has explicit trigger event set\n    if (element.dataset.autosubmitTriggerEvent) {\n      return element.dataset.autosubmitTriggerEvent;\n    }\n\n    // Check if form has explicit trigger event set\n    if (this.triggerEventValue !== \"input\") {\n      return this.triggerEventValue;\n    }\n\n    // Otherwise, choose trigger event based on element type\n    const type = element.type || element.tagName;\n\n    switch (type.toLowerCase()) {\n      case \"text\":\n      case \"email\":\n      case \"password\":\n      case \"search\":\n      case \"tel\":\n      case \"url\":\n      case \"textarea\":\n        return \"blur\";\n      case \"number\":\n      case \"date\":\n      case \"datetime-local\":\n      case \"month\":\n      case \"time\":\n      case \"week\":\n      case \"color\":\n        return \"change\";\n      case \"checkbox\":\n      case \"radio\":\n      case \"select\":\n      case \"select-one\":\n      case \"select-multiple\":\n        return \"change\";\n      case \"range\":\n        return \"input\";\n      default:\n        return \"blur\";\n    }\n  }\n\n  #debounceTimeout(element) {\n    if (element.dataset.autosubmitDebounceTimeout) {\n      return Number.parseInt(element.dataset.autosubmitDebounceTimeout);\n    }\n\n    const type = element.type || element.tagName;\n\n    switch (type.toLowerCase()) {\n      case \"input\":\n      case \"textarea\":\n        return 500;\n      case \"select-one\":\n      case \"select-multiple\":\n        return 0;\n      default:\n        return 500;\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/budget_form_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"budget-form\"\nexport default class extends Controller {\n  toggleAutoFill(e) {\n    const expectedIncome = e.params.income;\n    const budgetedSpending = e.params.spending;\n\n    if (e.target.checked) {\n      this.#fillField(expectedIncome.key, expectedIncome.value);\n      this.#fillField(budgetedSpending.key, budgetedSpending.value);\n    } else {\n      this.#clearField(expectedIncome.key);\n      this.#clearField(budgetedSpending.key);\n    }\n  }\n\n  #fillField(id, value) {\n    this.element.querySelector(`input[id=\"${id}\"]`).value = value;\n  }\n\n  #clearField(id) {\n    this.element.querySelector(`input[id=\"${id}\"]`).value = \"\";\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/bulk_select_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"bulk-select\"\nexport default class extends Controller {\n  static targets = [\n    \"row\",\n    \"group\",\n    \"selectionBar\",\n    \"selectionBarText\",\n    \"bulkEditDrawerHeader\",\n  ];\n  static values = {\n    singularLabel: String,\n    pluralLabel: String,\n    selectedIds: { type: Array, default: [] },\n  };\n\n  connect() {\n    document.addEventListener(\"turbo:load\", this._updateView);\n\n    this._updateView();\n  }\n\n  disconnect() {\n    document.removeEventListener(\"turbo:load\", this._updateView);\n  }\n\n  bulkEditDrawerHeaderTargetConnected(element) {\n    const headingTextEl = element.querySelector(\"h2\");\n    headingTextEl.innerText = `Edit ${\n      this.selectedIdsValue.length\n    } ${this._pluralizedResourceName()}`;\n  }\n\n  submitBulkRequest(e) {\n    const form = e.target.closest(\"form\");\n    const scope = e.params.scope;\n    this._addHiddenFormInputsForSelectedIds(\n      form,\n      `${scope}[entry_ids][]`,\n      this.selectedIdsValue,\n    );\n    form.requestSubmit();\n  }\n\n  togglePageSelection(e) {\n    if (e.target.checked) {\n      this._selectAll();\n    } else {\n      this.deselectAll();\n    }\n  }\n\n  toggleGroupSelection(e) {\n    const group = this.groupTargets.find((group) => group.contains(e.target));\n\n    this._rowsForGroup(group).forEach((row) => {\n      if (e.target.checked) {\n        this._addToSelection(row.dataset.id);\n      } else {\n        this._removeFromSelection(row.dataset.id);\n      }\n    });\n  }\n\n  toggleRowSelection(e) {\n    if (e.target.checked) {\n      this._addToSelection(e.target.dataset.id);\n    } else {\n      this._removeFromSelection(e.target.dataset.id);\n    }\n  }\n\n  deselectAll() {\n    this.selectedIdsValue = [];\n    this.element.querySelectorAll('input[type=\"checkbox\"]').forEach((el) => {\n      el.checked = false;\n    });\n  }\n\n  selectedIdsValueChanged() {\n    this._updateView();\n  }\n\n  _addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) {\n    this._resetFormInputs(form, paramName);\n\n    transactionIds.forEach((id) => {\n      const input = document.createElement(\"input\");\n      input.type = \"hidden\";\n      input.name = paramName;\n      input.value = id;\n      form.appendChild(input);\n    });\n  }\n\n  _resetFormInputs(form, paramName) {\n    const existingInputs = form.querySelectorAll(`input[name='${paramName}']`);\n    existingInputs.forEach((input) => input.remove());\n  }\n\n  _rowsForGroup(group) {\n    return this.rowTargets.filter(\n      (row) => group.contains(row) && !row.disabled,\n    );\n  }\n\n  _addToSelection(idToAdd) {\n    this.selectedIdsValue = Array.from(\n      new Set([...this.selectedIdsValue, idToAdd]),\n    );\n  }\n\n  _removeFromSelection(idToRemove) {\n    this.selectedIdsValue = this.selectedIdsValue.filter(\n      (id) => id !== idToRemove,\n    );\n  }\n\n  _selectAll() {\n    this.selectedIdsValue = this.rowTargets\n      .filter((t) => !t.disabled)\n      .map((t) => t.dataset.id);\n  }\n\n  _updateView = () => {\n    this._updateSelectionBar();\n    this._updateGroups();\n    this._updateRows();\n  };\n\n  _updateSelectionBar() {\n    const count = this.selectedIdsValue.length;\n    this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} selected`;\n    this.selectionBarTarget.classList.toggle(\"hidden\", count === 0);\n    this.selectionBarTarget.querySelector(\"input[type='checkbox']\").checked =\n      count > 0;\n  }\n\n  _pluralizedResourceName() {\n    if (this.selectedIdsValue.length === 1) {\n      return this.singularLabelValue;\n    }\n\n    return this.pluralLabelValue;\n  }\n\n  _updateGroups() {\n    this.groupTargets.forEach((group) => {\n      const rows = this.rowTargets.filter(\n        (row) => group.contains(row) && !row.disabled,\n      );\n      const groupSelected =\n        rows.length > 0 &&\n        rows.every((row) => this.selectedIdsValue.includes(row.dataset.id));\n      group.querySelector(\"input[type='checkbox']\").checked = groupSelected;\n    });\n  }\n\n  _updateRows() {\n    this.rowTargets.forEach((row) => {\n      row.checked = this.selectedIdsValue.includes(row.dataset.id);\n    });\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/category_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\nimport Pickr from \"@simonwep/pickr\";\n\nexport default class extends Controller {\n  static targets = [\n    \"pickerBtn\",\n    \"colorInput\",\n    \"colorsSection\",\n    \"paletteSection\",\n    \"pickerSection\",\n    \"colorPreview\",\n    \"avatar\",\n    \"details\",\n    \"icon\",\n    \"validationMessage\",\n    \"selection\",\n    \"colorPickerRadioBtn\",\n    \"popup\",\n  ];\n\n  static values = {\n    presetColors: Array,\n  };\n\n  initialize() {\n    this.pickerBtnTarget.addEventListener(\"click\", () => {\n      this.showPaletteSection();\n    });\n\n    this.colorInputTarget.addEventListener(\"input\", (e) => {\n      this.picker.setColor(e.target.value);\n    });\n\n    this.detailsTarget.addEventListener(\"toggle\", (e) => {\n      if (!this.colorInputTarget.checkValidity()) {\n        e.preventDefault();\n        this.colorInputTarget.reportValidity();\n        e.target.open = true;\n      }\n      this.updatePopupPosition()\n    });\n\n    this.selectedIcon = null;\n\n    if (!this.presetColorsValue.includes(this.colorInputTarget.value)) {\n      this.colorPickerRadioBtnTarget.checked = true;\n    }\n\n    document.addEventListener(\"mousedown\", this.handleOutsideClick);\n  }\n\n  initPicker() {\n    const pickerContainer = document.createElement(\"div\");\n    pickerContainer.classList.add(\"pickerContainer\");\n    this.pickerSectionTarget.append(pickerContainer);\n\n    this.picker = Pickr.create({\n      el: this.pickerBtnTarget,\n      theme: \"monolith\",\n      container: \".pickerContainer\",\n      useAsButton: true,\n      showAlways: true,\n      default: this.colorInputTarget.value,\n      components: {\n        hue: true,\n      },\n    });\n\n    this.picker.on(\"change\", (color) => {\n      const hexColor = color.toHEXA().toString();\n      const rgbacolor = color.toRGBA();\n\n      this.updateAvatarColors(hexColor);\n      this.updateSelectedIconColor(hexColor);\n\n      const backgroundColor = this.backgroundColor(rgbacolor, 10);\n      const contrastRatio = this.contrast(rgbacolor, backgroundColor);\n\n      this.colorInputTarget.value = hexColor;\n      this.colorInputTarget.dataset.colorPickerColorValue = hexColor;\n      this.colorPreviewTarget.style.backgroundColor = hexColor;\n\n      this.handleContrastValidation(contrastRatio);\n    });\n  }\n\n  updateAvatarColors(color) {\n    this.avatarTarget.style.backgroundColor = `${this.#backgroundColor(color)}`;\n    this.avatarTarget.style.color = color;\n  }\n\n  handleIconColorChange(e) {\n    const selectedIcon = e.target;\n    this.selectedIcon = selectedIcon;\n\n    const currentColor = this.colorInputTarget.value;\n\n    this.iconTargets.forEach((icon) => {\n      const iconWrapper = icon.nextElementSibling;\n      iconWrapper.style.removeProperty(\"background-color\");\n      iconWrapper.style.removeProperty(\"color\");\n    });\n\n    this.updateSelectedIconColor(currentColor);\n  }\n\n  handleIconChange(e) {\n    const iconSVG = e.currentTarget\n      .closest(\"label\")\n      .querySelector(\"svg\")\n      .cloneNode(true);\n    this.avatarTarget.innerHTML = \"\";\n    iconSVG.style.padding = \"0px\";\n    iconSVG.classList.add(\"w-8\", \"h-8\");\n    this.avatarTarget.appendChild(iconSVG);\n  }\n\n  updateSelectedIconColor(color) {\n    if (this.selectedIcon) {\n      const iconWrapper = this.selectedIcon.nextElementSibling;\n      iconWrapper.style.backgroundColor = `${this.#backgroundColor(color)}`;\n      iconWrapper.style.color = color;\n    }\n  }\n\n  handleColorChange(e) {\n    const color = e.currentTarget.value;\n    this.colorInputTarget.value = color;\n    this.colorPreviewTarget.style.backgroundColor = color;\n    this.updateAvatarColors(color);\n    this.updateSelectedIconColor(color);\n  }\n\n  handleContrastValidation(contrastRatio) {\n    if (contrastRatio < 4.5) {\n      this.colorInputTarget.setCustomValidity(\n        \"Poor contrast, choose darker color or auto-adjust.\",\n      );\n\n      this.validationMessageTarget.classList.remove(\"hidden\");\n    } else {\n      this.colorInputTarget.setCustomValidity(\"\");\n      this.validationMessageTarget.classList.add(\"hidden\");\n    }\n  }\n\n  autoAdjust(e) {\n    const currentRGBA = this.picker.getColor();\n    const adjustedRGBA = this.darkenColor(currentRGBA).toString();\n    this.picker.setColor(adjustedRGBA);\n  }\n\n  handleParentChange(e) {\n    const parent = e.currentTarget.value;\n    const display =\n      typeof parent === \"string\" && parent !== \"\" ? \"none\" : \"flex\";\n    this.selectionTarget.style.display = display;\n  }\n\n  backgroundColor([r, g, b, a], percentage) {\n    const mixedR = Math.round(\n      r * (percentage / 100) + 255 * (1 - percentage / 100),\n    );\n    const mixedG = Math.round(\n      g * (percentage / 100) + 255 * (1 - percentage / 100),\n    );\n    const mixedB = Math.round(\n      b * (percentage / 100) + 255 * (1 - percentage / 100),\n    );\n    return [mixedR, mixedG, mixedB];\n  }\n\n  luminance([r, g, b]) {\n    const toLinear = (c) => {\n      const scaled = c / 255;\n      return scaled <= 0.04045\n        ? scaled / 12.92\n        : ((scaled + 0.055) / 1.055) ** 2.4;\n    };\n    return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);\n  }\n\n  contrast(foregroundColor, backgroundColor) {\n    const fgLum = this.luminance(foregroundColor);\n    const bgLum = this.luminance(backgroundColor);\n    const [l1, l2] = [Math.max(fgLum, bgLum), Math.min(fgLum, bgLum)];\n    return (l1 + 0.05) / (l2 + 0.05);\n  }\n\n  darkenColor(color) {\n    let darkened = color.toRGBA();\n    const backgroundColor = this.backgroundColor(darkened, 10);\n    let contrastRatio = this.contrast(darkened, backgroundColor);\n\n    while (\n      contrastRatio < 4.5 &&\n      (darkened[0] > 0 || darkened[1] > 0 || darkened[2] > 0)\n    ) {\n      darkened = [\n        Math.max(0, darkened[0] - 10),\n        Math.max(0, darkened[1] - 10),\n        Math.max(0, darkened[2] - 10),\n        darkened[3],\n      ];\n      contrastRatio = this.contrast(darkened, backgroundColor);\n    }\n\n    return `rgba(${darkened.join(\", \")})`;\n  }\n\n  showPaletteSection() {\n    this.initPicker();\n    this.colorsSectionTarget.classList.add(\"hidden\");\n    this.paletteSectionTarget.classList.remove(\"hidden\");\n    this.pickerSectionTarget.classList.remove(\"hidden\");\n    this.updatePopupPosition();\n    this.picker.show();\n  }\n\n  showColorsSection() {\n    this.colorsSectionTarget.classList.remove(\"hidden\");\n    this.paletteSectionTarget.classList.add(\"hidden\");\n    this.pickerSectionTarget.classList.add(\"hidden\");\n    this.updatePopupPosition()\n    if (this.picker) {\n      this.picker.destroyAndRemove();\n    }\n  }\n\n  toggleSections() {\n    if (this.colorsSectionTarget.classList.contains(\"hidden\")) {\n      this.showColorsSection();\n    } else {\n      this.showPaletteSection();\n    }\n  }\n\n  handleOutsideClick = (event) => {\n    if (this.detailsTarget.open && !this.detailsTarget.contains(event.target)) {\n      this.detailsTarget.open = false;\n    }\n  };\n\n  updatePopupPosition() {\n    const popup = this.popupTarget;\n    popup.style.top = \"\";\n    popup.style.bottom = \"\";\n\n    const rect = popup.getBoundingClientRect();\n    const overflow = rect.bottom > window.innerHeight;\n\n    if (overflow) {\n      popup.style.bottom = \"0px\";\n    } else {\n      popup.style.bottom = \"\";\n    }\n  }\n\n  #backgroundColor(color) {\n    return `color-mix(in oklab, ${color} 10%, transparent)`;\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/chat_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n  static targets = [\"messages\", \"form\", \"input\"];\n\n  connect() {\n    this.#configureAutoScroll();\n  }\n\n  disconnect() {\n    if (this.messagesObserver) {\n      this.messagesObserver.disconnect();\n    }\n  }\n\n  autoResize() {\n    const input = this.inputTarget;\n    const lineHeight = 20; // text-sm line-height (14px * 1.429 ≈ 20px)\n    const maxLines = 3; // 3 lines = 60px total\n\n    input.style.height = \"auto\";\n    input.style.height = `${Math.min(input.scrollHeight, lineHeight * maxLines)}px`;\n    input.style.overflowY =\n      input.scrollHeight > lineHeight * maxLines ? \"auto\" : \"hidden\";\n  }\n\n  submitSampleQuestion(e) {\n    this.inputTarget.value = e.target.dataset.chatQuestionParam;\n\n    setTimeout(() => {\n      this.formTarget.requestSubmit();\n    }, 200);\n  }\n\n  // Newlines require shift+enter, otherwise submit the form (same functionality as ChatGPT and others)\n  handleInputKeyDown(e) {\n    if (e.key === \"Enter\" && !e.shiftKey) {\n      e.preventDefault();\n      this.formTarget.requestSubmit();\n    }\n  }\n\n  #configureAutoScroll() {\n    this.messagesObserver = new MutationObserver((_mutations) => {\n      if (this.hasMessagesTarget) {\n        this.#scrollToBottom();\n      }\n    });\n\n    // Listen to entire sidebar for changes, always try to scroll to the bottom\n    this.messagesObserver.observe(this.element, {\n      childList: true,\n      subtree: true,\n    });\n  }\n\n  #scrollToBottom = () => {\n    this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight;\n  };\n}\n"
  },
  {
    "path": "app/javascript/controllers/clipboard_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n  static targets = [\"source\", \"iconDefault\", \"iconSuccess\"];\n\n  copy(event) {\n    event.preventDefault();\n    if (this.sourceTarget?.textContent) {\n      navigator.clipboard\n        .writeText(this.sourceTarget.textContent)\n        .then(() => {\n          this.showSuccess();\n        })\n        .catch((error) => {\n          console.error(\"Failed to copy text: \", error);\n        });\n    }\n  }\n\n  showSuccess() {\n    this.iconDefaultTarget.classList.add(\"hidden\");\n    this.iconSuccessTarget.classList.remove(\"hidden\");\n    setTimeout(() => {\n      this.iconDefaultTarget.classList.remove(\"hidden\");\n      this.iconSuccessTarget.classList.add(\"hidden\");\n    }, 3000);\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/color_avatar_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"color-avatar\"\n// Used by the transaction merchant form to show a preview of what the avatar will look like\nexport default class extends Controller {\n  static targets = [\"name\", \"avatar\", \"selection\"];\n\n  connect() {\n    this.nameTarget.addEventListener(\"input\", this.handleNameChange);\n  }\n\n  disconnect() {\n    this.nameTarget.removeEventListener(\"input\", this.handleNameChange);\n  }\n\n  handleNameChange = (e) => {\n    this.avatarTarget.textContent = (\n      e.currentTarget.value?.[0] || \"?\"\n    ).toUpperCase();\n  };\n\n  handleColorChange(e) {\n    const color = e.currentTarget.value;\n    this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, transparent)`;\n    this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, transparent)`;\n    this.avatarTarget.style.color = color;\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/color_select_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n  static targets = [\"input\", \"decoration\"];\n  static values = { selection: String };\n\n  connect() {\n    this.#renderOptions();\n  }\n\n  select({ target }) {\n    this.selectionValue = target.dataset.value;\n  }\n\n  selectionValueChanged() {\n    this.#options.forEach((option) => {\n      if (option.dataset.value === this.selectionValue) {\n        this.#check(option);\n        this.inputTarget.value = this.selectionValue;\n      } else {\n        this.#uncheck(option);\n      }\n    });\n  }\n\n  #renderOptions() {\n    this.#options.forEach((option) => {\n      option.style.backgroundColor = option.dataset.value;\n    });\n  }\n\n  #check(option) {\n    option.setAttribute(\"aria-checked\", \"true\");\n    option.style.boxShadow = `0px 0px 0px 4px ${hexToRGBA(\n      option.dataset.value,\n      0.2,\n    )}`;\n    this.decorationTarget.style.backgroundColor = option.dataset.value;\n  }\n\n  #uncheck(option) {\n    option.setAttribute(\"aria-checked\", \"false\");\n    option.style.boxShadow = \"none\";\n  }\n\n  get #options() {\n    return Array.from(this.element.querySelectorAll(\"[role='radio']\"));\n  }\n}\n\nfunction hexToRGBA(hex, alpha = 1) {\n  let hexCode = hex.replace(/^#/, \"\");\n  let calculatedAlpha = alpha;\n\n  if (hexCode.length === 8) {\n    calculatedAlpha = Number.parseInt(hexCode.slice(6, 8), 16) / 255;\n    hexCode = hexCode.slice(0, 6);\n  }\n\n  const r = Number.parseInt(hexCode.slice(0, 2), 16);\n  const g = Number.parseInt(hexCode.slice(2, 4), 16);\n  const b = Number.parseInt(hexCode.slice(4, 6), 16);\n\n  return `rgba(${r}, ${g}, ${b}, ${calculatedAlpha})`;\n}\n"
  },
  {
    "path": "app/javascript/controllers/confirm_dialog_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"confirm-dialog\"\n// See javascript/controllers/application.js for how this is wired up\nexport default class extends Controller {\n  static targets = [\"title\", \"subtitle\", \"confirmButton\"];\n\n  handleConfirm(rawData) {\n    const data = this.#normalizeRawData(rawData);\n\n    this.#prepareDialog(data);\n\n    this.element.showModal();\n\n    return new Promise((resolve) => {\n      this.element.addEventListener(\n        \"close\",\n        () => {\n          const isConfirmed = this.element.returnValue === \"confirm\";\n          resolve(isConfirmed);\n        },\n        { once: true },\n      );\n    });\n  }\n\n  #prepareDialog(data) {\n    const variant = data.variant || \"primary\";\n\n    this.confirmButtonTargets.forEach((button) => {\n      if (button.dataset.variant === variant) {\n        button.removeAttribute(\"hidden\");\n      } else {\n        button.setAttribute(\"hidden\", true);\n      }\n\n      button.textContent = data.confirmText || \"Confirm\";\n    });\n\n    this.titleTarget.textContent = data.title || \"Are you sure?\";\n    this.subtitleTarget.innerHTML =\n      data.body || \"This action cannot be undone.\";\n  }\n\n  // If data is a string, it's the title.  Otherwise, return the parsed object.\n  #normalizeRawData(rawData) {\n    try {\n      const parsed = JSON.parse(rawData);\n\n      if (typeof parsed === \"boolean\") {\n        return { title: \"Are you sure?\" };\n      }\n\n      return parsed;\n    } catch (e) {\n      return { title: rawData };\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/deletion_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n  static targets = [\n    \"replacementField\",\n    \"destructiveSubmitButton\",\n    \"safeSubmitButton\",\n  ];\n\n  static values = {\n    submitTextWhenReplacing: String,\n    submitTextWhenNotReplacing: String,\n  };\n\n  chooseSubmitButton() {\n    if (this.replacementFieldTarget.value) {\n      this.destructiveSubmitButtonTarget.hidden = true;\n      this.safeSubmitButtonTarget.textContent =\n        this.submitTextWhenReplacingValue;\n      this.safeSubmitButtonTarget.hidden = false;\n    } else {\n      this.destructiveSubmitButtonTarget.textContent =\n        this.submitTextWhenNotReplacingValue;\n      this.destructiveSubmitButtonTarget.hidden = false;\n      this.safeSubmitButtonTarget.hidden = true;\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/donut_chart_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\nimport * as d3 from \"d3\";\n\n// Connects to data-controller=\"donut-chart\"\nexport default class extends Controller {\n  static targets = [\"chartContainer\", \"contentContainer\", \"defaultContent\"];\n  static values = {\n    segments: { type: Array, default: [] },\n    unusedSegmentId: { type: String, default: \"unused\" },\n    overageSegmentId: { type: String, default: \"overage\" },\n    segmentHeight: { type: Number, default: 3 },\n    segmentOpacity: { type: Number, default: 1 },\n  };\n\n  #viewBoxSize = 100;\n  #minSegmentAngle = this.segmentHeightValue * 0.01;\n\n  connect() {\n    this.#draw();\n    document.addEventListener(\"turbo:load\", this.#redraw);\n    this.element.addEventListener(\"mouseleave\", this.#clearSegmentHover);\n  }\n\n  disconnect() {\n    this.#teardown();\n    document.removeEventListener(\"turbo:load\", this.#redraw);\n    this.element.removeEventListener(\"mouseleave\", this.#clearSegmentHover);\n  }\n\n  get #data() {\n    const totalPieValue = this.segmentsValue.reduce(\n      (acc, s) => acc + Number(s.amount),\n      0,\n    );\n\n    // Overage is always first segment, unused is always last segment\n    return this.segmentsValue\n      .filter((s) => s.amount > 0)\n      .map((s) => ({\n        ...s,\n        amount: Math.max(\n          Number(s.amount),\n          totalPieValue * this.#minSegmentAngle,\n        ),\n      }))\n      .sort((a, b) => {\n        if (a.id === this.overageSegmentIdValue) return -1;\n        if (b.id === this.overageSegmentIdValue) return 1;\n        if (a.id === this.unusedSegmentIdValue) return 1;\n        if (b.id === this.unusedSegmentIdValue) return -1;\n        return b.amount - a.amount;\n      });\n  }\n\n  #redraw = () => {\n    this.#teardown();\n    this.#draw();\n  };\n\n  #teardown() {\n    d3.select(this.chartContainerTarget).selectAll(\"*\").remove();\n  }\n\n  #draw() {\n    const svg = d3\n      .select(this.chartContainerTarget)\n      .append(\"svg\")\n      .attr(\"viewBox\", `0 0 ${this.#viewBoxSize} ${this.#viewBoxSize}`) // Square aspect ratio\n      .attr(\"preserveAspectRatio\", \"xMidYMid meet\")\n      .attr(\"class\", \"w-full h-full\");\n\n    const pie = d3\n      .pie()\n      .sortValues(null) // Preserve order of segments\n      .value((d) => d.amount);\n\n    const mainArc = d3\n      .arc()\n      .innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue)\n      .outerRadius(this.#viewBoxSize / 2)\n      .cornerRadius(this.segmentHeightValue)\n      .padAngle(this.#minSegmentAngle);\n\n    const segmentArcs = svg\n      .append(\"g\")\n      .attr(\n        \"transform\",\n        `translate(${this.#viewBoxSize / 2}, ${this.#viewBoxSize / 2})`,\n      )\n      .selectAll(\"arc\")\n      .data(pie(this.#data))\n      .enter()\n      .append(\"g\")\n      .attr(\"class\", \"arc pointer-events-auto\")\n      .append(\"path\")\n      .attr(\"data-segment-id\", (d) => d.data.id)\n      .attr(\"data-original-color\", this.#transformRingColor)\n      .attr(\"fill\", this.#transformRingColor)\n      .attr(\"d\", mainArc);\n\n    // Ensures that user can click on default content without triggering hover on a segment if that is their intent\n    let hoverTimeout = null;\n\n    segmentArcs\n      .on(\"mouseover\", (event) => {\n        hoverTimeout = setTimeout(() => {\n          this.#clearSegmentHover();\n          this.#handleSegmentHover(event);\n        }, 150);\n      })\n      .on(\"mouseleave\", () => {\n        clearTimeout(hoverTimeout);\n      });\n  }\n\n  #transformRingColor = ({ data: { id, color } }) => {\n    if (id === this.unusedSegmentIdValue || id === this.overageSegmentIdValue) {\n      return color;\n    }\n\n    const reducedOpacityColor = d3.color(color);\n    reducedOpacityColor.opacity = this.segmentOpacityValue;\n    return reducedOpacityColor;\n  };\n\n  // Highlights segment and shows segment specific content (all other segments are grayed out)\n  #handleSegmentHover(event) {\n    const segmentId = event.target.dataset.segmentId;\n    const template = this.element.querySelector(`#segment_${segmentId}`);\n    const unusedSegmentId = this.unusedSegmentIdValue;\n\n    if (!template) return;\n\n    d3.select(this.chartContainerTarget)\n      .selectAll(\"path\")\n      .attr(\"fill\", function () {\n        if (this.dataset.segmentId === segmentId) {\n          if (this.dataset.segmentId === unusedSegmentId) {\n            return \"var(--budget-unused-fill)\";\n          }\n\n          return this.dataset.originalColor;\n        }\n\n        return \"var(--budget-unallocated-fill)\";\n      });\n\n    this.defaultContentTarget.classList.add(\"hidden\");\n    template.classList.remove(\"hidden\");\n  }\n\n  // Restores original segment colors and hides segment specific content\n  #clearSegmentHover = () => {\n    this.defaultContentTarget.classList.remove(\"hidden\");\n\n    d3.select(this.chartContainerTarget)\n      .selectAll(\"path\")\n      .attr(\"fill\", function () {\n        return this.dataset.originalColor;\n      });\n\n    for (const child of this.contentContainerTarget.children) {\n      if (child !== this.defaultContentTarget) {\n        child.classList.add(\"hidden\");\n      }\n    }\n  };\n}\n"
  },
  {
    "path": "app/javascript/controllers/element_removal_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"element-removal\"\nexport default class extends Controller {\n  remove() {\n    this.element.remove();\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/file_upload_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [\"input\", \"fileName\", \"uploadArea\", \"uploadText\"]\n\n  connect() {\n    if (this.hasInputTarget) {\n      this.inputTarget.addEventListener(\"change\", this.fileSelected.bind(this))\n    }\n    \n    // Find the form element\n    this.form = this.element.closest(\"form\")\n    if (this.form) {\n      this.form.addEventListener(\"turbo:submit-start\", this.formSubmitting.bind(this))\n    }\n  }\n\n  disconnect() {\n    if (this.hasInputTarget) {\n      this.inputTarget.removeEventListener(\"change\", this.fileSelected.bind(this))\n    }\n    \n    if (this.form) {\n      this.form.removeEventListener(\"turbo:submit-start\", this.formSubmitting.bind(this))\n    }\n  }\n\n  triggerFileInput() {\n    if (this.hasInputTarget) {\n      this.inputTarget.click()\n    }\n  }\n\n  fileSelected() {\n    if (this.hasInputTarget && this.inputTarget.files.length > 0) {\n      const fileName = this.inputTarget.files[0].name\n      \n      if (this.hasFileNameTarget) {\n        // Find the paragraph element inside the fileName target\n        const fileNameText = this.fileNameTarget.querySelector('p')\n        if (fileNameText) {\n          fileNameText.textContent = fileName\n        }\n        \n        this.fileNameTarget.classList.remove(\"hidden\")\n      }\n      \n      if (this.hasUploadTextTarget) {\n        this.uploadTextTarget.classList.add(\"hidden\")\n      }\n      \n    \n    }\n  }\n  \n  formSubmitting() {\n    if (this.hasFileNameTarget && this.hasInputTarget && this.inputTarget.files.length > 0) {\n      const fileNameText = this.fileNameTarget.querySelector('p')\n      if (fileNameText) {\n        fileNameText.textContent = `Uploading ${this.inputTarget.files[0].name}...`\n      }\n      \n      // Change the icon to a loader\n      const iconContainer = this.fileNameTarget.querySelector('.lucide-file-text')\n      if (iconContainer) {\n        iconContainer.classList.add('animate-pulse')\n      }\n    }\n    \n    if (this.hasUploadAreaTarget) {\n      this.uploadAreaTarget.classList.add(\"opacity-70\")\n    }\n  }\n} "
  },
  {
    "path": "app/javascript/controllers/hotkey_controller.js",
    "content": "import { install, uninstall } from \"@github/hotkey\";\nimport { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"hotkey\"\nexport default class extends Controller {\n  connect() {\n    install(this.element);\n  }\n\n  disconnect() {\n    uninstall(this.element);\n  }\n\n  navigateBack(event) {\n    window.history.back();\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/import_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"import\"\nexport default class extends Controller {\n  static values = {\n    csv: { type: Array, default: [] },\n    amountTypeColumnKey: { type: String, default: \"\" },\n  };\n\n  static targets = [\n    \"signedAmountFieldset\",\n    \"customColumnFieldset\",\n    \"amountTypeValue\",\n    \"amountTypeStrategySelect\",\n  ];\n\n  connect() {\n    if (\n      this.amountTypeStrategySelectTarget.value === \"custom_column\" &&\n      this.amountTypeColumnKeyValue\n    ) {\n      this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue);\n    }\n  }\n\n  handleAmountTypeStrategyChange(event) {\n    const amountTypeStrategy = event.target.value;\n\n    if (amountTypeStrategy === \"custom_column\") {\n      this.#enableCustomColumnFieldset();\n\n      if (this.amountTypeColumnKeyValue) {\n        this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue);\n      }\n    }\n\n    if (amountTypeStrategy === \"signed_amount\") {\n      this.#enableSignedAmountFieldset();\n    }\n  }\n\n  handleAmountTypeChange(event) {\n    const amountTypeColumnKey = event.target.value;\n\n    this.#showAmountTypeValueTargets(amountTypeColumnKey);\n  }\n\n  #showAmountTypeValueTargets(amountTypeColumnKey) {\n    const selectableValues = this.#uniqueValuesForColumn(amountTypeColumnKey);\n\n    this.amountTypeValueTarget.classList.remove(\"hidden\");\n    this.amountTypeValueTarget.classList.add(\"flex\");\n\n    const select = this.amountTypeValueTarget.querySelector(\"select\");\n    const currentValue = select.value;\n    select.options.length = 0;\n    const fragment = document.createDocumentFragment();\n\n    // Only add the prompt if there's no current value\n    if (!currentValue) {\n      fragment.appendChild(new Option(\"Select value\", \"\"));\n    }\n\n    selectableValues.forEach((value) => {\n      const option = new Option(value, value);\n      if (value === currentValue) {\n        option.selected = true;\n      }\n      fragment.appendChild(option);\n    });\n\n    select.appendChild(fragment);\n  }\n\n  #uniqueValuesForColumn(column) {\n    const colIdx = this.csvValue[0].indexOf(column);\n    const values = this.csvValue.slice(1).map((row) => row[colIdx]);\n    return [...new Set(values)];\n  }\n\n  #enableCustomColumnFieldset() {\n    this.customColumnFieldsetTarget.classList.remove(\"hidden\");\n    this.signedAmountFieldsetTarget.classList.add(\"hidden\");\n\n    // Set required on custom column fields\n    this.customColumnFieldsetTarget\n      .querySelectorAll(\"select, input\")\n      .forEach((field) => {\n        field.setAttribute(\"required\", \"\");\n      });\n\n    // Remove required from signed amount fields\n    this.signedAmountFieldsetTarget\n      .querySelectorAll(\"select, input\")\n      .forEach((field) => {\n        field.removeAttribute(\"required\");\n      });\n  }\n\n  #enableSignedAmountFieldset() {\n    this.customColumnFieldsetTarget.classList.add(\"hidden\");\n    this.signedAmountFieldsetTarget.classList.remove(\"hidden\");\n\n    // Remove required from custom column fields\n    this.customColumnFieldsetTarget\n      .querySelectorAll(\"select, input\")\n      .forEach((field) => {\n        field.removeAttribute(\"required\");\n      });\n\n    // Set required on signed amount fields\n    this.signedAmountFieldsetTarget\n      .querySelectorAll(\"select, input\")\n      .forEach((field) => {\n        field.setAttribute(\"required\", \"\");\n      });\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/index.js",
    "content": "// Import and register all your controllers from the importmap under controllers/*\n\nimport { application } from \"controllers/application\";\n\n// Eager load all controllers defined in the import map under controllers/**/*_controller\nimport { eagerLoadControllersFrom } from \"@hotwired/stimulus-loading\";\neagerLoadControllersFrom(\"controllers\", application);\n\n// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)\n// import { lazyLoadControllersFrom } from \"@hotwired/stimulus-loading\"\n// lazyLoadControllersFrom(\"controllers\", application)\n"
  },
  {
    "path": "app/javascript/controllers/intercom_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"intercom\"\nexport default class extends Controller {\n  show() {\n    Intercom(\"show\");\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/list_filter_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Basic functionality to filter a list based on a provided text attribute.\nexport default class extends Controller {\n  static targets = [\"input\", \"list\", \"emptyMessage\"];\n\n  connect() {\n    this.inputTarget.focus();\n  }\n\n  filter() {\n    const filterValue = this.inputTarget.value.toLowerCase();\n    const items = this.listTarget.querySelectorAll(\".filterable-item\");\n    let noMatchFound = true;\n\n    if (this.hasEmptyMessageTarget) {\n      this.emptyMessageTarget.classList.add(\"hidden\");\n    }\n\n    items.forEach((item) => {\n      const text = item.getAttribute(\"data-filter-name\").toLowerCase();\n      const shouldDisplay = text.includes(filterValue);\n      item.style.display = shouldDisplay ? \"\" : \"none\";\n\n      if (shouldDisplay) {\n        noMatchFound = false;\n      }\n    });\n\n    if (noMatchFound && this.hasEmptyMessageTarget) {\n      this.emptyMessageTarget.classList.remove(\"hidden\");\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/list_keyboard_navigation_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"list-keyboard-navigation\"\nexport default class extends Controller {\n  focusPrevious() {\n    this.focusLinkTargetInDirection(-1);\n  }\n\n  focusNext() {\n    this.focusLinkTargetInDirection(1);\n  }\n\n  focusLinkTargetInDirection(direction) {\n    const element = this.getLinkTargetInDirection(direction);\n    element?.focus();\n  }\n\n  getLinkTargetInDirection(direction) {\n    const indexOfLastFocus = this.indexOfLastFocus();\n    let nextIndex = (indexOfLastFocus + direction) % this.focusableLinks.length;\n    if (nextIndex < 0) nextIndex = this.focusableLinks.length - 1;\n\n    return this.focusableLinks[nextIndex];\n  }\n\n  indexOfLastFocus(targets = this.focusableLinks) {\n    const indexOfActiveElement = targets.indexOf(document.activeElement);\n\n    if (indexOfActiveElement !== -1) {\n      return indexOfActiveElement;\n    }\n    return targets.findIndex(\n      (target) => target.getAttribute(\"tabindex\") === \"0\",\n    );\n  }\n\n  get focusableLinks() {\n    return Array.from(this.element.querySelectorAll(\"a[href]\"));\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/mobile_cell_interaction_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"mobile-cell-interaction\"\nexport default class extends Controller {\n  static targets = [\"field\", \"highlight\", \"errorTooltip\", \"errorIcon\"];\n  static values = { error: String };\n  \n  touchTimeout = null;\n  activeTooltip = null;\n  documentClickHandler = null;\n\n  connect() {\n    this.documentClickHandler = this.handleDocumentClick.bind(this);\n    document.addEventListener('click', this.documentClickHandler);\n  }\n\n  disconnect() {\n    if (this.documentClickHandler) {\n      document.removeEventListener('click', this.documentClickHandler);\n    }\n  }\n\n  handleDocumentClick(event) {\n    if (event.target.closest('[data-mobile-cell-interaction-target=\"errorTooltip\"]') || \n        event.target.closest('[data-mobile-cell-interaction-target=\"errorIcon\"]')) {\n      return;\n    }\n    \n    this.hideAllErrorTooltips();\n  }\n\n  highlightCell(event) {\n    const field = event.target;\n    const highlight = this.findHighlightForField(field);\n    if (highlight) {\n      highlight.style.opacity = '1';\n    }\n  }\n  \n  unhighlightCell(event) {\n    const field = event.target;\n    const highlight = this.findHighlightForField(field);\n    if (highlight) {\n      highlight.style.opacity = '0';\n    }\n    \n    this.hideAllErrorTooltips();\n  }\n  \n  handleCellTouch(event) {\n    if (this.touchTimeout) {\n      clearTimeout(this.touchTimeout);\n    }\n    \n    const field = event.target;\n    \n    const highlight = this.findHighlightForField(field);\n    if (highlight) {\n      highlight.style.opacity = '1';\n      \n      this.touchTimeout = window.setTimeout(() => {\n        if (document.activeElement !== field) {\n          highlight.style.opacity = '0';\n        }\n      }, 1000);\n    }\n    \n    if (this.hasErrorValue && this.errorValue) {\n      this.showErrorTooltip();\n    }\n  }\n  \n  toggleErrorMessage(event) {\n    const errorIcon = event.currentTarget;\n    const cellContainer = errorIcon.closest('div');\n    const field = cellContainer.querySelector('input');\n    \n    if (field) {\n      field.focus();\n    }\n    \n    const tooltip = this.errorTooltipTarget;\n    \n    this.hideAllTooltipsExcept(tooltip);\n    \n    if (tooltip.classList.contains('hidden')) {\n      tooltip.classList.remove('hidden');\n      this.activeTooltip = tooltip;\n      \n      setTimeout(() => {\n        if (tooltip === this.activeTooltip) {\n          tooltip.classList.add('hidden');\n          this.activeTooltip = null;\n        }\n      }, 3000);\n    } else {\n      tooltip.classList.add('hidden');\n      this.activeTooltip = null;\n    }\n    \n    event.stopPropagation();\n  }\n  \n  showErrorTooltip() {\n    if (this.hasErrorTooltipTarget) {\n      const tooltip = this.errorTooltipTarget;\n      tooltip.classList.remove('hidden');\n      this.activeTooltip = tooltip;\n      \n      setTimeout(() => {\n        if (tooltip === this.activeTooltip) {\n          tooltip.classList.add('hidden');\n          this.activeTooltip = null;\n        }\n      }, 3000);\n    }\n  }\n  \n  hideAllErrorTooltips() {\n    document.querySelectorAll('[data-mobile-cell-interaction-target=\"errorTooltip\"]').forEach(tooltip => {\n      tooltip.classList.add('hidden');\n    });\n    this.activeTooltip = null;\n  }\n  \n  hideAllTooltipsExcept(tooltipToKeep) {\n    document.querySelectorAll('[data-mobile-cell-interaction-target=\"errorTooltip\"]').forEach(tooltip => {\n      if (tooltip !== tooltipToKeep) {\n        tooltip.classList.add('hidden');\n      }\n    });\n  }\n  \n  selectCell(event) {\n    const errorIcon = event.currentTarget;\n    const cellContainer = errorIcon.closest('div');\n    const field = cellContainer.querySelector('input');\n    \n    if (field) {\n      field.focus();\n      event.stopPropagation();\n    }\n  }\n  \n  findHighlightForField(field) {\n    const container = field.closest('div');\n    return container ? container.querySelector('[data-mobile-cell-interaction-target=\"highlight\"]') : null;\n  }\n} "
  },
  {
    "path": "app/javascript/controllers/money_field_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { CurrenciesService } from \"services/currencies_service\";\n\n// Connects to data-controller=\"money-field\"\n// when currency select change, update the input value with the correct placeholder and step\nexport default class extends Controller {\n  static targets = [\"amount\", \"currency\", \"symbol\"];\n\n  handleCurrencyChange(e) {\n    const selectedCurrency = e.target.value;\n    this.updateAmount(selectedCurrency);\n  }\n\n  updateAmount(currency) {\n    new CurrenciesService().get(currency).then((currency) => {\n      this.amountTarget.step = currency.step;\n\n      if (Number.isFinite(this.amountTarget.value)) {\n        this.amountTarget.value = Number.parseFloat(\n          this.amountTarget.value,\n        ).toFixed(currency.default_precision);\n      }\n\n      this.symbolTarget.innerText = currency.symbol;\n    });\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/onboarding_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"onboarding\"\nexport default class extends Controller {\n  setLocale(event) {\n    this.refreshWithParam(\"locale\", event.target.value);\n  }\n\n  setDateFormat(event) {\n    this.refreshWithParam(\"date_format\", event.target.value);\n  }\n\n  setCurrency(event) {\n    this.refreshWithParam(\"currency\", event.target.value);\n  }\n\n  setTheme(event) {\n    document.documentElement.setAttribute(\"data-theme\", event.target.value);\n  }\n\n  refreshWithParam(key, value) {\n    const url = new URL(window.location);\n    url.searchParams.set(key, value);\n\n    // Preserve existing params by getting the current search string\n    // and appending our new param to it\n    const currentParams = new URLSearchParams(window.location.search);\n    currentParams.set(key, value);\n\n    // Refresh the page with all params\n    window.location.search = currentParams.toString();\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/password_validator_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"password-validator\"\nexport default class extends Controller {\n  static targets = [\"input\", \"requirementType\", \"blockLine\"];\n\n  connect() {\n    this.validate();\n  }\n\n  validate() {\n    const password = this.inputTarget.value;\n    let requirementsMet = 0;\n\n    // Check each requirement and count how many are met\n    const lengthValid = password.length >= 8;\n    const caseValid = /[A-Z]/.test(password) && /[a-z]/.test(password);\n    const numberValid = /\\d/.test(password);\n    const specialValid = /[!@#$%^&*(),.?\":{}|<>]/.test(password);\n\n    // Update individual requirement text\n    this.validateRequirementText(\"length\", lengthValid);\n    this.validateRequirementText(\"case\", caseValid);\n    this.validateRequirementText(\"number\", numberValid);\n    this.validateRequirementText(\"special\", specialValid);\n\n    // Count total requirements met\n    if (lengthValid) requirementsMet++;\n    if (caseValid) requirementsMet++;\n    if (numberValid) requirementsMet++;\n    if (specialValid) requirementsMet++;\n\n    // Update block lines sequentially\n    this.updateBlockLines(requirementsMet);\n  }\n\n  validateRequirementText(type, isValid) {\n    this.requirementTypeTargets.forEach((target) => {\n      if (target.dataset.requirementType === type) {\n        if (isValid) {\n          target.classList.remove(\"text-secondary\");\n          target.classList.add(\"text-green-600\");\n        } else {\n          target.classList.remove(\"text-green-600\");\n          target.classList.add(\"text-secondary\");\n        }\n      }\n    });\n  }\n\n  updateBlockLines(requirementsMet) {\n    // Update block lines sequentially based on total requirements met\n    this.blockLineTargets.forEach((line, index) => {\n      if (index < requirementsMet) {\n        line.classList.remove(\"bg-gray-200\");\n        line.classList.add(\"bg-green-600\");\n      } else {\n        line.classList.remove(\"bg-green-600\");\n        line.classList.add(\"bg-gray-200\");\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/password_visibility_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"password-visibility\"\nexport default class extends Controller {\n  static targets = [\"input\", \"showIcon\", \"hideIcon\"];\n\n  connect() {\n    this.hideIconTarget.classList.add(\"hidden\");\n  }\n\n  toggle() {\n    const input = this.inputTarget;\n    const type = input.type === \"password\" ? \"text\" : \"password\";\n    input.type = type;\n\n    this.showIconTarget.classList.toggle(\"hidden\");\n    this.hideIconTarget.classList.toggle(\"hidden\");\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/plaid_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"plaid\"\nexport default class extends Controller {\n  static values = {\n    linkToken: String,\n    region: { type: String, default: \"us\" },\n    isUpdate: { type: Boolean, default: false },\n    itemId: String,\n  };\n\n  connect() {\n    this.open();\n  }\n\n  open() {\n    const handler = Plaid.create({\n      token: this.linkTokenValue,\n      onSuccess: this.handleSuccess,\n      onLoad: this.handleLoad,\n      onExit: this.handleExit,\n      onEvent: this.handleEvent,\n    });\n\n    handler.open();\n  }\n\n  handleSuccess = (public_token, metadata) => {\n    if (this.isUpdateValue) {\n      // Trigger a sync to verify the connection and update status\n      fetch(`/plaid_items/${this.itemIdValue}/sync`, {\n        method: \"POST\",\n        headers: {\n          Accept: \"application/json\",\n          \"Content-Type\": \"application/json\",\n          \"X-CSRF-Token\": document.querySelector('[name=\"csrf-token\"]').content,\n        },\n      }).then(() => {\n        // Refresh the page to show the updated status\n        window.location.href = \"/accounts\";\n      });\n      return;\n    }\n\n    // For new connections, create a new Plaid item\n    fetch(\"/plaid_items\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"X-CSRF-Token\": document.querySelector('[name=\"csrf-token\"]').content,\n      },\n      body: JSON.stringify({\n        plaid_item: {\n          public_token: public_token,\n          metadata: metadata,\n          region: this.regionValue,\n        },\n      }),\n    }).then((response) => {\n      if (response.redirected) {\n        window.location.href = response.url;\n      }\n    });\n  };\n\n  handleExit = (err, metadata) => {\n    // If there was an error during update mode, refresh the page to show latest status\n    if (err && metadata.status === \"requires_credentials\") {\n      window.location.href = \"/accounts\";\n    }\n  };\n\n  handleEvent = (eventName, metadata) => {\n    // no-op\n  };\n\n  handleLoad = () => {\n    // no-op\n  };\n}\n"
  },
  {
    "path": "app/javascript/controllers/preserve_scroll_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\n/*\n  https://dev.to/konnorrogers/maintain-scroll-position-in-turbo-without-data-turbo-permanent-2b1i\n  modified to add support for horizontal scrolling\n\n  only requirement is that the element has an id\n */\nexport default class extends Controller {\n  static scrollPositions = {}\n\n  connect() {\n    this.preserveScrollBound = this.preserveScroll.bind(this)\n    this.restoreScrollBound = this.restoreScroll.bind(this)\n\n    window.addEventListener(\"turbo:before-cache\", this.preserveScrollBound)\n    window.addEventListener(\"turbo:before-render\", this.restoreScrollBound)\n    window.addEventListener(\"turbo:render\", this.restoreScrollBound)\n  }\n\n  disconnect() {\n    window.removeEventListener(\"turbo:before-cache\", this.preserveScrollBound)\n    window.removeEventListener(\"turbo:before-render\", this.restoreScrollBound)\n    window.removeEventListener(\"turbo:render\", this.restoreScrollBound)\n  }\n\n  preserveScroll() {\n    if (!this.element.id) return\n\n    this.constructor.scrollPositions[this.element.id] = {\n      top: this.element.scrollTop,\n      left: this.element.scrollLeft\n    }\n  }\n\n  restoreScroll(event) {\n    if (!this.element.id) return\n\n    if (this.constructor.scrollPositions[this.element.id]) {\n      this.element.scrollTop = this.constructor.scrollPositions[this.element.id].top\n      this.element.scrollLeft = this.constructor.scrollPositions[this.element.id].left\n    }\n  }\n}"
  },
  {
    "path": "app/javascript/controllers/profile_image_preview_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n  static targets = [\n    \"attachedImage\",\n    \"previewImage\",\n    \"placeholderImage\",\n    \"deleteProfileImage\",\n    \"input\",\n    \"clearBtn\",\n    \"uploadText\",\n    \"changeText\",\n    \"cameraIcon\"\n  ];\n\n  clearFileInput() {\n    this.inputTarget.value = null;\n    this.clearBtnTarget.classList.add(\"hidden\");\n    this.placeholderImageTarget.classList.remove(\"hidden\");\n    this.attachedImageTarget.classList.add(\"hidden\");\n    this.previewImageTarget.classList.add(\"hidden\");\n    this.deleteProfileImageTarget.value = \"1\";\n    this.uploadTextTarget.classList.remove(\"hidden\");\n    this.changeTextTarget.classList.add(\"hidden\");\n    this.changeTextTarget.setAttribute(\"aria-hidden\", \"true\");\n    this.uploadTextTarget.setAttribute(\"aria-hidden\", \"false\");\n    this.cameraIconTarget.classList.remove(\"!hidden\");\n\n  }\n\n  showFileInputPreview(event) {\n    const file = event.target.files[0];\n    if (!file) return;\n\n    this.placeholderImageTarget.classList.add(\"hidden\");\n    this.attachedImageTarget.classList.add(\"hidden\");\n    this.previewImageTarget.classList.remove(\"hidden\");\n    this.clearBtnTarget.classList.remove(\"hidden\");\n    this.deleteProfileImageTarget.value = \"0\";\n    this.uploadTextTarget.classList.add(\"hidden\");\n    this.changeTextTarget.classList.remove(\"hidden\");\n    this.changeTextTarget.setAttribute(\"aria-hidden\", \"false\");\n    this.uploadTextTarget.setAttribute(\"aria-hidden\", \"true\");\n    this.cameraIconTarget.classList.add(\"!hidden\");\n    this.previewImageTarget.querySelector(\"img\").src =\n      URL.createObjectURL(file);\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/rule/actions_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"rule--actions\"\nexport default class extends Controller {\n  static values = { actionExecutors: Array };\n  static targets = [\n    \"destroyField\",\n    \"actionValue\",\n    \"selectTemplate\",\n    \"textTemplate\"\n  ];\n\n  remove(e) {\n    if (e.params.destroy) {\n      this.destroyFieldTarget.value = true;\n      this.element.classList.add(\"hidden\");\n    } else {\n      this.element.remove();\n    }\n  }\n\n  handleActionTypeChange(e) {\n    const actionExecutor = this.actionExecutorsValue.find(\n      (executor) => executor.key === e.target.value,\n    );\n\n    // Clear any existing input elements first\n    this.#clearFormFields();\n\n    if (actionExecutor.type === \"select\") {\n      this.#buildSelectFor(actionExecutor);\n    } else if (actionExecutor.type === \"text\") {\n      this.#buildTextInputFor();\n    } else {\n      // Hide for any type that doesn't need a value (e.g. function)\n      this.#hideActionValue();\n    }\n  }\n\n  #hideActionValue() {\n    this.actionValueTarget.classList.add(\"hidden\");\n  }\n\n  #clearFormFields() {\n    // Remove all children from actionValueTarget\n    this.actionValueTarget.innerHTML = \"\";\n  }\n\n  #buildSelectFor(actionExecutor) {\n    // Clone the select template\n    const template = this.selectTemplateTarget.content.cloneNode(true);\n    const selectEl = template.querySelector(\"select\");\n\n    // Add options to the select element\n    if (selectEl) {\n      selectEl.innerHTML = \"\";\n      if (!actionExecutor.options || actionExecutor.options.length === 0) {\n        selectEl.disabled = true;\n        const optionEl = document.createElement(\"option\");\n        optionEl.textContent = \"(none)\";\n        selectEl.appendChild(optionEl);\n      } else {\n        selectEl.disabled = false;\n        for (const option of actionExecutor.options) {\n          const optionEl = document.createElement(\"option\");\n          optionEl.value = option[1];\n          optionEl.textContent = option[0];\n          selectEl.appendChild(optionEl);\n        }\n      }\n    }\n\n    // Add the template content to the actionValue target and ensure it's visible\n    this.actionValueTarget.appendChild(template);\n    this.actionValueTarget.classList.remove(\"hidden\");\n  }\n\n  #buildTextInputFor() {\n    // Clone the text template\n    const template = this.textTemplateTarget.content.cloneNode(true);\n\n    // Ensure the input is always empty\n    const inputEl = template.querySelector(\"input\");\n    if (inputEl) inputEl.value = \"\";\n\n    // Add the template content to the actionValue target and ensure it's visible\n    this.actionValueTarget.appendChild(template);\n    this.actionValueTarget.classList.remove(\"hidden\");\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/rule/conditions_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"rule--conditions\"\nexport default class extends Controller {\n  static values = { conditionFilters: Array };\n  static targets = [\n    \"destroyField\",\n    \"filterValue\",\n    \"operatorSelect\",\n    \"subConditionTemplate\",\n    \"subConditionsList\",\n  ];\n\n  addSubCondition() {\n    const html = this.subConditionTemplateTarget.innerHTML.replaceAll(\n      \"IDX_CHILD_PLACEHOLDER\",\n      this.#uniqueKey(),\n    );\n\n    this.subConditionsListTarget.insertAdjacentHTML(\"beforeend\", html);\n  }\n\n  remove(e) {\n    // Find the parent rules controller before removing the condition\n    const rulesEl = this.element.closest('[data-controller~=\"rules\"]');\n\n    if (e.params.destroy) {\n      this.destroyFieldTarget.value = true;\n      this.element.classList.add(\"hidden\");\n    } else {\n      this.element.remove();\n    }\n\n    // Update the prefixes of all conditions from the parent rules controller\n    if (rulesEl) {\n      const rulesController = this.application.getControllerForElementAndIdentifier(rulesEl, \"rules\");\n      if (rulesController && typeof rulesController.updateConditionPrefixes === \"function\") {\n        rulesController.updateConditionPrefixes();\n      }\n    }\n  }\n\n  handleConditionTypeChange(e) {\n    const conditionFilter = this.conditionFiltersValue.find(\n      (filter) => filter.key === e.target.value,\n    );\n\n    if (conditionFilter.type === \"select\") {\n      this.#buildSelectFor(conditionFilter);\n    } else {\n      this.#buildTextInputFor(conditionFilter);\n    }\n\n    this.#updateOperatorsField(conditionFilter);\n  }\n\n  get valueInputEl() {\n    const textInput = this.filterValueTarget.querySelector(\"input\");\n    const selectInput = this.filterValueTarget.querySelector(\"select\");\n\n    return textInput || selectInput;\n  }\n\n  #updateOperatorsField(conditionFilter) {\n    this.operatorSelectTarget.innerHTML = \"\";\n\n    for (const operator of conditionFilter.operators) {\n      const optionEl = document.createElement(\"option\");\n      optionEl.value = operator[1];\n      optionEl.textContent = operator[0];\n      this.operatorSelectTarget.appendChild(optionEl);\n    }\n  }\n\n  #buildSelectFor(conditionFilter) {\n    const selectEl = this.#convertFormFieldTo(\"select\", this.valueInputEl);\n\n    for (const option of conditionFilter.options) {\n      const optionEl = document.createElement(\"option\");\n      optionEl.value = option[1];\n      optionEl.textContent = option[0];\n      selectEl.appendChild(optionEl);\n    }\n\n    this.valueInputEl.replaceWith(selectEl);\n  }\n\n  #buildTextInputFor(conditionFilter) {\n    const textInput = this.#convertFormFieldTo(\"input\", this.valueInputEl);\n    textInput.placeholder = \"Enter a value\";\n    textInput.type = conditionFilter.type; // \"text\" || \"number\"\n    if (conditionFilter.type === \"number\") {\n      textInput.step = conditionFilter.number_step;\n    }\n\n    this.valueInputEl.replaceWith(textInput);\n  }\n\n  #convertFormFieldTo(type, el) {\n    const priorClasses = el.classList;\n    const priorId = el.id;\n    const priorName = el.name;\n\n    const newFormField = document.createElement(type);\n    newFormField.classList.add(...priorClasses);\n    newFormField.id = priorId;\n    newFormField.name = priorName;\n\n    return newFormField;\n  }\n\n  #uniqueKey() {\n    return Date.now();\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/rules_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"rules\"\nexport default class extends Controller {\n  static targets = [\n    \"conditionTemplate\",\n    \"conditionGroupTemplate\",\n    \"actionTemplate\",\n    \"conditionsList\",\n    \"actionsList\",\n    \"effectiveDateInput\",\n  ];\n\n  connect() {\n    // Update condition prefixes on first connection (form render on edit)\n    this.updateConditionPrefixes();\n  }\n\n  addConditionGroup() {\n    this.#appendTemplate(\n      this.conditionGroupTemplateTarget,\n      this.conditionsListTarget,\n    );\n    this.updateConditionPrefixes();\n  }\n\n  addCondition() {\n    this.#appendTemplate(\n      this.conditionTemplateTarget,\n      this.conditionsListTarget,\n    );\n    this.updateConditionPrefixes();\n  }\n\n  addAction() {\n    this.#appendTemplate(this.actionTemplateTarget, this.actionsListTarget);\n  }\n\n  clearEffectiveDate() {\n    this.effectiveDateInputTarget.value = \"\";\n  }\n\n  #appendTemplate(templateEl, listEl) {\n    const html = templateEl.innerHTML.replaceAll(\n      \"IDX_PLACEHOLDER\",\n      this.#uniqueKey(),\n    );\n\n    listEl.insertAdjacentHTML(\"beforeend\", html);\n  }\n\n  #uniqueKey() {\n    return Date.now();\n  }\n\n  // Updates the prefix visibility of all conditions and condition groups\n  // This is also called by the rule/conditions_controller when a subcondition is removed\n  updateConditionPrefixes() {\n    const conditions = Array.from(this.conditionsListTarget.children);\n    let conditionIndex = 0;\n\n    conditions.forEach((condition) => {\n      // Only process visible conditions, this prevents conditions that are marked for removal and hidden\n      // from being added to the index. This is important when editing a rule.\n      if (!condition.classList.contains('hidden')) {\n        const prefixEl = condition.querySelector('[data-condition-prefix]');\n        if (prefixEl) {\n          if (conditionIndex === 0) {\n            prefixEl.classList.add('hidden');\n          } else {\n            prefixEl.classList.remove('hidden');\n          }\n          conditionIndex++;\n        }\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/sankey_chart_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\nimport * as d3 from \"d3\";\nimport { sankey, sankeyLinkHorizontal } from \"d3-sankey\";\n\n// Connects to data-controller=\"sankey-chart\"\nexport default class extends Controller {\n  static values = {\n    data: Object,\n    nodeWidth: { type: Number, default: 15 },\n    nodePadding: { type: Number, default: 20 },\n    currencySymbol: { type: String, default: \"$\" }\n  };\n\n  connect() {\n    this.resizeObserver = new ResizeObserver(() => this.#draw());\n    this.resizeObserver.observe(this.element);\n    this.#draw();\n  }\n\n  disconnect() {\n    this.resizeObserver?.disconnect();\n  }\n\n  #draw() {\n    const { nodes = [], links = [] } = this.dataValue || {};\n\n    if (!nodes.length || !links.length) return;\n\n    // Clear previous SVG\n    d3.select(this.element).selectAll(\"svg\").remove();\n\n    const width = this.element.clientWidth || 600;\n    const height = this.element.clientHeight || 400;\n\n    const svg = d3\n      .select(this.element)\n      .append(\"svg\")\n      .attr(\"width\", width)\n      .attr(\"height\", height);\n\n    const sankeyGenerator = sankey()\n      .nodeWidth(this.nodeWidthValue)\n      .nodePadding(this.nodePaddingValue)\n      .extent([\n        [16, 16],\n        [width - 16, height - 16],\n      ]);\n\n    const sankeyData = sankeyGenerator({\n      nodes: nodes.map((d) => Object.assign({}, d)),\n      links: links.map((d) => Object.assign({}, d)),\n    });\n\n    // Define gradients for links\n    const defs = svg.append(\"defs\");\n\n    sankeyData.links.forEach((link, i) => {\n      const gradientId = `link-gradient-${link.source.index}-${link.target.index}-${i}`;\n\n      const getStopColorWithOpacity = (nodeColorInput, opacity = 0.1) => {\n        let colorStr = nodeColorInput || \"var(--color-gray-400)\";\n        if (colorStr === \"var(--color-success)\") {\n          colorStr = \"#10A861\"; // Hex for --color-green-600\n        }\n        // Add other CSS var to hex mappings here if needed\n\n        if (colorStr.startsWith(\"var(--\")) { // Unmapped CSS var, use as is (likely solid)\n          return colorStr;\n        }\n\n        const d3Color = d3.color(colorStr);\n        return d3Color ? d3Color.copy({ opacity: opacity }) : \"var(--color-gray-400)\";\n      };\n\n      const sourceStopColor = getStopColorWithOpacity(link.source.color);\n      const targetStopColor = getStopColorWithOpacity(link.target.color);\n\n      const gradient = defs.append(\"linearGradient\")\n        .attr(\"id\", gradientId)\n        .attr(\"gradientUnits\", \"userSpaceOnUse\")\n        .attr(\"x1\", link.source.x1)\n        .attr(\"x2\", link.target.x0);\n\n      gradient.append(\"stop\")\n        .attr(\"offset\", \"0%\")\n        .attr(\"stop-color\", sourceStopColor);\n\n      gradient.append(\"stop\")\n        .attr(\"offset\", \"100%\")\n        .attr(\"stop-color\", targetStopColor);\n    });\n\n    // Draw links\n    svg\n      .append(\"g\")\n      .attr(\"fill\", \"none\")\n      .selectAll(\"path\")\n      .data(sankeyData.links)\n      .join(\"path\")\n      .attr(\"d\", (d) => {\n        const sourceX = d.source.x1;\n        const targetX = d.target.x0;\n        const path = d3.linkHorizontal()({\n          source: [sourceX, d.y0],\n          target: [targetX, d.y1]\n        });\n        return path;\n      })\n      .attr(\"stroke\", (d, i) => `url(#link-gradient-${d.source.index}-${d.target.index}-${i})`)\n      .attr(\"stroke-width\", (d) => Math.max(1, d.width))\n      .append(\"title\")\n      .text((d) => `${nodes[d.source.index].name} → ${nodes[d.target.index].name}: ${this.currencySymbolValue}${Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} (${d.percentage}%)`);\n\n    // Draw nodes\n    const node = svg\n      .append(\"g\")\n      .selectAll(\"g\")\n      .data(sankeyData.nodes)\n      .join(\"g\");\n\n    const cornerRadius = 8;\n\n    node.append(\"path\")\n      .attr(\"d\", (d) => {\n        const x0 = d.x0;\n        const y0 = d.y0;\n        const x1 = d.x1;\n        const y1 = d.y1;\n        const h = y1 - y0;\n        // const w = x1 - x0; // Not directly used in path string, but good for context\n\n        // Dynamic corner radius based on node height, maxed at 8\n        const effectiveCornerRadius = Math.max(0, Math.min(cornerRadius, h / 2));\n\n        const isSourceNode = d.sourceLinks && d.sourceLinks.length > 0 && (!d.targetLinks || d.targetLinks.length === 0);\n        const isTargetNode = d.targetLinks && d.targetLinks.length > 0 && (!d.sourceLinks || d.sourceLinks.length === 0);\n\n        if (isSourceNode) { // Round left corners, flat right for \"Total Income\"\n          if (h < effectiveCornerRadius * 2) {\n            return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;\n          }\n          return `M ${x0 + effectiveCornerRadius},${y0}\n                  L ${x1},${y0}\n                  L ${x1},${y1}\n                  L ${x0 + effectiveCornerRadius},${y1}\n                  Q ${x0},${y1} ${x0},${y1 - effectiveCornerRadius}\n                  L ${x0},${y0 + effectiveCornerRadius}\n                  Q ${x0},${y0} ${x0 + effectiveCornerRadius},${y0} Z`;\n        }\n\n        if (isTargetNode) { // Flat left corners, round right for Categories/Surplus\n          if (h < effectiveCornerRadius * 2) {\n            return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;\n          }\n          return `M ${x0},${y0}\n                  L ${x1 - effectiveCornerRadius},${y0}\n                  Q ${x1},${y0} ${x1},${y0 + effectiveCornerRadius}\n                  L ${x1},${y1 - effectiveCornerRadius}\n                  Q ${x1},${y1} ${x1 - effectiveCornerRadius},${y1}\n                  L ${x0},${y1} Z`;\n        }\n\n        // Fallback for intermediate nodes (e.g., \"Cash Flow\") - draw as a simple sharp-cornered rectangle\n        return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;\n      })\n      .attr(\"fill\", (d) => d.color || \"var(--color-gray-400)\")\n      .attr(\"stroke\", (d) => {\n        // If a node has an explicit color assigned (even if it's a gray variable),\n        // it gets no stroke. Only truly un-colored nodes (falling back to default fill)\n        // would get a stroke, but our current data structure assigns colors to all nodes.\n        if (d.color) {\n          return \"none\";\n        }\n        return \"var(--color-gray-500)\"; // Fallback, likely unused with current data\n      });\n\n    const stimulusControllerInstance = this;\n    node\n      .append(\"text\")\n      .attr(\"x\", (d) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6))\n      .attr(\"y\", (d) => (d.y1 + d.y0) / 2)\n      .attr(\"dy\", \"-0.2em\")\n      .attr(\"text-anchor\", (d) => (d.x0 < width / 2 ? \"start\" : \"end\"))\n      .attr(\"class\", \"text-xs font-medium text-primary fill-current\")\n      .each(function (d) {\n        const textElement = d3.select(this);\n        textElement.selectAll(\"tspan\").remove();\n\n        // Node Name on the first line\n        textElement.append(\"tspan\")\n          .text(d.name);\n\n        // Financial details on the second line\n        const financialDetailsTspan = textElement.append(\"tspan\")\n          .attr(\"x\", textElement.attr(\"x\"))\n          .attr(\"dy\", \"1.2em\")\n          .attr(\"class\", \"font-mono text-secondary\")\n          .style(\"font-size\", \"0.65rem\"); // Explicitly set smaller font size\n\n        financialDetailsTspan.append(\"tspan\")\n          .text(stimulusControllerInstance.currencySymbolValue + Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }));\n      });\n  }\n} "
  },
  {
    "path": "app/javascript/controllers/scroll_on_connect_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static values = {\n    selector: { type: String, default: \"[aria-current=\\\"page\\\"]\" },\n    delay: { type: Number, default: 500 }\n  }\n\n  connect() {\n    setTimeout(() => {\n      this.scrollToActiveItem()\n    }, this.delayValue)\n  }\n\n  scrollToActiveItem() {\n    const activeItem = this.element?.querySelector(this.selectorValue)\n\n\n    if (!activeItem) return\n\n    const scrollContainer = this.element\n    const containerRect = scrollContainer.getBoundingClientRect()\n    const activeItemRect = activeItem.getBoundingClientRect()\n\n    const scrollPositionX = (activeItemRect.left + scrollContainer.scrollLeft) -\n                          (containerRect.width / 2) +\n                          (activeItemRect.width / 2)\n\n    const scrollPositionY = (activeItemRect.top + scrollContainer.scrollTop) -\n                          (containerRect.height / 2) +\n                          (activeItemRect.height / 2)\n\n\n    // Smooth scroll to position\n    scrollContainer.scrollTo({\n      top: Math.max(0, scrollPositionY),\n      left: Math.max(0, scrollPositionX),\n      behavior: 'smooth'\n    })\n  }\n}"
  },
  {
    "path": "app/javascript/controllers/selectable_link_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"selectable-link\"\nexport default class extends Controller {\n  connect() {\n    this.element.addEventListener(\"change\", this.handleChange.bind(this));\n  }\n\n  disconnect() {\n    this.element.removeEventListener(\"change\", this.handleChange.bind(this));\n  }\n\n  handleChange(event) {\n    const paramName = this.element.name;\n    const currentUrl = new URL(window.location.href);\n    currentUrl.searchParams.set(paramName, event.target.value);\n\n    Turbo.visit(currentUrl.toString());\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/theme_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n  static values = { userPreference: String };\n\n  connect() {\n    this.startSystemThemeListener();\n  }\n\n  disconnect() {\n    this.stopSystemThemeListener();\n  }\n\n  // Called automatically by Stimulus when the userPreferenceValue changes (e.g., after form submit/page reload)\n  userPreferenceValueChanged() {\n    this.applyTheme();\n  }\n\n  // Called when a theme radio button is clicked\n  updateTheme(event) {\n    const selectedTheme = event.currentTarget.value;\n    if (selectedTheme === \"system\") {\n      this.setTheme(this.systemPrefersDark());\n    } else if (selectedTheme === \"dark\") {\n      this.setTheme(true);\n    } else {\n      this.setTheme(false);\n    }\n  }\n\n  // Applies theme based on the userPreferenceValue (from server)\n  applyTheme() {\n    if (this.userPreferenceValue === \"system\") {\n      this.setTheme(this.systemPrefersDark());\n    } else if (this.userPreferenceValue === \"dark\") {\n      this.setTheme(true);\n    } else {\n      this.setTheme(false);\n    }\n  }\n\n  // Sets or removes the data-theme attribute\n  setTheme(isDark) {\n    if (isDark) {\n      document.documentElement.setAttribute(\"data-theme\", \"dark\");\n    } else {\n      document.documentElement.setAttribute(\"data-theme\", \"light\");\n    }\n  }\n\n  systemPrefersDark() {\n    return window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n  }\n\n  handleSystemThemeChange = (event) => {\n    // Only apply system theme changes if the user preference is currently 'system'\n    if (this.userPreferenceValue === \"system\") {\n      this.setTheme(event.matches);\n    }\n  };\n\n  toggle() {\n    const currentTheme = document.documentElement.getAttribute(\"data-theme\");\n    if (currentTheme === \"dark\") {\n      this.setTheme(false);\n    } else {\n      this.setTheme(true);\n    }\n  }\n\n  startSystemThemeListener() {\n    this.darkMediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    this.darkMediaQuery.addEventListener(\n      \"change\",\n      this.handleSystemThemeChange,\n    );\n  }\n\n  stopSystemThemeListener() {\n    if (this.darkMediaQuery) {\n      this.darkMediaQuery.removeEventListener(\n        \"change\",\n        this.handleSystemThemeChange,\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/time_series_chart_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\nimport * as d3 from \"d3\";\n\nconst parseLocalDate = d3.timeParse(\"%Y-%m-%d\");\n\nexport default class extends Controller {\n  static values = {\n    data: Object,\n    strokeWidth: { type: Number, default: 2 },\n    useLabels: { type: Boolean, default: true },\n    useTooltip: { type: Boolean, default: true },\n  };\n\n  _d3SvgMemo = null;\n  _d3GroupMemo = null;\n  _d3Tooltip = null;\n  _d3InitialContainerWidth = 0;\n  _d3InitialContainerHeight = 0;\n  _normalDataPoints = [];\n  _resizeObserver = null;\n\n  connect() {\n    this._install();\n    document.addEventListener(\"turbo:load\", this._reinstall);\n    this._setupResizeObserver();\n  }\n\n  disconnect() {\n    this._teardown();\n    document.removeEventListener(\"turbo:load\", this._reinstall);\n    this._resizeObserver?.disconnect();\n  }\n\n  _reinstall = () => {\n    this._teardown();\n    this._install();\n  };\n\n  _teardown() {\n    this._d3SvgMemo = null;\n    this._d3GroupMemo = null;\n    this._d3Tooltip = null;\n    this._normalDataPoints = [];\n\n    this._d3Container.selectAll(\"*\").remove();\n  }\n\n  _install() {\n    this._normalizeDataPoints();\n    this._rememberInitialContainerSize();\n    this._draw();\n  }\n\n  _normalizeDataPoints() {\n    this._normalDataPoints = (this.dataValue.values || []).map((d) => ({\n      date: parseLocalDate(d.date),\n      date_formatted: d.date_formatted,\n      value: d.value,\n      trend: d.trend,\n    }));\n  }\n\n  _rememberInitialContainerSize() {\n    this._d3InitialContainerWidth = this._d3Container.node().clientWidth;\n    this._d3InitialContainerHeight = this._d3Container.node().clientHeight;\n  }\n\n  _draw() {\n    if (this._normalDataPoints.length < 2) {\n      this._drawEmpty();\n    } else {\n      this._drawChart();\n    }\n  }\n\n  _drawEmpty() {\n    this._d3Svg.selectAll(\".tick\").remove();\n    this._d3Svg.selectAll(\".domain\").remove();\n\n    this._drawDashedLineEmptyState();\n    this._drawCenteredCircleEmptyState();\n  }\n\n  _drawDashedLineEmptyState() {\n    this._d3Svg\n      .append(\"line\")\n      .attr(\"x1\", this._d3InitialContainerWidth / 2)\n      .attr(\"y1\", 0)\n      .attr(\"x2\", this._d3InitialContainerWidth / 2)\n      .attr(\"y2\", this._d3InitialContainerHeight)\n      .attr(\"stroke\", \"var(--color-gray-300)\")\n      .attr(\"stroke-dasharray\", \"4, 4\");\n  }\n\n  _drawCenteredCircleEmptyState() {\n    this._d3Svg\n      .append(\"circle\")\n      .attr(\"cx\", this._d3InitialContainerWidth / 2)\n      .attr(\"cy\", this._d3InitialContainerHeight / 2)\n      .attr(\"r\", 4)\n      .attr(\"class\", \"fg-subdued\")\n      .style(\"fill\", \"currentColor\");\n  }\n\n  _drawChart() {\n    this._drawTrendline();\n\n    if (this.useLabelsValue) {\n      this._drawXAxisLabels();\n      this._drawGradientBelowTrendline();\n    }\n\n    if (this.useTooltipValue) {\n      this._drawTooltip();\n      this._trackMouseForShowingTooltip();\n    }\n  }\n\n  _drawTrendline() {\n    this._installTrendlineSplit();\n\n    this._d3Group\n      .append(\"path\")\n      .datum(this._normalDataPoints)\n      .attr(\"fill\", \"none\")\n      .attr(\"stroke\", `url(#${this.element.id}-split-gradient)`)\n      .attr(\"d\", this._d3Line)\n      .attr(\"stroke-linejoin\", \"round\")\n      .attr(\"stroke-linecap\", \"round\")\n      .attr(\"stroke-width\", this.strokeWidthValue);\n  }\n\n  _installTrendlineSplit() {\n    const gradient = this._d3Svg\n      .append(\"defs\")\n      .append(\"linearGradient\")\n      .attr(\"id\", `${this.element.id}-split-gradient`)\n      .attr(\"gradientUnits\", \"userSpaceOnUse\")\n      .attr(\"x1\", this._d3XScale.range()[0])\n      .attr(\"x2\", this._d3XScale.range()[1]);\n\n    // First stop - solid trend color\n    gradient\n      .append(\"stop\")\n      .attr(\"class\", \"start-color\")\n      .attr(\"offset\", \"0%\")\n      .attr(\"stop-color\", this.dataValue.trend.color);\n\n    // Second stop - trend color right before split\n    gradient\n      .append(\"stop\")\n      .attr(\"class\", \"split-before\")\n      .attr(\"offset\", \"100%\")\n      .attr(\"stop-color\", this.dataValue.trend.color);\n\n    // Third stop - gray color right after split\n    gradient\n      .append(\"stop\")\n      .attr(\"class\", \"split-after\")\n      .attr(\"offset\", \"100%\")\n      .attr(\"stop-color\", \"var(--color-gray-400)\");\n\n    // Fourth stop - solid gray to end\n    gradient\n      .append(\"stop\")\n      .attr(\"class\", \"end-color\")\n      .attr(\"offset\", \"100%\")\n      .attr(\"stop-color\", \"var(--color-gray-400)\");\n  }\n\n  _setTrendlineSplitAt(percent) {\n    const position = percent * 100;\n\n    // Update both stops at the split point\n    this._d3Svg\n      .select(`#${this.element.id}-split-gradient`)\n      .select(\".split-before\")\n      .attr(\"offset\", `${position}%`);\n\n    this._d3Svg\n      .select(`#${this.element.id}-split-gradient`)\n      .select(\".split-after\")\n      .attr(\"offset\", `${position}%`);\n\n    this._d3Svg\n      .select(`#${this.element.id}-trendline-gradient-rect`)\n      .attr(\"width\", this._d3ContainerWidth * percent);\n  }\n\n  _drawXAxisLabels() {\n    // Add ticks\n    this._d3Group\n      .append(\"g\")\n      .attr(\"transform\", `translate(0,${this._d3ContainerHeight})`)\n      .call(\n        d3\n          .axisBottom(this._d3XScale)\n          .tickValues([\n            this._normalDataPoints[0].date,\n            this._normalDataPoints[this._normalDataPoints.length - 1].date,\n          ])\n          .tickSize(0)\n          .tickFormat(d3.timeFormat(\"%b %d, %Y\")),\n      )\n      .select(\".domain\")\n      .remove();\n\n    // Style ticks\n    this._d3Group\n      .selectAll(\".tick text\")\n      .attr(\"class\", \"fg-gray\")\n      .style(\"font-size\", \"12px\")\n      .style(\"font-weight\", \"500\")\n      .attr(\"text-anchor\", \"middle\")\n      .attr(\"dx\", (_d, i) => {\n        // We know we only have 2 values\n        return i === 0 ? \"5em\" : \"-5em\";\n      })\n      .attr(\"dy\", \"0em\");\n  }\n\n  _drawGradientBelowTrendline() {\n    // Define gradient\n    const gradient = this._d3Group\n      .append(\"defs\")\n      .append(\"linearGradient\")\n      .attr(\"id\", `${this.element.id}-trendline-gradient`)\n      .attr(\"gradientUnits\", \"userSpaceOnUse\")\n      .attr(\"x1\", 0)\n      .attr(\"x2\", 0)\n      .attr(\n        \"y1\",\n        this._d3YScale(d3.max(this._normalDataPoints, this._getDatumValue)),\n      )\n      .attr(\"y2\", this._d3ContainerHeight);\n\n    gradient\n      .append(\"stop\")\n      .attr(\"offset\", 0)\n      .attr(\"stop-color\", this._trendColor)\n      .attr(\"stop-opacity\", 0.06);\n\n    gradient\n      .append(\"stop\")\n      .attr(\"offset\", 0.5)\n      .attr(\"stop-color\", this._trendColor)\n      .attr(\"stop-opacity\", 0);\n\n    // Clip path makes gradient start at the trendline\n    this._d3Group\n      .append(\"clipPath\")\n      .attr(\"id\", `${this.element.id}-clip-below-trendline`)\n      .append(\"path\")\n      .datum(this._normalDataPoints)\n      .attr(\n        \"d\",\n        d3\n          .area()\n          .x((d) => this._d3XScale(d.date))\n          .y0(this._d3ContainerHeight)\n          .y1((d) => this._d3YScale(this._getDatumValue(d))),\n      );\n\n    // Apply the gradient + clip path\n    this._d3Group\n      .append(\"rect\")\n      .attr(\"id\", `${this.element.id}-trendline-gradient-rect`)\n      .attr(\"width\", this._d3ContainerWidth)\n      .attr(\"height\", this._d3ContainerHeight)\n      .attr(\"clip-path\", `url(#${this.element.id}-clip-below-trendline)`)\n      .style(\"fill\", `url(#${this.element.id}-trendline-gradient)`);\n  }\n\n  _drawTooltip() {\n    this._d3Tooltip = d3\n      .select(`#${this.element.id}`)\n      .append(\"div\")\n      .attr(\n        \"class\",\n        \"bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0\",\n      );\n  }\n\n  _trackMouseForShowingTooltip() {\n    const bisectDate = d3.bisector((d) => d.date).left;\n\n    this._d3Group\n      .append(\"rect\")\n      .attr(\"class\", \"bg-container\")\n      .attr(\"width\", this._d3ContainerWidth)\n      .attr(\"height\", this._d3ContainerHeight)\n      .attr(\"fill\", \"none\")\n      .attr(\"pointer-events\", \"all\")\n      .on(\"mousemove\", (event) => {\n        const estimatedTooltipWidth = 250;\n        const pageWidth = document.body.clientWidth;\n        const tooltipX = event.pageX + 10;\n        const overflowX = tooltipX + estimatedTooltipWidth - pageWidth;\n        const adjustedX =\n          overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX;\n\n        const [xPos] = d3.pointer(event);\n        const x0 = bisectDate(\n          this._normalDataPoints,\n          this._d3XScale.invert(xPos),\n          1,\n        );\n        const d0 = this._normalDataPoints[x0 - 1];\n        const d1 = this._normalDataPoints[x0];\n        const d =\n          xPos - this._d3XScale(d0.date) > this._d3XScale(d1.date) - xPos\n            ? d1\n            : d0;\n        const xPercent = this._d3XScale(d.date) / this._d3ContainerWidth;\n\n        this._setTrendlineSplitAt(xPercent);\n\n        // Reset\n        this._d3Group.selectAll(\".data-point-circle\").remove();\n        this._d3Group.selectAll(\".guideline\").remove();\n\n        // Guideline\n        this._d3Group\n          .append(\"line\")\n          .attr(\"class\", \"guideline fg-subdued\")\n          .attr(\"x1\", this._d3XScale(d.date))\n          .attr(\"y1\", 0)\n          .attr(\"x2\", this._d3XScale(d.date))\n          .attr(\"y2\", this._d3ContainerHeight)\n          .attr(\"stroke\", \"currentColor\")\n          .attr(\"stroke-dasharray\", \"4, 4\");\n\n        // Big circle\n        this._d3Group\n          .append(\"circle\")\n          .attr(\"class\", \"data-point-circle\")\n          .attr(\"cx\", this._d3XScale(d.date))\n          .attr(\"cy\", this._d3YScale(this._getDatumValue(d)))\n          .attr(\"r\", 10)\n          .attr(\"fill\", this._trendColor)\n          .attr(\"fill-opacity\", \"0.1\")\n          .attr(\"pointer-events\", \"none\");\n\n        // Small circle\n        this._d3Group\n          .append(\"circle\")\n          .attr(\"class\", \"data-point-circle\")\n          .attr(\"cx\", this._d3XScale(d.date))\n          .attr(\"cy\", this._d3YScale(this._getDatumValue(d)))\n          .attr(\"r\", 5)\n          .attr(\"fill\", this._trendColor)\n          .attr(\"pointer-events\", \"none\");\n\n        // Render tooltip\n        this._d3Tooltip\n          .html(this._tooltipTemplate(d))\n          .style(\"opacity\", 1)\n          .style(\"z-index\", 999)\n          .style(\"left\", `${adjustedX}px`)\n          .style(\"top\", `${event.pageY - 10}px`);\n      })\n      .on(\"mouseout\", (event) => {\n        const hoveringOnGuideline =\n          event.toElement?.classList.contains(\"guideline\");\n\n        if (!hoveringOnGuideline) {\n          this._d3Group.selectAll(\".guideline\").remove();\n          this._d3Group.selectAll(\".data-point-circle\").remove();\n          this._d3Tooltip.style(\"opacity\", 0);\n          this._setTrendlineSplitAt(1);\n        }\n      });\n  }\n\n  _tooltipTemplate(datum) {\n    return `\n      <div style=\"margin-bottom: 4px; color: var(--color-gray-500);\">\n        ${datum.date_formatted}\n      </div>\n      <div class=\"flex items-center gap-4\">\n        <div class=\"flex items-center gap-2 text-primary\">\n          <div class=\"flex items-center justify-center h-4 w-4\">\n            ${this._getTrendIcon(datum)}\n          </div>\n          ${this._extractFormattedValue(datum.trend.current)}\n        </div>\n\n        ${\n          datum.trend.value === 0\n            ? `<span class=\"w-20\"></span>`\n            : `\n          <span style=\"color: ${datum.trend.color};\">\n            ${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted})\n          </span>\n        `\n        }\n      </div>\n    `;\n  }\n\n  _getTrendIcon(datum) {\n    const isIncrease =\n      Number(datum.trend.previous.amount) < Number(datum.trend.current.amount);\n    const isDecrease =\n      Number(datum.trend.previous.amount) > Number(datum.trend.current.amount);\n\n    if (isIncrease) {\n      return `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"${datum.trend.color}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-arrow-up-right-icon lucide-arrow-up-right\"><path d=\"M7 7h10v10\"/><path d=\"M7 17 17 7\"/></svg>`;\n    }\n\n    if (isDecrease) {\n      return `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"${datum.trend.color}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-arrow-down-right-icon lucide-arrow-down-right\"><path d=\"m7 7 10 10\"/><path d=\"M17 7v10H7\"/></svg>`;\n    }\n\n    return `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"${datum.trend.color}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-minus-icon lucide-minus\"><path d=\"M5 12h14\"/></svg>`;\n  }\n\n  _getDatumValue = (datum) => {\n    return this._extractNumericValue(datum.value);\n  };\n\n  _extractNumericValue = (numeric) => {\n    if (typeof numeric === \"object\" && \"amount\" in numeric) {\n      return Number(numeric.amount);\n    }\n    return Number(numeric);\n  };\n\n  _extractFormattedValue = (numeric) => {\n    if (typeof numeric === \"object\" && \"formatted\" in numeric) {\n      return numeric.formatted;\n    }\n    return numeric;\n  };\n\n  _createMainSvg() {\n    return this._d3Container\n      .append(\"svg\")\n      .attr(\"width\", this._d3InitialContainerWidth)\n      .attr(\"height\", this._d3InitialContainerHeight)\n      .attr(\"viewBox\", [\n        0,\n        0,\n        this._d3InitialContainerWidth,\n        this._d3InitialContainerHeight,\n      ]);\n  }\n\n  _createMainGroup() {\n    return this._d3Svg\n      .append(\"g\")\n      .attr(\"transform\", `translate(${this._margin.left},${this._margin.top})`);\n  }\n\n  get _d3Svg() {\n    if (!this._d3SvgMemo) {\n      this._d3SvgMemo = this._createMainSvg();\n    }\n    return this._d3SvgMemo;\n  }\n\n  get _d3Group() {\n    if (!this._d3GroupMemo) {\n      this._d3GroupMemo = this._createMainGroup();\n    }\n    return this._d3GroupMemo;\n  }\n\n  get _margin() {\n    if (this.useLabelsValue) {\n      return { top: 20, right: 0, bottom: 10, left: 0 };\n    }\n    return { top: 0, right: 0, bottom: 0, left: 0 };\n  }\n\n  get _d3ContainerWidth() {\n    return (\n      this._d3InitialContainerWidth - this._margin.left - this._margin.right\n    );\n  }\n\n  get _d3ContainerHeight() {\n    return (\n      this._d3InitialContainerHeight - this._margin.top - this._margin.bottom\n    );\n  }\n\n  get _d3Container() {\n    return d3.select(this.element);\n  }\n\n  get _trendColor() {\n    return this.dataValue.trend.color;\n  }\n\n  get _d3Line() {\n    return d3\n      .line()\n      .x((d) => this._d3XScale(d.date))\n      .y((d) => this._d3YScale(this._getDatumValue(d)));\n  }\n\n  get _d3XScale() {\n    return d3\n      .scaleTime()\n      .rangeRound([0, this._d3ContainerWidth])\n      .domain(d3.extent(this._normalDataPoints, (d) => d.date));\n  }\n\n  get _d3YScale() {\n    const dataMin = d3.min(this._normalDataPoints, this._getDatumValue);\n    const dataMax = d3.max(this._normalDataPoints, this._getDatumValue);\n\n    // Handle edge case where all values are the same\n    if (dataMin === dataMax) {\n      const padding = dataMax === 0 ? 100 : Math.abs(dataMax) * 0.5;\n      return d3\n        .scaleLinear()\n        .rangeRound([this._d3ContainerHeight, 0])\n        .domain([dataMin - padding, dataMax + padding]);\n    }\n\n    const dataRange = dataMax - dataMin;\n    const avgValue = (dataMax + dataMin) / 2;\n\n    // Calculate relative change as a percentage\n    const relativeChange = avgValue !== 0 ? dataRange / Math.abs(avgValue) : 1;\n\n    // Dynamic baseline calculation\n    let yMin;\n    let yMax;\n\n    // For small relative changes (< 10%), use a tighter scale\n    if (relativeChange < 0.1 && dataMin > 0) {\n      // Start axis at a percentage below the minimum, not at 0\n      const baselinePadding = dataRange * 2; // Show 2x the data range below min\n      yMin = Math.max(0, dataMin - baselinePadding);\n      yMax = dataMax + dataRange * 0.5; // Add 50% padding above\n    } else {\n      // For larger changes or when data crosses zero, use more context\n      // Always include 0 when data is negative or close to 0\n      if (dataMin < 0 || (dataMin >= 0 && dataMin < avgValue * 0.1)) {\n        yMin = Math.min(0, dataMin * 1.1);\n      } else {\n        // Otherwise use dynamic baseline\n        yMin = dataMin - dataRange * 0.3;\n      }\n      yMax = dataMax + dataRange * 0.1;\n    }\n\n    // Adjust padding for labels if needed\n    if (this.useLabelsValue) {\n      const extraPadding = (yMax - yMin) * 0.1;\n      yMin -= extraPadding;\n      yMax += extraPadding;\n    }\n\n    return d3\n      .scaleLinear()\n      .rangeRound([this._d3ContainerHeight, 0])\n      .domain([yMin, yMax]);\n  }\n\n  _setupResizeObserver() {\n    this._resizeObserver = new ResizeObserver(() => {\n      this._reinstall();\n    });\n    this._resizeObserver.observe(this.element);\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/tooltip_controller.js",
    "content": "import {\n  autoUpdate,\n  computePosition,\n  flip,\n  offset,\n  shift,\n} from \"@floating-ui/dom\";\nimport { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n  static targets = [\"tooltip\"];\n  static values = {\n    placement: { type: String, default: \"top\" },\n    offset: { type: Number, default: 10 },\n    crossAxis: { type: Number, default: 0 },\n    alignmentAxis: { type: Number, default: null },\n  };\n\n  connect() {\n    this._cleanup = null;\n    this.boundUpdate = this.update.bind(this);\n    this.startAutoUpdate();\n    this.addEventListeners();\n  }\n\n  disconnect() {\n    this.removeEventListeners();\n    this.stopAutoUpdate();\n  }\n\n  addEventListeners() {\n    this.element.addEventListener(\"mouseenter\", this.show);\n    this.element.addEventListener(\"mouseleave\", this.hide);\n  }\n\n  removeEventListeners() {\n    this.element.removeEventListener(\"mouseenter\", this.show);\n    this.element.removeEventListener(\"mouseleave\", this.hide);\n  }\n\n  show = () => {\n    this.tooltipTarget.style.display = \"block\";\n    this.update(); // Ensure immediate update when shown\n  };\n\n  hide = () => {\n    this.tooltipTarget.style.display = \"none\";\n  };\n\n  startAutoUpdate() {\n    if (!this._cleanup) {\n      this._cleanup = autoUpdate(\n        this.element,\n        this.tooltipTarget,\n        this.boundUpdate,\n      );\n    }\n  }\n\n  stopAutoUpdate() {\n    if (this._cleanup) {\n      this._cleanup();\n      this._cleanup = null;\n    }\n  }\n\n  update() {\n    // Update position even if not visible, to ensure correct positioning when shown\n    computePosition(this.element, this.tooltipTarget, {\n      placement: this.placementValue,\n      middleware: [\n        offset({\n          mainAxis: this.offsetValue,\n          crossAxis: this.crossAxisValue,\n          alignmentAxis: this.alignmentAxisValue,\n        }),\n        flip(),\n        shift({ padding: 5 }),\n      ],\n    }).then(({ x, y, placement, middlewareData }) => {\n      Object.assign(this.tooltipTarget.style, {\n        left: `${x}px`,\n        top: `${y}px`,\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/trade_form_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"trade-form\"\nexport default class extends Controller {\n  // Reloads the page with a new type without closing the modal\n  async changeType(event) {\n    const url = new URL(event.params.url, window.location.origin);\n    url.searchParams.set(event.params.key, event.target.value);\n    Turbo.visit(url, { frame: \"modal\" });\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/transfer_match_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"transfer-match\"\nexport default class extends Controller {\n  static targets = [\"newSelect\", \"existingSelect\"];\n\n  update(event) {\n    if (event.target.value === \"new\") {\n      this.newSelectTarget.classList.remove(\"hidden\");\n      this.existingSelectTarget.classList.add(\"hidden\");\n    } else {\n      this.newSelectTarget.classList.add(\"hidden\");\n      this.existingSelectTarget.classList.remove(\"hidden\");\n    }\n  }\n}\n"
  },
  {
    "path": "app/javascript/controllers/turbo_frame_timeout_controller.js",
    "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// Connects to data-controller=\"turbo-frame-timeout\"\nexport default class extends Controller {\n  static values = { timeout: { type: Number, default: 10000 } }\n\n  connect() {\n    this.timeoutId = setTimeout(() => {\n      this.handleTimeout()\n    }, this.timeoutValue)\n\n    // Listen for successful frame loads to clear timeout\n    this.element.addEventListener(\"turbo:frame-load\", this.clearTimeout.bind(this))\n  }\n\n  disconnect() {\n    this.clearTimeout()\n  }\n\n  clearTimeout() {\n    if (this.timeoutId) {\n      clearTimeout(this.timeoutId)\n      this.timeoutId = null\n    }\n  }\n\n  handleTimeout() {\n    // Replace loading content with error state\n    this.element.innerHTML = `\n      <div class=\"flex items-center justify-end gap-1\">\n        <div class=\"w-8 h-4 flex items-center justify-center\">\n          <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"text-warning\">\n            <path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z\"/>\n            <path d=\"M12 9v4\"/>\n            <path d=\"m12 17 .01 0\"/>\n          </svg>\n        </div>\n        <p class=\"font-mono text-right text-xs text-warning\">Timeout</p>\n      </div>\n    `\n  }\n} "
  },
  {
    "path": "app/javascript/services/currencies_service.js",
    "content": "export class CurrenciesService {\n  get(id) {\n    return fetch(`/currencies/${id}.json`).then((response) => response.json());\n  }\n}\n"
  },
  {
    "path": "app/javascript/shims/d3-array-default.js",
    "content": "import * as d3Array from \"d3-array-src\";\nexport * from \"d3-array-src\";\nexport default d3Array; "
  },
  {
    "path": "app/javascript/shims/d3-shape-default.js",
    "content": "import * as d3Shape from \"d3-shape-src\";\nexport * from \"d3-shape-src\";\nexport default d3Shape; "
  },
  {
    "path": "app/jobs/application_job.rb",
    "content": "class ApplicationJob < ActiveJob::Base\n  retry_on ActiveRecord::Deadlocked\n  discard_on ActiveJob::DeserializationError\n  queue_as :low_priority # default queue\nend\n"
  },
  {
    "path": "app/jobs/assistant_response_job.rb",
    "content": "class AssistantResponseJob < ApplicationJob\n  queue_as :high_priority\n\n  def perform(message)\n    message.request_response\n  end\nend\n"
  },
  {
    "path": "app/jobs/auto_categorize_job.rb",
    "content": "class AutoCategorizeJob < ApplicationJob\n  queue_as :medium_priority\n\n  def perform(family, transaction_ids: [])\n    family.auto_categorize_transactions(transaction_ids)\n  end\nend\n"
  },
  {
    "path": "app/jobs/auto_detect_merchants_job.rb",
    "content": "class AutoDetectMerchantsJob < ApplicationJob\n  queue_as :medium_priority\n\n  def perform(family, transaction_ids: [])\n    family.auto_detect_transaction_merchants(transaction_ids)\n  end\nend\n"
  },
  {
    "path": "app/jobs/data_cache_clear_job.rb",
    "content": "class DataCacheClearJob < ApplicationJob\n  queue_as :low_priority\n\n  def perform(family)\n    ActiveRecord::Base.transaction do\n      ExchangeRate.delete_all\n      Security::Price.delete_all\n      family.accounts.each do |account|\n        account.balances.delete_all\n        account.holdings.delete_all\n      end\n\n      family.sync_later\n    end\n  end\nend\n"
  },
  {
    "path": "app/jobs/destroy_job.rb",
    "content": "class DestroyJob < ApplicationJob\n  queue_as :low_priority\n\n  def perform(model)\n    model.destroy\n  rescue => e\n    model.update!(scheduled_for_deletion: false) # Let's the user try again by resetting the state\n  end\nend\n"
  },
  {
    "path": "app/jobs/family_data_export_job.rb",
    "content": "class FamilyDataExportJob < ApplicationJob\n  queue_as :default\n\n  def perform(family_export)\n    family_export.update!(status: :processing)\n\n    exporter = Family::DataExporter.new(family_export.family)\n    zip_file = exporter.generate_export\n\n    family_export.export_file.attach(\n      io: zip_file,\n      filename: family_export.filename,\n      content_type: \"application/zip\"\n    )\n\n    family_export.update!(status: :completed)\n  rescue => e\n    Rails.logger.error \"Family export failed: #{e.message}\"\n    Rails.logger.error e.backtrace.join(\"\\n\")\n    family_export.update!(status: :failed)\n  end\nend\n"
  },
  {
    "path": "app/jobs/family_reset_job.rb",
    "content": "class FamilyResetJob < ApplicationJob\n  queue_as :low_priority\n\n  def perform(family)\n    # Delete all family data except users\n    ActiveRecord::Base.transaction do\n      # Delete accounts and related data\n      family.accounts.destroy_all\n      family.categories.destroy_all\n      family.tags.destroy_all\n      family.merchants.destroy_all\n      family.plaid_items.destroy_all\n      family.imports.destroy_all\n      family.budgets.destroy_all\n\n      family.sync_later\n    end\n  end\nend\n"
  },
  {
    "path": "app/jobs/import_job.rb",
    "content": "class ImportJob < ApplicationJob\n  queue_as :high_priority\n\n  def perform(import)\n    import.publish\n  end\nend\n"
  },
  {
    "path": "app/jobs/import_market_data_job.rb",
    "content": "# This job runs daily at market close.  See config/schedule.yml for details.\n#\n# The primary purpose of this job is to:\n# 1. Determine what exchange rate pairs, security prices, and other market data all of our users need to view historical account balance data\n# 2. For each needed rate/price, fetch from our data provider and upsert to our database\n#\n# Each individual account sync will still fetch any missing market data that isn't yet synced, but by running\n# this job daily, we significantly reduce overlapping account syncs that both need the same market data (e.g. common security like `AAPL`)\n#\nclass ImportMarketDataJob < ApplicationJob\n  queue_as :scheduled\n\n  def perform(opts)\n    return if Rails.env.development?\n\n    opts = opts.symbolize_keys\n    mode = opts.fetch(:mode, :full)\n    clear_cache = opts.fetch(:clear_cache, false)\n\n    MarketDataImporter.new(mode: mode, clear_cache: clear_cache).import_all\n  end\nend\n"
  },
  {
    "path": "app/jobs/revert_import_job.rb",
    "content": "class RevertImportJob < ApplicationJob\n  queue_as :medium_priority\n\n  def perform(import)\n    import.revert\n  end\nend\n"
  },
  {
    "path": "app/jobs/rule_job.rb",
    "content": "class RuleJob < ApplicationJob\n  queue_as :medium_priority\n\n  def perform(rule, ignore_attribute_locks: false)\n    rule.apply(ignore_attribute_locks: ignore_attribute_locks)\n  end\nend\n"
  },
  {
    "path": "app/jobs/security_health_check_job.rb",
    "content": "class SecurityHealthCheckJob < ApplicationJob\n  queue_as :scheduled\n\n  def perform\n    return if Rails.env.development?\n\n    Security::HealthChecker.check_all\n  end\nend\n"
  },
  {
    "path": "app/jobs/stripe_event_handler_job.rb",
    "content": "class StripeEventHandlerJob < ApplicationJob\n  queue_as :default\n\n  def perform(event_id)\n    stripe_provider = Provider::Registry.get_provider(:stripe)\n    Rails.logger.info \"Processing Stripe event: #{event_id}\"\n    stripe_provider.process_event(event_id)\n  end\nend\n"
  },
  {
    "path": "app/jobs/sync_cleaner_job.rb",
    "content": "class SyncCleanerJob < ApplicationJob\n  queue_as :scheduled\n\n  def perform\n    Sync.clean\n  end\nend\n"
  },
  {
    "path": "app/jobs/sync_job.rb",
    "content": "class SyncJob < ApplicationJob\n  queue_as :high_priority\n\n  def perform(sync)\n    sync.perform\n  end\nend\n"
  },
  {
    "path": "app/jobs/user_purge_job.rb",
    "content": "class UserPurgeJob < ApplicationJob\n  queue_as :low_priority\n\n  def perform(user)\n    user.purge\n  end\nend\n"
  },
  {
    "path": "app/mailers/application_mailer.rb",
    "content": "class ApplicationMailer < ActionMailer::Base\n  default from: email_address_with_name(ENV.fetch(\"EMAIL_SENDER\", \"sender@maybe.local\"), \"Maybe Finance\")\n  layout \"mailer\"\nend\n"
  },
  {
    "path": "app/mailers/email_confirmation_mailer.rb",
    "content": "class EmailConfirmationMailer < ApplicationMailer\n  # Subject can be set in your I18n file at config/locales/en.yml\n  # with the following lookup:\n  #\n  #   en.email_confirmation_mailer.confirmation_email.subject\n  #\n  def confirmation_email\n    @user = params[:user]\n    @subject = t(\".subject\")\n    @cta = t(\".cta\")\n    @confirmation_url = new_email_confirmation_url(token: @user.generate_token_for(:email_confirmation))\n\n    mail to: @user.unconfirmed_email, subject: @subject\n  end\nend\n"
  },
  {
    "path": "app/mailers/invitation_mailer.rb",
    "content": "class InvitationMailer < ApplicationMailer\n  def invite_email(invitation)\n    @invitation = invitation\n    @accept_url = accept_invitation_url(@invitation.token)\n\n    mail(\n      to: @invitation.email,\n      subject: t(\".subject\", inviter: @invitation.inviter.display_name)\n    )\n  end\nend\n"
  },
  {
    "path": "app/mailers/password_mailer.rb",
    "content": "class PasswordMailer < ApplicationMailer\n  def password_reset\n    @user = params[:user]\n    @subject = t(\".subject\")\n    @cta = t(\".cta\")\n\n    mail to: @user.email, subject: @subject\n  end\nend\n"
  },
  {
    "path": "app/models/account/activity_feed_data.rb",
    "content": "# Data used to build the paginated feed of account \"activity\" (events like transfers, deposits, withdrawals, etc.)\n# This data object is useful for avoiding N+1 queries and having an easy way to pass around the required data to the\n# activity feed component in controllers and background jobs that refresh it.\nclass Account::ActivityFeedData\n  ActivityDateData = Data.define(:date, :entries, :balance, :transfers)\n\n  attr_reader :account, :entries\n\n  def initialize(account, entries)\n    @account = account\n    @entries = entries.to_a\n  end\n\n  def entries_by_date\n    @entries_by_date_objects ||= begin\n      grouped_entries.map do |date, date_entries|\n        ActivityDateData.new(\n          date: date,\n          entries: date_entries,\n          balance: balance_for_date(date),\n          transfers: transfers_for_date(date)\n        )\n      end\n    end\n  end\n\n  private\n    def balance_for_date(date)\n      balances_by_date[date]\n    end\n\n    def transfers_for_date(date)\n      transfers_by_date[date] || []\n    end\n\n    def grouped_entries\n      @grouped_entries ||= entries.group_by(&:date)\n    end\n\n    def balances_by_date\n      @balances_by_date ||= begin\n        return {} if entries.empty?\n\n        dates = grouped_entries.keys\n        account.balances\n          .where(date: dates, currency: account.currency)\n          .index_by(&:date)\n      end\n    end\n\n    def transfers_by_date\n      @transfers_by_date ||= begin\n        return {} if transaction_ids.empty?\n\n        transfers = Transfer\n          .where(inflow_transaction_id: transaction_ids)\n          .or(Transfer.where(outflow_transaction_id: transaction_ids))\n          .to_a\n\n        # Group transfers by the date of their transaction entries\n        result = Hash.new { |h, k| h[k] = [] }\n\n        entries.each do |entry|\n          next unless entry.transaction? && transaction_ids.include?(entry.entryable_id)\n\n          transfers.each do |transfer|\n            if transfer.inflow_transaction_id == entry.entryable_id ||\n               transfer.outflow_transaction_id == entry.entryable_id\n              result[entry.date] << transfer\n            end\n          end\n        end\n\n        # Remove duplicates\n        result.transform_values(&:uniq)\n      end\n    end\n\n    def transaction_ids\n      @transaction_ids ||= entries\n        .select(&:transaction?)\n        .map(&:entryable_id)\n        .compact\n    end\nend\n"
  },
  {
    "path": "app/models/account/anchorable.rb",
    "content": "# All accounts are \"anchored\" with start/end valuation records, with transactions,\n# trades, and reconciliations between them.\nmodule Account::Anchorable\n  extend ActiveSupport::Concern\n\n  included do\n    include Monetizable\n\n    monetize :opening_balance\n  end\n\n  def set_opening_anchor_balance(**opts)\n    result = opening_balance_manager.set_opening_balance(**opts)\n    sync_later if result.success?\n    result\n  end\n\n  def opening_anchor_date\n    opening_balance_manager.opening_date\n  end\n\n  def opening_anchor_balance\n    opening_balance_manager.opening_balance\n  end\n\n  def has_opening_anchor?\n    opening_balance_manager.has_opening_anchor?\n  end\n\n  def set_current_balance(balance)\n    result = current_balance_manager.set_current_balance(balance)\n    sync_later if result.success?\n    result\n  end\n\n  def current_anchor_balance\n    current_balance_manager.current_balance\n  end\n\n  def current_anchor_date\n    current_balance_manager.current_date\n  end\n\n  def has_current_anchor?\n    current_balance_manager.has_current_anchor?\n  end\n\n  private\n    def opening_balance_manager\n      @opening_balance_manager ||= Account::OpeningBalanceManager.new(self)\n    end\n\n    def current_balance_manager\n      @current_balance_manager ||= Account::CurrentBalanceManager.new(self)\n    end\nend\n"
  },
  {
    "path": "app/models/account/chartable.rb",
    "content": "module Account::Chartable\n  extend ActiveSupport::Concern\n\n  def favorable_direction\n    classification == \"asset\" ? \"up\" : \"down\"\n  end\n\n  def balance_series(period: Period.last_30_days, view: :balance, interval: nil)\n    raise ArgumentError, \"Invalid view type\" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)\n\n    @balance_series ||= {}\n\n    memo_key = [ period.start_date, period.end_date, interval ].compact.join(\"_\")\n\n    builder = (@balance_series[memo_key] ||= Balance::ChartSeriesBuilder.new(\n      account_ids: [ id ],\n      currency: self.currency,\n      period: period,\n      favorable_direction: favorable_direction,\n      interval: interval\n    ))\n\n    builder.send(\"#{view}_series\")\n  end\n\n  def sparkline_series\n    cache_key = family.build_cache_key(\"#{id}_sparkline\", invalidate_on_data_updates: true)\n\n    Rails.cache.fetch(cache_key, expires_in: 24.hours) do\n      balance_series\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/account/current_balance_manager.rb",
    "content": "class Account::CurrentBalanceManager\n  InvalidOperation = Class.new(StandardError)\n\n  Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true)\n\n  def initialize(account)\n    @account = account\n  end\n\n  def has_current_anchor?\n    current_anchor_valuation.present?\n  end\n\n  # Our system should always make sure there is a current anchor, and that it is up to date.\n  # The fallback is provided for backwards compatibility, but should not be relied on since account.balance is a \"cached/derived\" value.\n  def current_balance\n    if current_anchor_valuation\n      current_anchor_valuation.entry.amount\n    else\n      Rails.logger.warn \"No current balance anchor found for account #{account.id}. Using cached balance instead, which may be out of date.\"\n      account.balance\n    end\n  end\n\n  def current_date\n    if current_anchor_valuation\n      current_anchor_valuation.entry.date\n    else\n      Date.current\n    end\n  end\n\n  def set_current_balance(balance)\n    if account.linked?\n      result = set_current_balance_for_linked_account(balance)\n    else\n      result = set_current_balance_for_manual_account(balance)\n    end\n\n    # Update cache field so changes appear immediately to the user\n    account.update!(balance: balance)\n\n    result\n  rescue => e\n    Result.new(success?: false, changes_made?: false, error: e.message)\n  end\n\n  private\n    attr_reader :account\n\n    def opening_balance_manager\n      @opening_balance_manager ||= Account::OpeningBalanceManager.new(account)\n    end\n\n    def reconciliation_manager\n      @reconciliation_manager ||= Account::ReconciliationManager.new(account)\n    end\n\n    # Manual accounts do not manage the `current_anchor` valuation (otherwise, user would need to continually update it, which is bad UX)\n    # Instead, we use a combination of \"auto-update strategies\" to set the current balance according to the user's intent.\n    #\n    # The \"auto-update strategies\" are:\n    # 1. Value tracking - If the account has a reconciliation already, we assume they are tracking the account value primarily with reconciliations, so we append a new one\n    # 2. Transaction adjustment - If the account doesn't have recons, we assume user is tracking with transactions, so we adjust the opening balance with a delta until it\n    #                             gets us to the desired balance. This ensures we don't append unnecessary reconciliations to the account, which \"reset\" the value from that\n    #                             date forward (not user's intent).\n    #\n    # For more documentation on these auto-update strategies, see the test cases.\n    def set_current_balance_for_manual_account(balance)\n      # If we're dealing with a cash account that has no reconciliations, use \"Transaction adjustment\" strategy (update opening balance to \"back in\" to the desired current balance)\n      if account.balance_type == :cash && account.valuations.reconciliation.empty?\n        adjust_opening_balance_with_delta(new_balance: balance, old_balance: account.balance)\n      else\n        existing_reconciliation = account.entries.valuations.find_by(date: Date.current)\n\n        result = reconciliation_manager.reconcile_balance(balance: balance, date: Date.current, existing_valuation_entry: existing_reconciliation)\n\n        # Normalize to expected result format\n        Result.new(success?: result.success?, changes_made?: true, error: result.error_message)\n      end\n    end\n\n    def adjust_opening_balance_with_delta(new_balance:, old_balance:)\n      delta = new_balance - old_balance\n\n      result = opening_balance_manager.set_opening_balance(balance: account.opening_anchor_balance + delta)\n\n      # Normalize to expected result format\n      Result.new(success?: result.success?, changes_made?: true, error: result.error)\n    end\n\n    # Linked accounts manage \"current balance\" via the special `current_anchor` valuation.\n    # This is NOT a user-facing feature, and is primarily used in \"processors\" while syncing\n    # linked account data (e.g. via Plaid)\n    def set_current_balance_for_linked_account(balance)\n      if current_anchor_valuation\n        changes_made = update_current_anchor(balance)\n        Result.new(success?: true, changes_made?: changes_made, error: nil)\n      else\n        create_current_anchor(balance)\n        Result.new(success?: true, changes_made?: true, error: nil)\n      end\n    end\n\n    def current_anchor_valuation\n      @current_anchor_valuation ||= account.valuations.current_anchor.includes(:entry).first\n    end\n\n    def create_current_anchor(balance)\n      account.entries.create!(\n        date: Date.current,\n        name: Valuation.build_current_anchor_name(account.accountable_type),\n        amount: balance,\n        currency: account.currency,\n        entryable: Valuation.new(kind: \"current_anchor\")\n      )\n    end\n\n    def update_current_anchor(balance)\n      changes_made = false\n\n      ActiveRecord::Base.transaction do\n        # Update associated entry attributes\n        entry = current_anchor_valuation.entry\n\n        if entry.amount != balance\n          entry.amount = balance\n          changes_made = true\n        end\n\n        if entry.date != Date.current\n          entry.date = Date.current\n          changes_made = true\n        end\n\n        entry.save! if entry.changed?\n      end\n\n      changes_made\n    end\nend\n"
  },
  {
    "path": "app/models/account/linkable.rb",
    "content": "module Account::Linkable\n  extend ActiveSupport::Concern\n\n  included do\n    belongs_to :plaid_account, optional: true\n  end\n\n  # A \"linked\" account gets transaction and balance data from a third party like Plaid\n  def linked?\n    plaid_account_id.present?\n  end\n\n  # An \"offline\" or \"unlinked\" account is one where the user tracks values and\n  # adds transactions manually, without the help of a data provider\n  def unlinked?\n    !linked?\n  end\n  alias_method :manual?, :unlinked?\nend\n"
  },
  {
    "path": "app/models/account/market_data_importer.rb",
    "content": "class Account::MarketDataImporter\n  attr_reader :account\n\n  def initialize(account)\n    @account = account\n  end\n\n  def import_all\n    import_exchange_rates\n    import_security_prices\n  end\n\n  def import_exchange_rates\n    return unless needs_exchange_rates?\n    return unless ExchangeRate.provider\n\n    pair_dates = {}\n\n    # 1. ENTRY-BASED PAIRS – currencies that differ from the account currency\n    account.entries\n           .where.not(currency: account.currency)\n           .group(:currency)\n           .minimum(:date)\n           .each do |source_currency, date|\n      key = [ source_currency, account.currency ]\n      pair_dates[key] = [ pair_dates[key], date ].compact.min\n    end\n\n    # 2. ACCOUNT-BASED PAIR – convert the account currency to the family currency (if different)\n    if foreign_account?\n      key = [ account.currency, account.family.currency ]\n      pair_dates[key] = [ pair_dates[key], account.start_date ].compact.min\n    end\n\n    pair_dates.each do |(source, target), start_date|\n      ExchangeRate.import_provider_rates(\n        from: source,\n        to: target,\n        start_date: start_date,\n        end_date: Date.current\n      )\n    end\n  end\n\n  def import_security_prices\n    return unless Security.provider\n\n    account_securities = account.trades.map(&:security).uniq\n\n    return if account_securities.empty?\n\n    account_securities.each do |security|\n      security.import_provider_prices(\n        start_date: first_required_price_date(security),\n        end_date: Date.current\n      )\n\n      security.import_provider_details\n    end\n  end\n\n  private\n    # Calculates the first date we require a price for the given security scoped to this account\n    def first_required_price_date(security)\n      account.trades.with_entry\n                    .where(security: security)\n                    .where(entries: { account_id: account.id })\n                    .minimum(\"entries.date\")\n    end\n\n    def needs_exchange_rates?\n      has_multi_currency_entries? || foreign_account?\n    end\n\n    def has_multi_currency_entries?\n      account.entries.where.not(currency: account.currency).exists?\n    end\n\n    def foreign_account?\n      account.currency != account.family.currency\n    end\nend\n"
  },
  {
    "path": "app/models/account/opening_balance_manager.rb",
    "content": "class Account::OpeningBalanceManager\n  Result = Struct.new(:success?, :changes_made?, :error, keyword_init: true)\n\n  def initialize(account)\n    @account = account\n  end\n\n  def has_opening_anchor?\n    opening_anchor_valuation.present?\n  end\n\n  # Most accounts should have an opening anchor. If not, we derive the opening date from the oldest entry date\n  def opening_date\n    return opening_anchor_valuation.entry.date if opening_anchor_valuation.present?\n\n    [\n      account.entries.valuations.order(:date).first&.date,\n      account.entries.where.not(entryable_type: \"Valuation\").order(:date).first&.date&.prev_day\n    ].compact.min || Date.current\n  end\n\n  def opening_balance\n    opening_anchor_valuation&.entry&.amount || 0\n  end\n\n  def set_opening_balance(balance:, date: nil)\n    resolved_date = date || default_date\n\n    # Validate date is before oldest entry\n    if date && oldest_entry_date && resolved_date >= oldest_entry_date\n      return Result.new(success?: false, changes_made?: false, error: \"Opening balance date must be before the oldest entry date\")\n    end\n\n    if opening_anchor_valuation.nil?\n      create_opening_anchor(\n        balance: balance,\n        date: resolved_date\n      )\n      Result.new(success?: true, changes_made?: true, error: nil)\n    else\n      changes_made = update_opening_anchor(balance: balance, date: date)\n      Result.new(success?: true, changes_made?: changes_made, error: nil)\n    end\n  end\n\n  private\n    attr_reader :account\n\n    def opening_anchor_valuation\n      @opening_anchor_valuation ||= account.valuations.opening_anchor.includes(:entry).first\n    end\n\n    def oldest_entry_date\n      @oldest_entry_date ||= account.entries.minimum(:date)\n    end\n\n    def default_date\n      if oldest_entry_date\n        [ oldest_entry_date - 1.day, 2.years.ago.to_date ].min\n      else\n        2.years.ago.to_date\n      end\n    end\n\n    def create_opening_anchor(balance:, date:)\n      account.entries.create!(\n        date: date,\n        name: Valuation.build_opening_anchor_name(account.accountable_type),\n        amount: balance,\n        currency: account.currency,\n        entryable: Valuation.new(\n          kind: \"opening_anchor\"\n        )\n      )\n    end\n\n    def update_opening_anchor(balance:, date: nil)\n      changes_made = false\n\n      ActiveRecord::Base.transaction do\n        # Update associated entry attributes\n        entry = opening_anchor_valuation.entry\n\n        if entry.amount != balance\n          entry.amount = balance\n          changes_made = true\n        end\n\n        if date.present? && entry.date != date\n          entry.date = date\n          changes_made = true\n        end\n\n        entry.save! if entry.changed?\n      end\n\n      changes_made\n    end\nend\n"
  },
  {
    "path": "app/models/account/reconcileable.rb",
    "content": "module Account::Reconcileable\n  extend ActiveSupport::Concern\n\n  def create_reconciliation(balance:, date:, dry_run: false)\n    result = reconciliation_manager.reconcile_balance(balance: balance, date: date, dry_run: dry_run)\n    sync_later if result.success? && !dry_run\n    result\n  end\n\n  def update_reconciliation(existing_valuation_entry, balance:, date:, dry_run: false)\n    result = reconciliation_manager.reconcile_balance(balance: balance, date: date, existing_valuation_entry: existing_valuation_entry, dry_run: dry_run)\n    sync_later if result.success? && !dry_run\n    result\n  end\n\n  private\n    def reconciliation_manager\n      @reconciliation_manager ||= Account::ReconciliationManager.new(self)\n    end\nend\n"
  },
  {
    "path": "app/models/account/reconciliation_manager.rb",
    "content": "class Account::ReconciliationManager\n  attr_reader :account\n\n  def initialize(account)\n    @account = account\n  end\n\n  # Reconciles balance by creating a Valuation entry. If existing valuation is provided, it will be updated instead of creating a new one.\n  def reconcile_balance(balance:, date: Date.current, dry_run: false, existing_valuation_entry: nil)\n    old_balance_components = old_balance_components(reconciliation_date: date, existing_valuation_entry: existing_valuation_entry)\n    prepared_valuation = prepare_reconciliation(balance, date, existing_valuation_entry)\n\n    unless dry_run\n      prepared_valuation.save!\n    end\n\n    ReconciliationResult.new(\n      success?: true,\n      old_cash_balance: old_balance_components[:cash_balance],\n      old_balance: old_balance_components[:balance],\n      new_cash_balance: derived_cash_balance(date: date, total_balance: prepared_valuation.amount),\n      new_balance: prepared_valuation.amount,\n      error_message: nil\n    )\n  rescue => e\n    ReconciliationResult.new(\n      success?: false,\n      error_message: e.message\n    )\n  end\n\n  private\n    # Returns before -> after OR error message\n    ReconciliationResult = Struct.new(\n      :success?,\n      :old_cash_balance,\n      :old_balance,\n      :new_cash_balance,\n      :new_balance,\n      :error_message,\n      keyword_init: true\n    )\n\n    def prepare_reconciliation(balance, date, existing_valuation)\n      valuation_record = existing_valuation ||\n                         account.entries.valuations.find_by(date: date) || # In case of conflict, where existing valuation is not passed as arg, but one exists\n                         account.entries.build(\n                                  name: Valuation.build_reconciliation_name(account.accountable_type),\n                                  entryable: Valuation.new(kind: \"reconciliation\")\n                                )\n\n      valuation_record.assign_attributes(\n        date: date,\n        amount: balance,\n        currency: account.currency\n      )\n\n      valuation_record\n    end\n\n    def derived_cash_balance(date:, total_balance:)\n      balance_components_for_reconciliation_date = get_balance_components_for_date(date)\n\n      return nil unless balance_components_for_reconciliation_date[:balance] && balance_components_for_reconciliation_date[:cash_balance]\n\n      # We calculate the existing non-cash balance, which for investments would represents \"holdings\" for the date of reconciliation\n      # Since the user is setting \"total balance\", we have to subtract the existing non-cash balance from the total balance to get the new cash balance\n      existing_non_cash_balance = balance_components_for_reconciliation_date[:balance] - balance_components_for_reconciliation_date[:cash_balance]\n\n      total_balance - existing_non_cash_balance\n    end\n\n    def old_balance_components(reconciliation_date:, existing_valuation_entry: nil)\n      if existing_valuation_entry\n        get_balance_components_for_date(existing_valuation_entry.date)\n      else\n        get_balance_components_for_date(reconciliation_date)\n      end\n    end\n\n    def get_balance_components_for_date(date)\n      balance_record = account.balances.find_by(date: date, currency: account.currency)\n\n      {\n        cash_balance: balance_record&.end_cash_balance,\n        balance: balance_record&.end_balance\n      }\n    end\nend\n"
  },
  {
    "path": "app/models/account/sync_complete_event.rb",
    "content": "class Account::SyncCompleteEvent\n  attr_reader :account\n\n  Error = Class.new(StandardError)\n\n  def initialize(account)\n    @account = account\n  end\n\n  def broadcast\n    # Replace account row in accounts list\n    account.broadcast_replace_to(\n      account.family,\n      target: \"account_#{account.id}\",\n      partial: \"accounts/account\",\n      locals: { account: account }\n    )\n\n    # Replace the groups this account belongs to in both desktop and mobile sidebars\n    sidebar_targets.each do |(tab, mobile_flag)|\n      account.broadcast_replace_to(\n        account.family,\n        target: account_group.dom_id(tab: tab, mobile: mobile_flag),\n        partial: \"accounts/accountable_group\",\n        locals: { account_group: account_group, open: true, all_tab: tab == :all, mobile: mobile_flag }\n      )\n    end\n\n    # If this is a manual, unlinked account (i.e. not part of a Plaid Item),\n    # trigger the family sync complete broadcast so net worth graph is updated\n    unless account.linked?\n      account.family.broadcast_sync_complete\n    end\n\n    # Refresh entire account page (only applies if currently viewing this account)\n    account.broadcast_refresh\n  end\n\n  private\n    # Returns an array of [tab, mobile?] tuples that should receive an update.\n    # We broadcast to both the classification-specific tab and the \"all\" tab,\n    # for desktop (mobile: false) and mobile (mobile: true) variants.\n    def sidebar_targets\n      return [] unless account_group.present?\n\n      [\n        [ account_group.classification.to_sym, false ],\n        [ :all, false ],\n        [ account_group.classification.to_sym, true ],\n        [ :all, true ]\n      ]\n    end\n\n    def account_group\n      family_balance_sheet.account_groups.find do |group|\n        group.accounts.any? { |a| a.id == account.id }\n      end\n    end\n\n    def family_balance_sheet\n      account.family.balance_sheet\n    end\nend\n"
  },
  {
    "path": "app/models/account/syncer.rb",
    "content": "class Account::Syncer\n  attr_reader :account\n\n  def initialize(account)\n    @account = account\n  end\n\n  def perform_sync(sync)\n    Rails.logger.info(\"Processing balances (#{account.linked? ? 'reverse' : 'forward'})\")\n    import_market_data\n    materialize_balances\n  end\n\n  def perform_post_sync\n    account.family.auto_match_transfers!\n  end\n\n  private\n    def materialize_balances\n      strategy = account.linked? ? :reverse : :forward\n      Balance::Materializer.new(account, strategy: strategy).materialize_balances\n    end\n\n    # Syncs all the exchange rates + security prices this account needs to display historical chart data\n    #\n    # This is a *supplemental* sync.  The daily market data sync should have already populated\n    # a majority or all of this data, so this is often a no-op.\n    #\n    # We rescue errors here because if this operation fails, we don't want to fail the entire sync since\n    # we have reasonable fallbacks for missing market data.\n    def import_market_data\n      Account::MarketDataImporter.new(account).import_all\n    rescue => e\n      Rails.logger.error(\"Error syncing market data for account #{account.id}: #{e.message}\")\n      Sentry.capture_exception(e)\n    end\nend\n"
  },
  {
    "path": "app/models/account.rb",
    "content": "class Account < ApplicationRecord\n  include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable\n\n  validates :name, :balance, :currency, presence: true\n\n  belongs_to :family\n  belongs_to :import, optional: true\n\n  has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: \"Import::Mapping\"\n  has_many :entries, dependent: :destroy\n  has_many :transactions, through: :entries, source: :entryable, source_type: \"Transaction\"\n  has_many :valuations, through: :entries, source: :entryable, source_type: \"Valuation\"\n  has_many :trades, through: :entries, source: :entryable, source_type: \"Trade\"\n  has_many :holdings, dependent: :destroy\n  has_many :balances, dependent: :destroy\n\n  monetize :balance, :cash_balance\n\n  enum :classification, { asset: \"asset\", liability: \"liability\" }, validate: { allow_nil: true }\n\n  scope :visible, -> { where(status: [ \"draft\", \"active\" ]) }\n  scope :assets, -> { where(classification: \"asset\") }\n  scope :liabilities, -> { where(classification: \"liability\") }\n  scope :alphabetically, -> { order(:name) }\n  scope :manual, -> { where(plaid_account_id: nil) }\n\n  has_one_attached :logo\n\n  delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy\n\n  accepts_nested_attributes_for :accountable, update_only: true\n\n  # Account state machine\n  aasm column: :status, timestamps: true do\n    state :active, initial: true\n    state :draft\n    state :disabled\n    state :pending_deletion\n\n    event :activate do\n      transitions from: [ :draft, :disabled ], to: :active\n    end\n\n    event :disable do\n      transitions from: [ :draft, :active ], to: :disabled\n    end\n\n    event :enable do\n      transitions from: :disabled, to: :active\n    end\n\n    event :mark_for_deletion do\n      transitions from: [ :draft, :active, :disabled ], to: :pending_deletion\n    end\n  end\n\n  class << self\n    def create_and_sync(attributes)\n      attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty\n      account = new(attributes.merge(cash_balance: attributes[:balance]))\n      initial_balance = attributes.dig(:accountable_attributes, :initial_balance)&.to_d\n\n      transaction do\n        account.save!\n\n        manager = Account::OpeningBalanceManager.new(account)\n        result = manager.set_opening_balance(balance: initial_balance || account.balance)\n        raise result.error if result.error\n      end\n\n      account.sync_later\n      account\n    end\n  end\n\n  def institution_domain\n    url_string = plaid_account&.plaid_item&.institution_url\n    return nil unless url_string.present?\n\n    begin\n      uri = URI.parse(url_string)\n      # Use safe navigation on .host before calling gsub\n      uri.host&.gsub(/^www\\./, \"\")\n    rescue URI::InvalidURIError\n      # Log a warning if the URL is invalid and return nil\n      Rails.logger.warn(\"Invalid institution URL encountered for account #{id}: #{url_string}\")\n      nil\n    end\n  end\n\n  def destroy_later\n    mark_for_deletion!\n    DestroyJob.perform_later(self)\n  end\n\n  # Override destroy to handle error recovery for accounts\n  def destroy\n    super\n  rescue => e\n    # If destruction fails, transition back to disabled state\n    # This provides a cleaner recovery path than the generic scheduled_for_deletion flag\n    disable! if may_disable?\n    raise e\n  end\n\n  def current_holdings\n    holdings.where(currency: currency)\n            .where.not(qty: 0)\n            .where(\n              id: holdings.select(\"DISTINCT ON (security_id) id\")\n                          .where(currency: currency)\n                          .order(:security_id, date: :desc)\n            )\n            .order(amount: :desc)\n  end\n\n  def start_date\n    first_entry_date = entries.minimum(:date) || Date.current\n    first_entry_date - 1.day\n  end\n\n  def lock_saved_attributes!\n    super\n    accountable.lock_saved_attributes!\n  end\n\n  def first_valuation\n    entries.valuations.order(:date).first\n  end\n\n  def first_valuation_amount\n    first_valuation&.amount_money || balance_money\n  end\n\n  # Get short version of the subtype label\n  def short_subtype_label\n    accountable_class.short_subtype_label_for(subtype) || accountable_class.display_name\n  end\n\n  # Get long version of the subtype label\n  def long_subtype_label\n    accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name\n  end\n\n  # The balance type determines which \"component\" of balance is being tracked.\n  # This is primarily used for balance related calculations and updates.\n  #\n  # \"Cash\" = \"Liquid\"\n  # \"Non-cash\" = \"Illiquid\"\n  # \"Investment\" = A mix of both, including brokerage cash (liquid) and holdings (illiquid)\n  def balance_type\n    case accountable_type\n    when \"Depository\", \"CreditCard\"\n      :cash\n    when \"Property\", \"Vehicle\", \"OtherAsset\", \"Loan\", \"OtherLiability\"\n      :non_cash\n    when \"Investment\", \"Crypto\"\n      :investment\n    else\n      raise \"Unknown account type: #{accountable_type}\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/account_import.rb",
    "content": "class AccountImport < Import\n  OpeningBalanceError = Class.new(StandardError)\n\n  def import!\n    transaction do\n      rows.each do |row|\n        mapping = mappings.account_types.find_by(key: row.entity_type)\n        accountable_class = mapping.value.constantize\n\n        account = family.accounts.build(\n          name: row.name,\n          balance: row.amount.to_d,\n          currency: row.currency,\n          accountable: accountable_class.new,\n          import: self\n        )\n\n        account.save!\n\n        manager = Account::OpeningBalanceManager.new(account)\n        result = manager.set_opening_balance(balance: row.amount.to_d)\n\n        # Re-raise since we should never have an error here\n        if result.error\n          raise OpeningBalanceError, result.error\n        end\n      end\n    end\n  end\n\n  def mapping_steps\n    [ Import::AccountTypeMapping ]\n  end\n\n  def required_column_keys\n    %i[name amount]\n  end\n\n  def column_keys\n    %i[entity_type name amount currency]\n  end\n\n  def dry_run\n    {\n      accounts: rows.count\n    }\n  end\n\n  def csv_template\n    template = <<-CSV\n      Account type*,Name*,Balance*,Currency\n      Checking,Main Checking Account,1000.00,USD\n      Savings,Emergency Fund,5000.00,USD\n      Credit Card,Rewards Card,-500.00,USD\n    CSV\n\n    CSV.parse(template, headers: true)\n  end\n\n  def max_row_count\n    50\n  end\nend\n"
  },
  {
    "path": "app/models/address.rb",
    "content": "class Address < ApplicationRecord\n  belongs_to :addressable, polymorphic: true\n\n  def to_s\n    string = I18n.t(\"address.format\",\n      line1: line1,\n      line2: line2,\n      county: county,\n      locality: locality,\n      region: region,\n      country: country,\n      postal_code: postal_code\n    )\n\n    # Clean up the string to maintain I18n comma formatting\n    string.split(\",\").map(&:strip).reject(&:empty?).join(\", \")\n  end\nend\n"
  },
  {
    "path": "app/models/api_key.rb",
    "content": "class ApiKey < ApplicationRecord\n  belongs_to :user\n\n  # Use Rails built-in encryption for secure storage\n  encrypts :display_key, deterministic: true\n\n  # Constants\n  SOURCES = [ \"web\", \"mobile\" ].freeze\n\n  # Validations\n  validates :display_key, presence: true, uniqueness: true\n  validates :name, presence: true\n  validates :scopes, presence: true\n  validates :source, presence: true, inclusion: { in: SOURCES }\n  validate :scopes_not_empty\n  validate :one_active_key_per_user_per_source, on: :create\n\n  # Callbacks\n  before_validation :set_display_key\n\n  # Scopes\n  scope :active, -> { where(revoked_at: nil).where(\"expires_at IS NULL OR expires_at > ?\", Time.current) }\n\n  # Class methods\n  def self.find_by_value(plain_key)\n    return nil unless plain_key\n\n    # Find by encrypted display_key (deterministic encryption allows querying)\n    find_by(display_key: plain_key)&.tap do |api_key|\n      return api_key if api_key.active?\n    end\n  end\n\n  def self.generate_secure_key\n    SecureRandom.hex(32)\n  end\n\n  # Instance methods\n  def active?\n    !revoked? && !expired?\n  end\n\n  def revoked?\n    revoked_at.present?\n  end\n\n  def expired?\n    expires_at.present? && expires_at < Time.current\n  end\n\n  def key_matches?(plain_key)\n    display_key == plain_key\n  end\n\n  def revoke!\n    update!(revoked_at: Time.current)\n  end\n\n  def update_last_used!\n    update_column(:last_used_at, Time.current)\n  end\n\n  # Get the plain text API key for display (automatically decrypted by Rails)\n  def plain_key\n    display_key\n  end\n\n  # Temporarily store the plain key for creation flow\n  attr_accessor :key\n\n  private\n\n    def set_display_key\n      if key.present?\n        self.display_key = key\n      end\n    end\n\n    def scopes_not_empty\n      if scopes.blank? || (scopes.is_a?(Array) && (scopes.empty? || scopes.all?(&:blank?)))\n        errors.add(:scopes, \"must include at least one permission\")\n      elsif scopes.is_a?(Array) && scopes.length > 1\n        errors.add(:scopes, \"can only have one permission level\")\n      elsif scopes.is_a?(Array) && !%w[read read_write].include?(scopes.first)\n        errors.add(:scopes, \"must be either 'read' or 'read_write'\")\n      end\n    end\n\n    def one_active_key_per_user_per_source\n      if user&.api_keys&.active&.where(source: source)&.where&.not(id: id)&.exists?\n        errors.add(:user, \"can only have one active API key per source (#{source})\")\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/application_record.rb",
    "content": "class ApplicationRecord < ActiveRecord::Base\n  primary_abstract_class\nend\n"
  },
  {
    "path": "app/models/assistant/broadcastable.rb",
    "content": "module Assistant::Broadcastable\n  extend ActiveSupport::Concern\n\n  private\n    def update_thinking(thought)\n      chat.broadcast_update target: \"thinking-indicator\", partial: \"chats/thinking_indicator\", locals: { chat: chat, message: thought }\n    end\n\n    def stop_thinking\n      chat.broadcast_remove target: \"thinking-indicator\"\n    end\nend\n"
  },
  {
    "path": "app/models/assistant/configurable.rb",
    "content": "module Assistant::Configurable\n  extend ActiveSupport::Concern\n\n  class_methods do\n    def config_for(chat)\n      preferred_currency = Money::Currency.new(chat.user.family.currency)\n      preferred_date_format = chat.user.family.date_format\n\n      {\n        instructions: default_instructions(preferred_currency, preferred_date_format),\n        functions: default_functions\n      }\n    end\n\n    private\n      def default_functions\n        [\n          Assistant::Function::GetTransactions,\n          Assistant::Function::GetAccounts,\n          Assistant::Function::GetBalanceSheet,\n          Assistant::Function::GetIncomeStatement\n        ]\n      end\n\n      def default_instructions(preferred_currency, preferred_date_format)\n        <<~PROMPT\n          ## Your identity\n\n          You are a friendly financial assistant for an open source personal finance application called \"Maybe\", which is short for \"Maybe Finance\".\n\n          ## Your purpose\n\n          You help users understand their financial data by answering questions about their accounts, transactions, income, expenses, net worth, forecasting and more.\n\n          ## Your rules\n\n          Follow all rules below at all times.\n\n          ### General rules\n\n          - Provide ONLY the most important numbers and insights\n          - Eliminate all unnecessary words and context\n          - Ask follow-up questions to keep the conversation going. Help educate the user about their own data and entice them to ask more questions.\n          - Do NOT add introductions or conclusions\n          - Do NOT apologize or explain limitations\n\n          ### Formatting rules\n\n          - Format all responses in markdown\n          - Format all monetary values according to the user's preferred currency\n          - Format dates in the user's preferred format: #{preferred_date_format}\n\n          #### User's preferred currency\n\n          Maybe is a multi-currency app where each user has a \"preferred currency\" setting.\n\n          When no currency is specified, use the user's preferred currency for formatting and displaying monetary values.\n\n          - Symbol: #{preferred_currency.symbol}\n          - ISO code: #{preferred_currency.iso_code}\n          - Default precision: #{preferred_currency.default_precision}\n          - Default format: #{preferred_currency.default_format}\n            - Separator: #{preferred_currency.separator}\n            - Delimiter: #{preferred_currency.delimiter}\n\n          ### Rules about financial advice\n\n          You should focus on educating the user about personal finance using their own data so they can make informed decisions.\n\n          - Do not tell the user to buy or sell specific financial products or investments.\n          - Do not make assumptions about the user's financial situation. Use the functions available to get the data you need.\n\n          ### Function calling rules\n\n          - Use the functions available to you to get user financial data and enhance your responses\n          - For functions that require dates, use the current date as your reference point: #{Date.current}\n          - If you suspect that you do not have enough data to 100% accurately answer, be transparent about it and state exactly what\n            the data you're presenting represents and what context it is in (i.e. date range, account, etc.)\n        PROMPT\n      end\n  end\nend\n"
  },
  {
    "path": "app/models/assistant/function/get_accounts.rb",
    "content": "class Assistant::Function::GetAccounts < Assistant::Function\n  class << self\n    def name\n      \"get_accounts\"\n    end\n\n    def description\n      \"Use this to see what accounts the user has along with their current and historical balances\"\n    end\n  end\n\n  def call(params = {})\n    {\n      as_of_date: Date.current,\n      accounts: family.accounts.includes(:balances).map do |account|\n        {\n          name: account.name,\n          balance: account.balance,\n          currency: account.currency,\n          balance_formatted: account.balance_money.format,\n          classification: account.classification,\n          type: account.accountable_type,\n          start_date: account.start_date,\n          is_plaid_linked: account.plaid_account_id.present?,\n          status: account.status,\n          historical_balances: historical_balances(account)\n        }\n      end\n    }\n  end\n\n  private\n    def historical_balances(account)\n      start_date = [ account.start_date, 5.years.ago.to_date ].max\n      period = Period.custom(start_date: start_date, end_date: Date.current)\n      balance_series = account.balance_series(period: period, interval: \"1 month\")\n\n      to_ai_time_series(balance_series)\n    end\nend\n"
  },
  {
    "path": "app/models/assistant/function/get_balance_sheet.rb",
    "content": "class Assistant::Function::GetBalanceSheet < Assistant::Function\n  include ActiveSupport::NumberHelper\n\n  class << self\n    def name\n      \"get_balance_sheet\"\n    end\n\n    def description\n      <<~INSTRUCTIONS\n        Use this to get the user's balance sheet with varying amounts of historical data.\n\n        This is great for answering questions like:\n        - What is the user's net worth?  What is it composed of?\n        - How has the user's wealth changed over time?\n      INSTRUCTIONS\n    end\n  end\n\n  def call(params = {})\n    observation_start_date = [ 5.years.ago.to_date, family.oldest_entry_date ].max\n\n    period = Period.custom(start_date: observation_start_date, end_date: Date.current)\n\n    {\n      as_of_date: Date.current,\n      oldest_account_start_date: family.oldest_entry_date,\n      currency: family.currency,\n      net_worth: {\n        current: family.balance_sheet.net_worth_money.format,\n        monthly_history: historical_data(period)\n      },\n      assets: {\n        current: family.balance_sheet.assets.total_money.format,\n        monthly_history: historical_data(period, classification: \"asset\")\n      },\n      liabilities: {\n        current: family.balance_sheet.liabilities.total_money.format,\n        monthly_history: historical_data(period, classification: \"liability\")\n      },\n      insights: insights_data\n    }\n  end\n\n  private\n    def historical_data(period, classification: nil)\n      scope = family.accounts.visible\n      scope = scope.where(classification: classification) if classification.present?\n\n      if period.start_date == Date.current\n        []\n      else\n        account_ids = scope.pluck(:id)\n\n        builder = Balance::ChartSeriesBuilder.new(\n          account_ids: account_ids,\n          currency: family.currency,\n          period: period,\n          favorable_direction: \"up\",\n          interval: \"1 month\"\n        )\n\n        to_ai_time_series(builder.balance_series)\n      end\n    end\n\n    def insights_data\n      assets = family.balance_sheet.assets.total\n      liabilities = family.balance_sheet.liabilities.total\n      ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f)\n\n      {\n        debt_to_asset_ratio: number_to_percentage(ratio * 100, precision: 0)\n      }\n    end\nend\n"
  },
  {
    "path": "app/models/assistant/function/get_income_statement.rb",
    "content": "class Assistant::Function::GetIncomeStatement < Assistant::Function\n  include ActiveSupport::NumberHelper\n\n  class << self\n    def name\n      \"get_income_statement\"\n    end\n\n    def description\n      <<~INSTRUCTIONS\n        Use this to get income and expense insights by category, for a specific time period\n\n        This is great for answering questions like:\n        - What is the user's net income for the current month?\n        - What are the user's spending habits?\n        - How much income or spending did the user have over a specific time period?\n\n        Simple example:\n\n        ```\n        get_income_statement({\n          start_date: \"2024-01-01\",\n          end_date: \"2024-12-31\"\n        })\n        ```\n      INSTRUCTIONS\n    end\n  end\n\n  def call(params = {})\n    period = Period.custom(start_date: Date.parse(params[\"start_date\"]), end_date: Date.parse(params[\"end_date\"]))\n    income_data = family.income_statement.income_totals(period: period)\n    expense_data = family.income_statement.expense_totals(period: period)\n\n    {\n      currency: family.currency,\n      period: {\n        start_date: period.start_date,\n        end_date: period.end_date\n      },\n      income: {\n        total: format_money(income_data.total),\n        by_category: to_ai_category_totals(income_data.category_totals)\n      },\n      expense: {\n        total: format_money(expense_data.total),\n        by_category: to_ai_category_totals(expense_data.category_totals)\n      },\n      insights: get_insights(income_data, expense_data)\n    }\n  end\n\n  def params_schema\n    build_schema(\n      required: [ \"start_date\", \"end_date\" ],\n      properties: {\n        start_date: {\n          type: \"string\",\n          description: \"Start date for aggregation period in YYYY-MM-DD format\"\n        },\n        end_date: {\n          type: \"string\",\n          description: \"End date for aggregation period in YYYY-MM-DD format\"\n        }\n      }\n    )\n  end\n\n  private\n    def format_money(value)\n      Money.new(value, family.currency).format\n    end\n\n    def calculate_savings_rate(total_income, total_expenses)\n      return 0 if total_income.zero?\n      savings = total_income - total_expenses\n      rate = (savings / total_income.to_f) * 100\n      rate.round(2)\n    end\n\n    def to_ai_category_totals(category_totals)\n      hierarchical_groups = category_totals.group_by { |ct| ct.category.parent_id }.then do |grouped|\n        root_category_totals = grouped[nil] || []\n\n        root_category_totals.each_with_object({}) do |ct, hash|\n          subcategory_totals = ct.category.name == \"Uncategorized\" ? [] : (grouped[ct.category.id] || [])\n          hash[ct.category.name] = {\n            category_total: ct,\n            subcategory_totals: subcategory_totals\n          }\n        end\n      end\n\n      hierarchical_groups.sort_by { |name, data| -data.dig(:category_total).total }.map do |name, data|\n        {\n          name: name,\n          total: format_money(data.dig(:category_total).total),\n          percentage_of_total: number_to_percentage(data.dig(:category_total).weight, precision: 1),\n          subcategory_totals: data.dig(:subcategory_totals).map do |st|\n            {\n              name: st.category.name,\n              total: format_money(st.total),\n              percentage_of_total: number_to_percentage(st.weight, precision: 1)\n            }\n          end\n        }\n      end\n    end\n\n    def get_insights(income_data, expense_data)\n      net_income = income_data.total - expense_data.total\n      savings_rate = calculate_savings_rate(income_data.total, expense_data.total)\n      median_monthly_income = family.income_statement.median_income\n      median_monthly_expenses = family.income_statement.median_expense\n      avg_monthly_expenses = family.income_statement.avg_expense\n\n      {\n        net_income: format_money(net_income),\n        savings_rate: number_to_percentage(savings_rate),\n        median_monthly_income: format_money(median_monthly_income),\n        median_monthly_expenses: format_money(median_monthly_expenses),\n        avg_monthly_expenses: format_money(avg_monthly_expenses)\n      }\n    end\nend\n"
  },
  {
    "path": "app/models/assistant/function/get_transactions.rb",
    "content": "class Assistant::Function::GetTransactions < Assistant::Function\n  include Pagy::Backend\n\n  class << self\n    def default_page_size\n      50\n    end\n\n    def name\n      \"get_transactions\"\n    end\n\n    def description\n      <<~INSTRUCTIONS\n        Use this to search user's transactions by using various optional filters.\n\n        This function is great for things like:\n        - Finding specific transactions\n        - Getting basic stats about a small group of transactions\n\n        This function is not great for:\n        - Large time periods (use the get_income_statement function for this)\n\n        Note on pagination:\n\n        This function can be paginated.  You can expect the following properties in the response:\n\n        - `total_pages`: The total number of pages of results\n        - `page`: The current page of results\n        - `page_size`: The number of results per page (this will always be #{default_page_size})\n        - `total_results`: The total number of results for the given filters\n        - `total_income`: The total income for the given filters\n        - `total_expenses`: The total expenses for the given filters\n\n        Simple example (transactions from the last 30 days):\n\n        ```\n        get_transactions({\n          page: 1,\n          start_date: \"#{30.days.ago.to_date}\",\n          end_date: \"#{Date.current}\"\n        })\n        ```\n\n        More complex example (various filters):\n\n        ```\n        get_transactions({\n          page: 1,\n          search: \"mcdonalds\",\n          accounts: [\"Checking\", \"Savings\"],\n          start_date: \"#{30.days.ago.to_date}\",\n          end_date: \"#{Date.current}\",\n          categories: [\"Restaurants\"],\n          merchants: [\"McDonald's\"],\n          tags: [\"Food\"],\n          amount: \"100\",\n          amount_operator: \"less\"\n        })\n        ```\n      INSTRUCTIONS\n    end\n  end\n\n  def strict_mode?\n    false\n  end\n\n  def params_schema\n    build_schema(\n      required: [ \"order\", \"page\", \"page_size\" ],\n      properties: {\n        page: {\n          type: \"integer\",\n          description: \"Page number\"\n        },\n        order: {\n          enum: [ \"asc\", \"desc\" ],\n          description: \"Order of the transactions by date\"\n        },\n        search: {\n          type: \"string\",\n          description: \"Search for transactions by name\"\n        },\n        amount: {\n          type: \"string\",\n          description: \"Amount for transactions (must be used with amount_operator)\"\n        },\n        amount_operator: {\n          type: \"string\",\n          description: \"Operator for amount (must be used with amount)\",\n          enum: [ \"equal\", \"less\", \"greater\" ]\n        },\n        start_date: {\n          type: \"string\",\n          description: \"Start date for transactions in YYYY-MM-DD format\"\n        },\n        end_date: {\n          type: \"string\",\n          description: \"End date for transactions in YYYY-MM-DD format\"\n        },\n        accounts: {\n          type: \"array\",\n          description: \"Filter transactions by account name\",\n          items: { enum: family_account_names },\n          minItems: 1,\n          uniqueItems: true\n        },\n        categories: {\n          type: \"array\",\n          description: \"Filter transactions by category name\",\n          items: { enum: family_category_names },\n          minItems: 1,\n          uniqueItems: true\n        },\n        merchants: {\n          type: \"array\",\n          description: \"Filter transactions by merchant name\",\n          items: { enum: family_merchant_names },\n          minItems: 1,\n          uniqueItems: true\n        },\n        tags: {\n          type: \"array\",\n          description: \"Filter transactions by tag name\",\n          items: { enum: family_tag_names },\n          minItems: 1,\n          uniqueItems: true\n        }\n      }\n    )\n  end\n\n  def call(params = {})\n    search_params = params.except(\"order\", \"page\")\n\n    search = Transaction::Search.new(family, filters: search_params)\n    transactions_query = search.transactions_scope\n    pagy_query = params[\"order\"] == \"asc\" ? transactions_query.chronological : transactions_query.reverse_chronological\n\n    # By default, we give a small page size to force the AI to use filters effectively and save on tokens\n    pagy, paginated_transactions = pagy(\n      pagy_query.includes(\n        { entry: :account },\n        :category, :merchant, :tags,\n        transfer_as_outflow: { inflow_transaction: { entry: :account } },\n        transfer_as_inflow: { outflow_transaction: { entry: :account } }\n      ),\n      page: params[\"page\"] || 1,\n      limit: default_page_size\n    )\n\n    totals = search.totals\n\n    normalized_transactions = paginated_transactions.map do |txn|\n      entry = txn.entry\n      {\n        date: entry.date,\n        amount: entry.amount.abs,\n        currency: entry.currency,\n        formatted_amount: entry.amount_money.abs.format,\n        classification: entry.amount < 0 ? \"income\" : \"expense\",\n        account: entry.account.name,\n        category: txn.category&.name,\n        merchant: txn.merchant&.name,\n        tags: txn.tags.map(&:name),\n        is_transfer: txn.transfer?\n      }\n    end\n\n    {\n      transactions: normalized_transactions,\n      total_results: pagy.count,\n      page: pagy.page,\n      page_size: default_page_size,\n      total_pages: pagy.pages,\n      total_income: totals.income_money.format,\n      total_expenses: totals.expense_money.format\n    }\n  end\n\n  private\n    def default_page_size\n      self.class.default_page_size\n    end\nend\n"
  },
  {
    "path": "app/models/assistant/function.rb",
    "content": "class Assistant::Function\n  class << self\n    def name\n      raise NotImplementedError, \"Subclasses must implement the name class method\"\n    end\n\n    def description\n      raise NotImplementedError, \"Subclasses must implement the description class method\"\n    end\n  end\n\n  def initialize(user)\n    @user = user\n  end\n\n  def call(params = {})\n    raise NotImplementedError, \"Subclasses must implement the call method\"\n  end\n\n  def name\n    self.class.name\n  end\n\n  def description\n    self.class.description\n  end\n\n  def params_schema\n    build_schema\n  end\n\n  # (preferred) when in strict mode, the schema needs to include all properties in required array\n  def strict_mode?\n    true\n  end\n\n  def to_definition\n    {\n      name: name,\n      description: description,\n      params_schema: params_schema,\n      strict: strict_mode?\n    }\n  end\n\n  private\n    attr_reader :user\n\n    def build_schema(properties: {}, required: [])\n      {\n        type: \"object\",\n        properties: properties,\n        required: required,\n        additionalProperties: false\n      }\n    end\n\n    def family_account_names\n      @family_account_names ||= family.accounts.visible.pluck(:name)\n    end\n\n    def family_category_names\n      @family_category_names ||= begin\n        names = family.categories.pluck(:name)\n        names << \"Uncategorized\"\n        names\n      end\n    end\n\n    def family_merchant_names\n      @family_merchant_names ||= family.merchants.pluck(:name)\n    end\n\n    def family_tag_names\n      @family_tag_names ||= family.tags.pluck(:name)\n    end\n\n    def family\n      user.family\n    end\n\n    # To save tokens, we provide the AI metadata about the series and a flat array of\n    # raw, formatted values which it can infer dates from\n    def to_ai_time_series(series)\n      {\n        start_date: series.start_date,\n        end_date: series.end_date,\n        interval: series.interval,\n        values: series.values.map { |v| v.trend.current.format }\n      }\n    end\nend\n"
  },
  {
    "path": "app/models/assistant/function_tool_caller.rb",
    "content": "class Assistant::FunctionToolCaller\n  Error = Class.new(StandardError)\n  FunctionExecutionError = Class.new(Error)\n\n  attr_reader :functions\n\n  def initialize(functions = [])\n    @functions = functions\n  end\n\n  def fulfill_requests(function_requests)\n    function_requests.map do |function_request|\n      result = execute(function_request)\n\n      ToolCall::Function.from_function_request(function_request, result)\n    end\n  end\n\n  def function_definitions\n    functions.map(&:to_definition)\n  end\n\n  private\n    def execute(function_request)\n      fn = find_function(function_request)\n      fn_args = JSON.parse(function_request.function_args)\n      fn.call(fn_args)\n    rescue => e\n      raise FunctionExecutionError.new(\n        \"Error calling function #{fn.name} with arguments #{fn_args}: #{e.message}\"\n      )\n    end\n\n    def find_function(function_request)\n      functions.find { |f| f.name == function_request.function_name }\n    end\nend\n"
  },
  {
    "path": "app/models/assistant/provided.rb",
    "content": "module Assistant::Provided\n  extend ActiveSupport::Concern\n\n  def get_model_provider(ai_model)\n    registry.providers.find { |provider| provider.supports_model?(ai_model) }\n  end\n\n  private\n    def registry\n      @registry ||= Provider::Registry.for_concept(:llm)\n    end\nend\n"
  },
  {
    "path": "app/models/assistant/responder.rb",
    "content": "class Assistant::Responder\n  def initialize(message:, instructions:, function_tool_caller:, llm:)\n    @message = message\n    @instructions = instructions\n    @function_tool_caller = function_tool_caller\n    @llm = llm\n  end\n\n  def on(event_name, &block)\n    listeners[event_name.to_sym] << block\n  end\n\n  def respond(previous_response_id: nil)\n    # For the first response\n    streamer = proc do |chunk|\n      case chunk.type\n      when \"output_text\"\n        emit(:output_text, chunk.data)\n      when \"response\"\n        response = chunk.data\n\n        if response.function_requests.any?\n          handle_follow_up_response(response)\n        else\n          emit(:response, { id: response.id })\n        end\n      end\n    end\n\n    get_llm_response(streamer: streamer, previous_response_id: previous_response_id)\n  end\n\n  private\n    attr_reader :message, :instructions, :function_tool_caller, :llm\n\n    def handle_follow_up_response(response)\n      streamer = proc do |chunk|\n        case chunk.type\n        when \"output_text\"\n          emit(:output_text, chunk.data)\n        when \"response\"\n          # We do not currently support function executions for a follow-up response (avoid recursive LLM calls that could lead to high spend)\n          emit(:response, { id: chunk.data.id })\n        end\n      end\n\n      function_tool_calls = function_tool_caller.fulfill_requests(response.function_requests)\n\n      emit(:response, {\n        id: response.id,\n        function_tool_calls: function_tool_calls\n      })\n\n      # Get follow-up response with tool call results\n      get_llm_response(\n        streamer: streamer,\n        function_results: function_tool_calls.map(&:to_result),\n        previous_response_id: response.id\n      )\n    end\n\n    def get_llm_response(streamer:, function_results: [], previous_response_id: nil)\n      response = llm.chat_response(\n        message.content,\n        model: message.ai_model,\n        instructions: instructions,\n        functions: function_tool_caller.function_definitions,\n        function_results: function_results,\n        streamer: streamer,\n        previous_response_id: previous_response_id\n      )\n\n      unless response.success?\n        raise response.error\n      end\n\n      response.data\n    end\n\n    def emit(event_name, payload = nil)\n      listeners[event_name.to_sym].each { |block| block.call(payload) }\n    end\n\n    def listeners\n      @listeners ||= Hash.new { |h, k| h[k] = [] }\n    end\nend\n"
  },
  {
    "path": "app/models/assistant.rb",
    "content": "class Assistant\n  include Provided, Configurable, Broadcastable\n\n  attr_reader :chat, :instructions\n\n  class << self\n    def for_chat(chat)\n      config = config_for(chat)\n      new(chat, instructions: config[:instructions], functions: config[:functions])\n    end\n  end\n\n  def initialize(chat, instructions: nil, functions: [])\n    @chat = chat\n    @instructions = instructions\n    @functions = functions\n  end\n\n  def respond_to(message)\n    assistant_message = AssistantMessage.new(\n      chat: chat,\n      content: \"\",\n      ai_model: message.ai_model\n    )\n\n    responder = Assistant::Responder.new(\n      message: message,\n      instructions: instructions,\n      function_tool_caller: function_tool_caller,\n      llm: get_model_provider(message.ai_model)\n    )\n\n    latest_response_id = chat.latest_assistant_response_id\n\n    responder.on(:output_text) do |text|\n      if assistant_message.content.blank?\n        stop_thinking\n\n        Chat.transaction do\n          assistant_message.append_text!(text)\n          chat.update_latest_response!(latest_response_id)\n        end\n      else\n        assistant_message.append_text!(text)\n      end\n    end\n\n    responder.on(:response) do |data|\n      update_thinking(\"Analyzing your data...\")\n\n      if data[:function_tool_calls].present?\n        assistant_message.tool_calls = data[:function_tool_calls]\n        latest_response_id = data[:id]\n      else\n        chat.update_latest_response!(data[:id])\n      end\n    end\n\n    responder.respond(previous_response_id: latest_response_id)\n  rescue => e\n    stop_thinking\n    chat.add_error(e)\n  end\n\n  private\n    attr_reader :functions\n\n    def function_tool_caller\n      function_instances = functions.map do |fn|\n        fn.new(chat.user)\n      end\n\n      @function_tool_caller ||= FunctionToolCaller.new(function_instances)\n    end\nend\n"
  },
  {
    "path": "app/models/assistant_message.rb",
    "content": "class AssistantMessage < Message\n  validates :ai_model, presence: true\n\n  def role\n    \"assistant\"\n  end\n\n  def append_text!(text)\n    self.content += text\n    save!\n  end\nend\n"
  },
  {
    "path": "app/models/balance/base_calculator.rb",
    "content": "class Balance::BaseCalculator\n  attr_reader :account\n\n  def initialize(account)\n    @account = account\n  end\n\n  def calculate\n    raise NotImplementedError, \"Subclasses must implement this method\"\n  end\n\n  private\n    def sync_cache\n      @sync_cache ||= Balance::SyncCache.new(account)\n    end\n\n    def holdings_value_for_date(date)\n      @holdings_value_for_date ||= {}\n      @holdings_value_for_date[date] ||= sync_cache.get_holdings(date).sum(&:amount)\n    end\n\n    def derive_cash_balance_on_date_from_total(total_balance:, date:)\n      if account.balance_type == :investment\n        total_balance - holdings_value_for_date(date)\n      elsif account.balance_type == :cash\n        total_balance\n      else\n        0\n      end\n    end\n\n    def cash_adjustments_for_date(start_cash, end_cash, net_cash_flows)\n      return 0 unless account.balance_type != :non_cash\n\n      end_cash - start_cash - net_cash_flows\n    end\n\n    def non_cash_adjustments_for_date(start_non_cash, end_non_cash, non_cash_flows)\n      return 0 unless account.balance_type == :non_cash\n\n      end_non_cash - start_non_cash - non_cash_flows\n    end\n\n    # If holdings value goes from $100 -> $200 (change_holdings_value is $100)\n    # And non-cash flows (i.e. \"buys\") for day are +$50 (net_buy_sell_value is $50)\n    # That means value increased by $100, where $50 of that is due to the change in holdings value, and $50 is due to the buy/sell\n    def market_value_change_on_date(date, flows)\n      return 0 unless account.balance_type == :investment\n\n      start_of_day_holdings_value = holdings_value_for_date(date.prev_day)\n      end_of_day_holdings_value = holdings_value_for_date(date)\n\n      change_holdings_value = end_of_day_holdings_value - start_of_day_holdings_value\n      net_buy_sell_value = flows[:non_cash_inflows] - flows[:non_cash_outflows]\n\n      change_holdings_value - net_buy_sell_value\n    end\n\n    def flows_for_date(date)\n      entries = sync_cache.get_entries(date)\n\n      cash_inflows = 0\n      cash_outflows = 0\n      non_cash_inflows = 0\n      non_cash_outflows = 0\n\n      txn_inflow_sum = entries.select { |e| e.amount < 0 && e.transaction? }.sum(&:amount)\n      txn_outflow_sum = entries.select { |e| e.amount >= 0 && e.transaction? }.sum(&:amount)\n\n      trade_cash_inflow_sum = entries.select { |e| e.amount < 0 && e.trade? }.sum(&:amount)\n      trade_cash_outflow_sum = entries.select { |e| e.amount >= 0 && e.trade? }.sum(&:amount)\n\n      if account.balance_type == :non_cash && account.accountable_type == \"Loan\"\n        non_cash_inflows = txn_inflow_sum.abs\n        non_cash_outflows = txn_outflow_sum\n      elsif account.balance_type != :non_cash\n        cash_inflows = txn_inflow_sum.abs + trade_cash_inflow_sum.abs\n        cash_outflows = txn_outflow_sum + trade_cash_outflow_sum\n\n        # Trades are inverse (a \"buy\" is outflow of cash, but \"inflow\" of non-cash, aka \"holdings\")\n        non_cash_outflows = trade_cash_inflow_sum.abs\n        non_cash_inflows = trade_cash_outflow_sum\n      end\n\n      {\n        cash_inflows: cash_inflows,\n        cash_outflows: cash_outflows,\n        non_cash_inflows: non_cash_inflows,\n        non_cash_outflows: non_cash_outflows\n      }\n    end\n\n    def derive_cash_balance(cash_balance, date)\n      entries = sync_cache.get_entries(date)\n\n      if account.balance_type == :non_cash\n        0\n      else\n        cash_balance + signed_entry_flows(entries)\n      end\n    end\n\n    def derive_non_cash_balance(non_cash_balance, date, direction: :forward)\n      entries = sync_cache.get_entries(date)\n      # Loans are a special case (loan payment reducing principal, which is non-cash)\n      if account.balance_type == :non_cash && account.accountable_type == \"Loan\"\n        non_cash_balance + signed_entry_flows(entries)\n      elsif account.balance_type == :investment\n        # For reverse calculations, we need the previous day's holdings\n        target_date = direction == :forward ? date : date.prev_day\n        holdings_value_for_date(target_date)\n      else\n        non_cash_balance\n      end\n    end\n\n    def signed_entry_flows(entries)\n      raise NotImplementedError, \"Directional calculators must implement this method\"\n    end\n\n    def build_balance(date:, **args)\n      Balance.new(\n        account_id: account.id,\n        currency: account.currency,\n        date: date,\n        balance: args[:balance],\n        cash_balance: args[:cash_balance],\n        start_cash_balance: args[:start_cash_balance] || 0,\n        start_non_cash_balance: args[:start_non_cash_balance] || 0,\n        cash_inflows: args[:cash_inflows] || 0,\n        cash_outflows: args[:cash_outflows] || 0,\n        non_cash_inflows: args[:non_cash_inflows] || 0,\n        non_cash_outflows: args[:non_cash_outflows] || 0,\n        cash_adjustments: args[:cash_adjustments] || 0,\n        non_cash_adjustments: args[:non_cash_adjustments] || 0,\n        net_market_flows: args[:net_market_flows] || 0,\n        flows_factor: account.classification == \"asset\" ? 1 : -1\n      )\n    end\nend\n"
  },
  {
    "path": "app/models/balance/chart_series_builder.rb",
    "content": "class Balance::ChartSeriesBuilder\n  def initialize(account_ids:, currency:, period: Period.last_30_days, interval: \"1 day\", favorable_direction: \"up\")\n    @account_ids = account_ids\n    @currency = currency\n    @period = period\n    @interval = interval\n    @favorable_direction = favorable_direction\n  end\n\n  def balance_series\n    build_series_for(:end_balance)\n  rescue => e\n    Rails.logger.error \"Balance series error: #{e.message} for accounts #{@account_ids}\"\n    raise\n  end\n\n  def cash_balance_series\n    build_series_for(:end_cash_balance)\n  rescue => e\n    Rails.logger.error \"Cash balance series error: #{e.message} for accounts #{@account_ids}\"\n    raise\n  end\n\n  def holdings_balance_series\n    build_series_for(:end_holdings_balance)\n  rescue => e\n    Rails.logger.error \"Holdings balance series error: #{e.message} for accounts #{@account_ids}\"\n    raise\n  end\n\n  private\n    attr_reader :account_ids, :currency, :period, :favorable_direction\n\n    def interval\n      @interval || period.interval\n    end\n\n    def build_series_for(column)\n      values = query_data.map do |datum|\n        # Map column names to their start equivalents\n        previous_column = case column\n        when :end_balance then :start_balance\n        when :end_cash_balance then :start_cash_balance\n        when :end_holdings_balance then :start_holdings_balance\n        end\n\n        Series::Value.new(\n          date: datum.date,\n          date_formatted: I18n.l(datum.date, format: :long),\n          value: Money.new(datum.send(column), currency),\n          trend: Trend.new(\n            current: Money.new(datum.send(column), currency),\n            previous: Money.new(datum.send(previous_column), currency),\n            favorable_direction: favorable_direction\n          )\n        )\n      end\n\n      Series.new(\n        start_date: period.start_date,\n        end_date: period.end_date,\n        interval: interval,\n        values: values,\n        favorable_direction: favorable_direction\n      )\n    end\n\n    def query_data\n      @query_data ||= Balance.find_by_sql([\n        query,\n        {\n          account_ids: account_ids,\n          target_currency: currency,\n          start_date: period.start_date,\n          end_date: period.end_date,\n          interval: interval,\n          sign_multiplier: sign_multiplier\n        }\n      ])\n    rescue => e\n      Rails.logger.error \"Query data error: #{e.message} for accounts #{account_ids}, period #{period.start_date} to #{period.end_date}\"\n      raise\n    end\n\n    # Since the query aggregates the *net* of assets - liabilities, this means that if we're looking at\n    # a single liability account, we'll get a negative set of values.  This is not what the user expects\n    # to see.  When favorable direction is \"down\" (i.e. liability, decrease is \"good\"), we need to invert\n    # the values by multiplying by -1.\n    def sign_multiplier\n      favorable_direction == \"down\" ? -1 : 1\n    end\n\n    def query\n      <<~SQL\n        WITH dates AS (\n          SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date\n          UNION DISTINCT\n          SELECT :end_date::date  -- Ensure end date is included\n        )\n        SELECT\n          d.date,\n          -- Use flows_factor: already handles asset (+1) vs liability (-1)\n          COALESCE(SUM(last_bal.end_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_balance,\n          COALESCE(SUM(last_bal.end_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_cash_balance,\n          -- Holdings only for assets (flows_factor = 1)\n          COALESCE(SUM(\n            CASE WHEN last_bal.flows_factor = 1\n              THEN last_bal.end_non_cash_balance\n              ELSE 0\n            END * COALESCE(er.rate, 1) * :sign_multiplier::integer\n          ), 0) AS end_holdings_balance,\n          -- Previous balances\n          COALESCE(SUM(last_bal.start_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_balance,\n          COALESCE(SUM(last_bal.start_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_cash_balance,\n          COALESCE(SUM(\n            CASE WHEN last_bal.flows_factor = 1\n              THEN last_bal.start_non_cash_balance\n              ELSE 0\n            END * COALESCE(er.rate, 1) * :sign_multiplier::integer\n          ), 0) AS start_holdings_balance\n        FROM dates d\n        CROSS JOIN accounts\n        LEFT JOIN LATERAL (\n          SELECT b.end_balance,\n                 b.end_cash_balance,\n                 b.end_non_cash_balance,\n                 b.start_balance,\n                 b.start_cash_balance,\n                 b.start_non_cash_balance,\n                 b.flows_factor\n          FROM balances b\n          WHERE b.account_id = accounts.id\n            AND b.date <= d.date\n          ORDER BY b.date DESC\n          LIMIT 1\n        ) last_bal ON TRUE\n        LEFT JOIN LATERAL (\n          SELECT er.rate\n          FROM exchange_rates er\n          WHERE er.from_currency = accounts.currency\n            AND er.to_currency = :target_currency\n            AND er.date <= d.date\n          ORDER BY er.date DESC\n          LIMIT 1\n        ) er ON TRUE\n        WHERE accounts.id = ANY(array[:account_ids]::uuid[])\n        GROUP BY d.date\n        ORDER BY d.date\n      SQL\n    end\nend\n"
  },
  {
    "path": "app/models/balance/forward_calculator.rb",
    "content": "class Balance::ForwardCalculator < Balance::BaseCalculator\n  def calculate\n    Rails.logger.tagged(\"Balance::ForwardCalculator\") do\n      start_cash_balance = derive_cash_balance_on_date_from_total(\n        total_balance: account.opening_anchor_balance,\n        date: account.opening_anchor_date\n      )\n      start_non_cash_balance = account.opening_anchor_balance - start_cash_balance\n\n      calc_start_date.upto(calc_end_date).map do |date|\n        valuation = sync_cache.get_valuation(date)\n\n        if valuation\n          end_cash_balance = derive_cash_balance_on_date_from_total(\n            total_balance: valuation.amount,\n            date: date\n          )\n          end_non_cash_balance = valuation.amount - end_cash_balance\n        else\n          end_cash_balance = derive_end_cash_balance(start_cash_balance: start_cash_balance, date: date)\n          end_non_cash_balance = derive_end_non_cash_balance(start_non_cash_balance: start_non_cash_balance, date: date)\n        end\n\n        flows = flows_for_date(date)\n        market_value_change = market_value_change_on_date(date, flows)\n\n        cash_adjustments = cash_adjustments_for_date(start_cash_balance, end_cash_balance, (flows[:cash_inflows] - flows[:cash_outflows]) * flows_factor)\n        non_cash_adjustments = non_cash_adjustments_for_date(start_non_cash_balance, end_non_cash_balance, (flows[:non_cash_inflows] - flows[:non_cash_outflows]) * flows_factor)\n\n        output_balance = build_balance(\n          date: date,\n          balance: end_cash_balance + end_non_cash_balance,\n          cash_balance: end_cash_balance,\n          start_cash_balance: start_cash_balance,\n          start_non_cash_balance: start_non_cash_balance,\n          cash_inflows: flows[:cash_inflows],\n          cash_outflows: flows[:cash_outflows],\n          non_cash_inflows: flows[:non_cash_inflows],\n          non_cash_outflows: flows[:non_cash_outflows],\n          cash_adjustments: cash_adjustments,\n          non_cash_adjustments: non_cash_adjustments,\n          net_market_flows: market_value_change\n        )\n\n        # Set values for the next iteration\n        start_cash_balance = end_cash_balance\n        start_non_cash_balance = end_non_cash_balance\n\n        output_balance\n      end\n    end\n  end\n\n  private\n    def calc_start_date\n      account.opening_anchor_date\n    end\n\n    def calc_end_date\n      [ account.entries.order(:date).last&.date, account.holdings.order(:date).last&.date ].compact.max || Date.current\n    end\n\n    # Negative entries amount on an \"asset\" account means, \"account value has increased\"\n    # Negative entries amount on a \"liability\" account means, \"account debt has decreased\"\n    # Positive entries amount on an \"asset\" account means, \"account value has decreased\"\n    # Positive entries amount on a \"liability\" account means, \"account debt has increased\"\n    def signed_entry_flows(entries)\n      entry_flows = entries.sum(&:amount)\n      account.asset? ? -entry_flows : entry_flows\n    end\n\n    # Derives cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance\n    def derive_end_cash_balance(start_cash_balance:, date:)\n      derive_cash_balance(start_cash_balance, date)\n    end\n\n    # Derives non-cash balance, starting from the start-of-day, applying entries in forward to get the end-of-day balance\n    def derive_end_non_cash_balance(start_non_cash_balance:, date:)\n      derive_non_cash_balance(start_non_cash_balance, date, direction: :forward)\n    end\n\n    def flows_factor\n      account.asset? ? 1 : -1\n    end\nend\n"
  },
  {
    "path": "app/models/balance/materializer.rb",
    "content": "class Balance::Materializer\n  attr_reader :account, :strategy\n\n  def initialize(account, strategy:)\n    @account = account\n    @strategy = strategy\n  end\n\n  def materialize_balances\n    Balance.transaction do\n      materialize_holdings\n      calculate_balances\n\n      Rails.logger.info(\"Persisting #{@balances.size} balances\")\n      persist_balances\n\n      purge_stale_balances\n\n      if strategy == :forward\n        update_account_info\n      end\n    end\n  end\n\n  private\n    def materialize_holdings\n      @holdings = Holding::Materializer.new(account, strategy: strategy).materialize_holdings\n    end\n\n    def update_account_info\n      # Query fresh balance from DB to get generated column values\n      current_balance = account.balances\n        .where(currency: account.currency)\n        .order(date: :desc)\n        .first\n\n      if current_balance\n        calculated_balance = current_balance.end_balance\n        calculated_cash_balance = current_balance.end_cash_balance\n      else\n        # Fallback if no balance exists\n        calculated_balance = 0\n        calculated_cash_balance = 0\n      end\n\n      Rails.logger.info(\"Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}\")\n\n      account.update!(\n        balance: calculated_balance,\n        cash_balance: calculated_cash_balance\n      )\n    end\n\n    def calculate_balances\n      @balances = calculator.calculate\n    end\n\n    def persist_balances\n      current_time = Time.now\n      account.balances.upsert_all(\n        @balances.map { |b| b.attributes\n               .slice(\"date\", \"balance\", \"cash_balance\", \"currency\",\n                      \"start_cash_balance\", \"start_non_cash_balance\",\n                      \"cash_inflows\", \"cash_outflows\",\n                      \"non_cash_inflows\", \"non_cash_outflows\",\n                      \"net_market_flows\",\n                      \"cash_adjustments\", \"non_cash_adjustments\",\n                      \"flows_factor\")\n               .merge(\"updated_at\" => current_time) },\n        unique_by: %i[account_id date currency]\n      )\n    end\n\n    def purge_stale_balances\n      sorted_balances = @balances.sort_by(&:date)\n      oldest_calculated_balance_date = sorted_balances.first&.date\n      newest_calculated_balance_date = sorted_balances.last&.date\n      deleted_count = account.balances.delete_by(\"date < ? OR date > ?\", oldest_calculated_balance_date, newest_calculated_balance_date)\n      Rails.logger.info(\"Purged #{deleted_count} stale balances\") if deleted_count > 0\n    end\n\n    def calculator\n      if strategy == :reverse\n        Balance::ReverseCalculator.new(account)\n      else\n        Balance::ForwardCalculator.new(account)\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/balance/reverse_calculator.rb",
    "content": "class Balance::ReverseCalculator < Balance::BaseCalculator\n  def calculate\n    Rails.logger.tagged(\"Balance::ReverseCalculator\") do\n      # Since it's a reverse sync, we're starting with the \"end of day\" balance components and\n      # calculating backwards to derive the \"start of day\" balance components.\n      end_cash_balance = derive_cash_balance_on_date_from_total(\n        total_balance: account.current_anchor_balance,\n        date: account.current_anchor_date\n      )\n      end_non_cash_balance = account.current_anchor_balance - end_cash_balance\n\n      # Calculates in reverse-chronological order (End of day -> Start of day)\n      account.current_anchor_date.downto(account.opening_anchor_date).map do |date|\n        flows = flows_for_date(date)\n\n        if use_opening_anchor_for_date?(date)\n          end_cash_balance = derive_cash_balance_on_date_from_total(\n            total_balance: account.opening_anchor_balance,\n            date: date\n          )\n          end_non_cash_balance = account.opening_anchor_balance - end_cash_balance\n\n          start_cash_balance = end_cash_balance\n          start_non_cash_balance = end_non_cash_balance\n          market_value_change = 0\n        else\n          start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date)\n          start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date)\n          market_value_change = market_value_change_on_date(date, flows)\n        end\n\n        output_balance = build_balance(\n          date: date,\n          balance: end_cash_balance + end_non_cash_balance,\n          cash_balance: end_cash_balance,\n          start_cash_balance: start_cash_balance,\n          start_non_cash_balance: start_non_cash_balance,\n          cash_inflows: flows[:cash_inflows],\n          cash_outflows: flows[:cash_outflows],\n          non_cash_inflows: flows[:non_cash_inflows],\n          non_cash_outflows: flows[:non_cash_outflows],\n          net_market_flows: market_value_change\n        )\n\n        end_cash_balance = start_cash_balance\n        end_non_cash_balance = start_non_cash_balance\n\n        output_balance\n      end\n    end\n  end\n\n  private\n\n    # Negative entries amount on an \"asset\" account means, \"account value has increased\"\n    # Negative entries amount on a \"liability\" account means, \"account debt has decreased\"\n    # Positive entries amount on an \"asset\" account means, \"account value has decreased\"\n    # Positive entries amount on a \"liability\" account means, \"account debt has increased\"\n    def signed_entry_flows(entries)\n      entry_flows = entries.sum(&:amount)\n      account.asset? ? entry_flows : -entry_flows\n    end\n\n    # Alias method, for algorithmic clarity\n    # Derives cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance\n    def derive_start_cash_balance(end_cash_balance:, date:)\n      derive_cash_balance(end_cash_balance, date)\n    end\n\n    # Alias method, for algorithmic clarity\n    # Derives non-cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance\n    def derive_start_non_cash_balance(end_non_cash_balance:, date:)\n      derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse)\n    end\n\n    # Reverse syncs are a bit different than forward syncs because we do not allow \"reconciliation\" valuations\n    # to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed\n    # explanation, see the test suite.\n    def use_opening_anchor_for_date?(date)\n      account.has_opening_anchor? && date == account.opening_anchor_date\n    end\nend\n"
  },
  {
    "path": "app/models/balance/sync_cache.rb",
    "content": "class Balance::SyncCache\n  def initialize(account)\n    @account = account\n  end\n\n  def get_valuation(date)\n    converted_entries.find { |e| e.date == date && e.valuation? }\n  end\n\n  def get_holdings(date)\n    converted_holdings.select { |h| h.date == date }\n  end\n\n  def get_entries(date)\n    converted_entries.select { |e| e.date == date && (e.transaction? || e.trade?) }\n  end\n\n  private\n    attr_reader :account\n\n    def converted_entries\n      @converted_entries ||= account.entries.order(:date).to_a.map do |e|\n        converted_entry = e.dup\n        converted_entry.amount = converted_entry.amount_money.exchange_to(\n          account.currency,\n          date: e.date,\n          fallback_rate: 1\n        ).amount\n        converted_entry.currency = account.currency\n        converted_entry\n      end\n    end\n\n    def converted_holdings\n      @converted_holdings ||= account.holdings.map do |h|\n        converted_holding = h.dup\n        converted_holding.amount = converted_holding.amount_money.exchange_to(\n          account.currency,\n          date: h.date,\n          fallback_rate: 1\n        ).amount\n        converted_holding.currency = account.currency\n        converted_holding\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/balance.rb",
    "content": "class Balance < ApplicationRecord\n  include Monetizable\n\n  belongs_to :account\n\n  validates :account, :date, :balance, presence: true\n  validates :flows_factor, inclusion: { in: [ -1, 1 ] }\n\n  monetize :balance, :cash_balance,\n           :start_cash_balance, :start_non_cash_balance, :start_balance,\n           :cash_inflows, :cash_outflows, :non_cash_inflows, :non_cash_outflows, :net_market_flows,\n           :cash_adjustments, :non_cash_adjustments,\n           :end_cash_balance, :end_non_cash_balance, :end_balance\n\n  scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }\n  scope :chronological, -> { order(:date) }\n\n  def balance_trend\n    Trend.new(\n      current: end_balance_money,\n      previous: start_balance_money,\n      favorable_direction: favorable_direction\n    )\n  end\n\n  private\n\n    def favorable_direction\n      flows_factor == -1 ? \"down\" : \"up\"\n    end\nend\n"
  },
  {
    "path": "app/models/balance_sheet/account_group.rb",
    "content": "class BalanceSheet::AccountGroup\n  include Monetizable\n\n  monetize :total, as: :total_money\n\n  attr_reader :name, :color, :accountable_type, :accounts\n\n  def initialize(name:, color:, accountable_type:, accounts:, classification_group:)\n    @name = name\n    @color = color\n    @accountable_type = accountable_type\n    @accounts = accounts\n    @classification_group = classification_group\n  end\n\n  # A stable DOM id for this group.\n  # Example outputs:\n  #   dom_id(tab: :asset)               # => \"asset_depository\"\n  #   dom_id(tab: :all, mobile: true)   # => \"mobile_all_depository\"\n  #\n  # Keeping all of the logic here means the view layer and broadcaster only\n  # need to ask the object for its DOM id instead of rebuilding string\n  # fragments in multiple places.\n  def dom_id(tab: nil, mobile: false)\n    parts = []\n    parts << \"mobile\" if mobile\n    parts << (tab ? tab.to_s : classification.to_s)\n    parts << key\n    parts.compact.join(\"_\")\n  end\n\n  def key\n    accountable_type.to_s.underscore\n  end\n\n  def total\n    accounts.sum(&:converted_balance)\n  end\n\n  def weight\n    return 0 if classification_group.total.zero?\n\n    total / classification_group.total.to_d * 100\n  end\n\n  def syncing?\n    accounts.any?(&:syncing?)\n  end\n\n  # \"asset\" or \"liability\"\n  def classification\n    classification_group.classification\n  end\n\n  def currency\n    classification_group.currency\n  end\n\n  private\n    attr_reader :classification_group\nend\n"
  },
  {
    "path": "app/models/balance_sheet/account_totals.rb",
    "content": "class BalanceSheet::AccountTotals\n  def initialize(family, sync_status_monitor:)\n    @family = family\n    @sync_status_monitor = sync_status_monitor\n  end\n\n  def asset_accounts\n    @asset_accounts ||= account_rows.filter { |t| t.classification == \"asset\" }\n  end\n\n  def liability_accounts\n    @liability_accounts ||= account_rows.filter { |t| t.classification == \"liability\" }\n  end\n\n  private\n    attr_reader :family, :sync_status_monitor\n\n    AccountRow = Data.define(:account, :converted_balance, :is_syncing) do\n      def syncing? = is_syncing\n\n      # Allows Rails path helpers to generate URLs from the wrapper\n      def to_param = account.to_param\n      delegate_missing_to :account\n    end\n\n    def visible_accounts\n      @visible_accounts ||= family.accounts.visible.with_attached_logo\n    end\n\n    def account_rows\n      @account_rows ||= query.map do |account_row|\n        AccountRow.new(\n          account: account_row,\n          converted_balance: account_row.converted_balance,\n          is_syncing: sync_status_monitor.account_syncing?(account_row)\n        )\n      end\n    end\n\n    def cache_key\n      family.build_cache_key(\n        \"balance_sheet_account_rows\",\n        invalidate_on_data_updates: true\n      )\n    end\n\n    def query\n      @query ||= Rails.cache.fetch(cache_key) do\n        visible_accounts\n          .joins(ActiveRecord::Base.sanitize_sql_array([\n            \"LEFT JOIN exchange_rates ON exchange_rates.date = ? AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?\",\n            Date.current,\n            family.currency\n          ]))\n          .select(\n            \"accounts.*\",\n            \"SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance\"\n          )\n          .group(:classification, :accountable_type, :id)\n          .to_a\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/balance_sheet/classification_group.rb",
    "content": "class BalanceSheet::ClassificationGroup\n  include Monetizable\n\n  monetize :total, as: :total_money\n\n  attr_reader :classification, :currency\n\n  def initialize(classification:, currency:, accounts:)\n    @classification = normalize_classification!(classification)\n    @name = name\n    @currency = currency\n    @accounts = accounts\n  end\n\n  def name\n    classification.titleize.pluralize\n  end\n\n  def icon\n    classification == \"asset\" ? \"plus\" : \"minus\"\n  end\n\n  def total\n    accounts.sum(&:converted_balance)\n  end\n\n  def syncing?\n    accounts.any?(&:syncing?)\n  end\n\n  # For now, we group by accountable type. This can be extended in the future to support arbitrary user groupings.\n  def account_groups\n    groups = accounts.group_by(&:accountable_type)\n                     .transform_keys { |at| Accountable.from_type(at) }\n                     .map do |accountable, account_rows|\n                       BalanceSheet::AccountGroup.new(\n                         name: accountable.display_name,\n                         color: accountable.color,\n                         accountable_type: accountable,\n                         accounts: account_rows,\n                         classification_group: self\n                       )\n                     end\n\n    # Sort the groups using the manual order defined by Accountable::TYPES so that\n    # the UI displays account groups in a predictable, domain-specific sequence.\n    groups.sort_by do |group|\n      manual_order = Accountable::TYPES\n      type_name    = group.key.camelize\n      manual_order.index(type_name) || Float::INFINITY\n    end\n  end\n\n  private\n    attr_reader :accounts\n\n    def normalize_classification!(classification)\n      raise ArgumentError, \"Invalid classification: #{classification}\" unless %w[asset liability].include?(classification)\n      classification\n    end\nend\n"
  },
  {
    "path": "app/models/balance_sheet/net_worth_series_builder.rb",
    "content": "class BalanceSheet::NetWorthSeriesBuilder\n  def initialize(family)\n    @family = family\n  end\n\n  def net_worth_series(period: Period.last_30_days)\n    Rails.cache.fetch(cache_key(period)) do\n      builder = Balance::ChartSeriesBuilder.new(\n        account_ids: visible_account_ids,\n        currency: family.currency,\n        period: period,\n        favorable_direction: \"up\"\n      )\n\n      builder.balance_series\n    end\n  end\n\n  private\n    attr_reader :family\n\n    def visible_account_ids\n      @visible_account_ids ||= family.accounts.visible.with_attached_logo.pluck(:id)\n    end\n\n    def cache_key(period)\n      key = [\n        \"balance_sheet_net_worth_series\",\n        period.start_date,\n        period.end_date\n      ].compact.join(\"_\")\n\n      family.build_cache_key(\n        key,\n        invalidate_on_data_updates: true\n      )\n    end\nend\n"
  },
  {
    "path": "app/models/balance_sheet/sync_status_monitor.rb",
    "content": "class BalanceSheet::SyncStatusMonitor\n  def initialize(family)\n    @family = family\n  end\n\n  def syncing?\n    syncing_account_ids.any?\n  end\n\n  def account_syncing?(account)\n    syncing_account_ids.include?(account.id)\n  end\n\n  private\n    attr_reader :family\n\n    def syncing_account_ids\n      Rails.cache.fetch(cache_key) do\n        Sync.visible\n            .where(syncable_type: \"Account\", syncable_id: family.accounts.visible.pluck(:id))\n            .pluck(:syncable_id)\n            .to_set\n      end\n    end\n\n    # We re-fetch the set of syncing IDs any time a sync that belongs to the family is started or completed.\n    # This ensures we're always fetching the latest sync statuses without re-querying on every page load in idle times (no syncs happening).\n    def cache_key\n      [\n        \"balance_sheet_sync_status\",\n        family.id,\n        family.latest_sync_activity_at\n      ].join(\"_\")\n    end\nend\n"
  },
  {
    "path": "app/models/balance_sheet.rb",
    "content": "class BalanceSheet\n  include Monetizable\n\n  monetize :net_worth\n\n  attr_reader :family\n\n  def initialize(family)\n    @family = family\n  end\n\n  def assets\n    @assets ||= ClassificationGroup.new(\n      classification: \"asset\",\n      currency: family.currency,\n      accounts: account_totals.asset_accounts\n    )\n  end\n\n  def liabilities\n    @liabilities ||= ClassificationGroup.new(\n      classification: \"liability\",\n      currency: family.currency,\n      accounts: account_totals.liability_accounts\n    )\n  end\n\n  def classification_groups\n    [ assets, liabilities ]\n  end\n\n  def account_groups\n    [ assets.account_groups, liabilities.account_groups ].flatten\n  end\n\n  def net_worth\n    assets.total - liabilities.total\n  end\n\n  def net_worth_series(period: Period.last_30_days)\n    net_worth_series_builder.net_worth_series(period: period)\n  end\n\n  def currency\n    family.currency\n  end\n\n  def syncing?\n    sync_status_monitor.syncing?\n  end\n\n  private\n    def sync_status_monitor\n      @sync_status_monitor ||= SyncStatusMonitor.new(family)\n    end\n\n    def account_totals\n      @account_totals ||= AccountTotals.new(family, sync_status_monitor: sync_status_monitor)\n    end\n\n    def net_worth_series_builder\n      @net_worth_series_builder ||= NetWorthSeriesBuilder.new(family)\n    end\nend\n"
  },
  {
    "path": "app/models/budget.rb",
    "content": "class Budget < ApplicationRecord\n  include Monetizable\n\n  PARAM_DATE_FORMAT = \"%b-%Y\"\n\n  belongs_to :family\n\n  has_many :budget_categories, -> { includes(:category) }, dependent: :destroy\n\n  validates :start_date, :end_date, presence: true\n  validates :start_date, :end_date, uniqueness: { scope: :family_id }\n\n  monetize :budgeted_spending, :expected_income, :allocated_spending,\n           :actual_spending, :available_to_spend, :available_to_allocate,\n           :estimated_spending, :estimated_income, :actual_income, :remaining_expected_income\n\n  class << self\n    def date_to_param(date)\n      date.strftime(PARAM_DATE_FORMAT).downcase\n    end\n\n    def param_to_date(param)\n      Date.strptime(param, PARAM_DATE_FORMAT).beginning_of_month\n    end\n\n    def budget_date_valid?(date, family:)\n      beginning_of_month = date.beginning_of_month\n\n      beginning_of_month >= oldest_valid_budget_date(family) && beginning_of_month <= Date.current.end_of_month\n    end\n\n    def find_or_bootstrap(family, start_date:)\n      return nil unless budget_date_valid?(start_date, family: family)\n\n      Budget.transaction do\n        budget = Budget.find_or_create_by!(\n          family: family,\n          start_date: start_date.beginning_of_month,\n          end_date: start_date.end_of_month\n        ) do |b|\n          b.currency = family.currency\n        end\n\n        budget.sync_budget_categories\n\n        budget\n      end\n    end\n\n    private\n      def oldest_valid_budget_date(family)\n        # Allow going back to either the earliest entry date OR 2 years ago, whichever is earlier\n        two_years_ago = 2.years.ago.beginning_of_month\n        oldest_entry_date = family.oldest_entry_date.beginning_of_month\n        [ two_years_ago, oldest_entry_date ].min\n      end\n  end\n\n  def period\n    Period.custom(start_date: start_date, end_date: end_date)\n  end\n\n  def to_param\n    self.class.date_to_param(start_date)\n  end\n\n  def sync_budget_categories\n    current_category_ids = family.categories.expenses.pluck(:id).to_set\n    existing_budget_category_ids = budget_categories.pluck(:category_id).to_set\n    categories_to_add = current_category_ids - existing_budget_category_ids\n    categories_to_remove = existing_budget_category_ids - current_category_ids\n\n    # Create missing categories\n    categories_to_add.each do |category_id|\n      budget_categories.create!(\n        category_id: category_id,\n        budgeted_spending: 0,\n        currency: family.currency\n      )\n    end\n\n    # Remove old categories\n    budget_categories.where(category_id: categories_to_remove).destroy_all if categories_to_remove.any?\n  end\n\n  def uncategorized_budget_category\n    budget_categories.uncategorized.tap do |bc|\n      bc.budgeted_spending = [ available_to_allocate, 0 ].max\n      bc.currency = family.currency\n    end\n  end\n\n  def transactions\n    family.transactions.visible.in_period(period)\n  end\n\n  def name\n    start_date.strftime(\"%B %Y\")\n  end\n\n  def initialized?\n    budgeted_spending.present?\n  end\n\n  def income_category_totals\n    income_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse\n  end\n\n  def expense_category_totals\n    expense_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse\n  end\n\n  def current?\n    start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month\n  end\n\n  def previous_budget_param\n    previous_date = start_date - 1.month\n    return nil unless self.class.budget_date_valid?(previous_date, family: family)\n\n    self.class.date_to_param(previous_date)\n  end\n\n  def next_budget_param\n    return nil if current?\n\n    next_date = start_date + 1.month\n    return nil unless self.class.budget_date_valid?(next_date, family: family)\n\n    self.class.date_to_param(next_date)\n  end\n\n  def to_donut_segments_json\n    unused_segment_id = \"unused\"\n\n    # Continuous gray segment for empty budgets\n    return [ { color: \"var(--budget-unallocated-fill)\", amount: 1, id: unused_segment_id } ] unless allocations_valid?\n\n    segments = budget_categories.map do |bc|\n      { color: bc.category.color, amount: budget_category_actual_spending(bc), id: bc.id }\n    end\n\n    if available_to_spend.positive?\n      segments.push({ color: \"var(--budget-unallocated-fill)\", amount: available_to_spend, id: unused_segment_id })\n    end\n\n    segments\n  end\n\n  # =============================================================================\n  # Actuals: How much user has spent on each budget category\n  # =============================================================================\n  def estimated_spending\n    income_statement.median_expense(interval: \"month\")\n  end\n\n  def actual_spending\n    expense_totals.total\n  end\n\n  def budget_category_actual_spending(budget_category)\n    expense_totals.category_totals.find { |ct| ct.category.id == budget_category.category.id }&.total || 0\n  end\n\n  def category_median_monthly_expense(category)\n    income_statement.median_expense(category: category)\n  end\n\n  def category_avg_monthly_expense(category)\n    income_statement.avg_expense(category: category)\n  end\n\n  def available_to_spend\n    (budgeted_spending || 0) - actual_spending\n  end\n\n  def percent_of_budget_spent\n    return 0 unless budgeted_spending > 0\n\n    (actual_spending / budgeted_spending.to_f) * 100\n  end\n\n  def overage_percent\n    return 0 unless available_to_spend.negative?\n\n    available_to_spend.abs / actual_spending.to_f * 100\n  end\n\n  # =============================================================================\n  # Budget allocations: How much user has budgeted for all parent categories combined\n  # =============================================================================\n  def allocated_spending\n    budget_categories.reject { |bc| bc.subcategory? }.sum(&:budgeted_spending)\n  end\n\n  def allocated_percent\n    return 0 unless budgeted_spending && budgeted_spending > 0\n\n    (allocated_spending / budgeted_spending.to_f) * 100\n  end\n\n  def available_to_allocate\n    (budgeted_spending || 0) - allocated_spending\n  end\n\n  def allocations_valid?\n    initialized? && available_to_allocate >= 0 && allocated_spending > 0\n  end\n\n  # =============================================================================\n  # Income: How much user earned relative to what they expected to earn\n  # =============================================================================\n  def estimated_income\n    family.income_statement.median_income(interval: \"month\")\n  end\n\n  def actual_income\n    family.income_statement.income_totals(period: self.period).total\n  end\n\n  def actual_income_percent\n    return 0 unless expected_income > 0\n\n    (actual_income / expected_income.to_f) * 100\n  end\n\n  def remaining_expected_income\n    expected_income - actual_income\n  end\n\n  def surplus_percent\n    return 0 unless remaining_expected_income.negative?\n\n    remaining_expected_income.abs / expected_income.to_f * 100\n  end\n\n  private\n    def income_statement\n      @income_statement ||= family.income_statement\n    end\n\n    def expense_totals\n      @expense_totals ||= income_statement.expense_totals(period: period)\n    end\n\n    def income_totals\n      @income_totals ||= family.income_statement.income_totals(period: period)\n    end\nend\n"
  },
  {
    "path": "app/models/budget_category.rb",
    "content": "class BudgetCategory < ApplicationRecord\n  include Monetizable\n\n  belongs_to :budget\n  belongs_to :category\n\n  validates :budget_id, uniqueness: { scope: :category_id }\n\n  monetize :budgeted_spending, :available_to_spend, :avg_monthly_expense, :median_monthly_expense, :actual_spending\n\n  class Group\n    attr_reader :budget_category, :budget_subcategories\n\n    delegate :category, to: :budget_category\n    delegate :name, :color, to: :category\n\n    def self.for(budget_categories)\n      top_level_categories = budget_categories.select { |budget_category| budget_category.category.parent_id.nil? }\n      top_level_categories.map do |top_level_category|\n        subcategories = budget_categories.select { |bc| bc.category.parent_id == top_level_category.category_id && top_level_category.category_id.present? }\n        new(top_level_category, subcategories.sort_by { |subcategory| subcategory.category.name })\n      end.sort_by { |group| group.category.name }\n    end\n\n    def initialize(budget_category, budget_subcategories = [])\n      @budget_category = budget_category\n      @budget_subcategories = budget_subcategories\n    end\n  end\n\n  class << self\n    def uncategorized\n      new(\n        id: Digest::UUID.uuid_v5(Digest::UUID::URL_NAMESPACE, \"uncategorized\"),\n        category: nil,\n      )\n    end\n  end\n\n  def initialized?\n    budget.initialized?\n  end\n\n  def category\n    super || budget.family.categories.uncategorized\n  end\n\n  def name\n    category.name\n  end\n\n  def actual_spending\n    budget.budget_category_actual_spending(self)\n  end\n\n  def avg_monthly_expense\n    budget.category_avg_monthly_expense(category)\n  end\n\n  def median_monthly_expense\n    budget.category_median_monthly_expense(category)\n  end\n\n  def subcategory?\n    category.parent_id.present?\n  end\n\n  def available_to_spend\n    (budgeted_spending || 0) - actual_spending\n  end\n\n  def percent_of_budget_spent\n    return 0 unless budgeted_spending > 0\n\n    (actual_spending / budgeted_spending) * 100\n  end\n\n  def to_donut_segments_json\n    unused_segment_id = \"unused\"\n    overage_segment_id = \"overage\"\n\n    return [ { color: \"var(--budget-unallocated-fill)\", amount: 1, id: unused_segment_id } ] unless actual_spending > 0\n\n    segments = [ { color: category.color, amount: actual_spending, id: id } ]\n\n    if available_to_spend.negative?\n      segments.push({ color: \"var(--color-destructive)\", amount: available_to_spend.abs, id: overage_segment_id })\n    else\n      segments.push({ color: \"var(--budget-unallocated-fill)\", amount: available_to_spend, id: unused_segment_id })\n    end\n\n    segments\n  end\n\n  def siblings\n    budget.budget_categories.select { |bc| bc.category.parent_id == category.parent_id && bc.id != id }\n  end\n\n  def max_allocation\n    return nil unless subcategory?\n\n    parent_budget = budget.budget_categories.find { |bc| bc.category.id == category.parent_id }&.budgeted_spending\n    siblings_budget = siblings.sum(&:budgeted_spending)\n\n    [ parent_budget - siblings_budget, 0 ].max\n  end\n\n  def subcategories\n    return BudgetCategory.none unless category.parent_id.nil?\n\n    budget.budget_categories\n      .joins(:category)\n      .where(categories: { parent_id: category.id })\n  end\n\n  def subcategory?\n    category.parent_id.present?\n  end\nend\n"
  },
  {
    "path": "app/models/category.rb",
    "content": "class Category < ApplicationRecord\n  has_many :transactions, dependent: :nullify, class_name: \"Transaction\"\n  has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: \"Import::Mapping\"\n\n  belongs_to :family\n\n  has_many :budget_categories, dependent: :destroy\n  has_many :subcategories, class_name: \"Category\", foreign_key: :parent_id, dependent: :nullify\n  belongs_to :parent, class_name: \"Category\", optional: true\n\n  validates :name, :color, :lucide_icon, :family, presence: true\n  validates :name, uniqueness: { scope: :family_id }\n\n  validate :category_level_limit\n  validate :nested_category_matches_parent_classification\n\n  before_save :inherit_color_from_parent\n\n  scope :alphabetically, -> { order(:name) }\n  scope :roots, -> { where(parent_id: nil) }\n  scope :incomes, -> { where(classification: \"income\") }\n  scope :expenses, -> { where(classification: \"expense\") }\n\n  COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]\n\n  UNCATEGORIZED_COLOR = \"#737373\"\n  TRANSFER_COLOR = \"#444CE7\"\n  PAYMENT_COLOR = \"#db5a54\"\n  TRADE_COLOR = \"#e99537\"\n\n  class Group\n    attr_reader :category, :subcategories\n\n    delegate :name, :color, to: :category\n\n    def self.for(categories)\n      categories.select { |category| category.parent_id.nil? }.map do |category|\n        new(category, category.subcategories)\n      end\n    end\n\n    def initialize(category, subcategories = nil)\n      @category = category\n      @subcategories = subcategories || []\n    end\n  end\n\n  class << self\n    def icon_codes\n      %w[bus circle-dollar-sign ambulance apple award baby battery lightbulb bed-single beer bluetooth book briefcase building credit-card camera utensils cooking-pot cookie dices drama dog drill drum dumbbell gamepad-2 graduation-cap house hand-helping ice-cream-cone phone piggy-bank pill pizza printer puzzle ribbon shopping-cart shield-plus ticket trees]\n    end\n\n    def bootstrap!\n      default_categories.each do |name, color, icon, classification|\n        find_or_create_by!(name: name) do |category|\n          category.color = color\n          category.classification = classification\n          category.lucide_icon = icon\n        end\n      end\n    end\n\n    def uncategorized\n      new(\n        name: \"Uncategorized\",\n        color: UNCATEGORIZED_COLOR,\n        lucide_icon: \"circle-dashed\"\n      )\n    end\n\n    private\n      def default_categories\n        [\n          [ \"Income\", \"#e99537\", \"circle-dollar-sign\", \"income\" ],\n          [ \"Loan Payments\", \"#6471eb\", \"credit-card\", \"expense\" ],\n          [ \"Fees\", \"#6471eb\", \"credit-card\", \"expense\" ],\n          [ \"Entertainment\", \"#df4e92\", \"drama\", \"expense\" ],\n          [ \"Food & Drink\", \"#eb5429\", \"utensils\", \"expense\" ],\n          [ \"Shopping\", \"#e99537\", \"shopping-cart\", \"expense\" ],\n          [ \"Home Improvement\", \"#6471eb\", \"house\", \"expense\" ],\n          [ \"Healthcare\", \"#4da568\", \"pill\", \"expense\" ],\n          [ \"Personal Care\", \"#4da568\", \"pill\", \"expense\" ],\n          [ \"Services\", \"#4da568\", \"briefcase\", \"expense\" ],\n          [ \"Gifts & Donations\", \"#61c9ea\", \"hand-helping\", \"expense\" ],\n          [ \"Transportation\", \"#df4e92\", \"bus\", \"expense\" ],\n          [ \"Travel\", \"#df4e92\", \"plane\", \"expense\" ],\n          [ \"Rent & Utilities\", \"#db5a54\", \"lightbulb\", \"expense\" ]\n        ]\n      end\n  end\n\n  def inherit_color_from_parent\n    if subcategory?\n      self.color = parent.color\n    end\n  end\n\n  def replace_and_destroy!(replacement)\n    transaction do\n      transactions.update_all category_id: replacement&.id\n      destroy!\n    end\n  end\n\n  def parent?\n    subcategories.any?\n  end\n\n  def subcategory?\n    parent.present?\n  end\n\n  private\n    def category_level_limit\n      if (subcategory? && parent.subcategory?) || (parent? && subcategory?)\n        errors.add(:parent, \"can't have more than 2 levels of subcategories\")\n      end\n    end\n\n    def nested_category_matches_parent_classification\n      if subcategory? && parent.classification != classification\n        errors.add(:parent, \"must have the same classification as its parent\")\n      end\n    end\n\n    def monetizable_currency\n      family.currency\n    end\nend\n"
  },
  {
    "path": "app/models/chat/debuggable.rb",
    "content": "module Chat::Debuggable\n  extend ActiveSupport::Concern\n\n  def debug_mode?\n    ENV[\"AI_DEBUG_MODE\"] == \"true\"\n  end\nend\n"
  },
  {
    "path": "app/models/chat.rb",
    "content": "class Chat < ApplicationRecord\n  include Debuggable\n\n  belongs_to :user\n\n  has_one :viewer, class_name: \"User\", foreign_key: :last_viewed_chat_id, dependent: :nullify # \"Last chat user has viewed\"\n  has_many :messages, dependent: :destroy\n\n  validates :title, presence: true\n\n  scope :ordered, -> { order(created_at: :desc) }\n\n  class << self\n    def start!(prompt, model:)\n      create!(\n        title: generate_title(prompt),\n        messages: [ UserMessage.new(content: prompt, ai_model: model) ]\n      )\n    end\n\n    def generate_title(prompt)\n      prompt.first(80)\n    end\n  end\n\n  def needs_assistant_response?\n    conversation_messages.ordered.last.role != \"assistant\"\n  end\n\n  def retry_last_message!\n    update!(error: nil)\n\n    last_message = conversation_messages.ordered.last\n\n    if last_message.present? && last_message.role == \"user\"\n\n      ask_assistant_later(last_message)\n    end\n  end\n\n  def update_latest_response!(provider_response_id)\n    update!(latest_assistant_response_id: provider_response_id)\n  end\n\n  def add_error(e)\n    update! error: e.to_json\n    broadcast_append target: \"messages\", partial: \"chats/error\", locals: { chat: self }\n  end\n\n  def clear_error\n    update! error: nil\n    broadcast_remove target: \"chat-error\"\n  end\n\n  def assistant\n    @assistant ||= Assistant.for_chat(self)\n  end\n\n  def ask_assistant_later(message)\n    clear_error\n    AssistantResponseJob.perform_later(message)\n  end\n\n  def ask_assistant(message)\n    assistant.respond_to(message)\n  end\n\n  def conversation_messages\n    if debug_mode?\n      messages\n    else\n      messages.where(type: [ \"UserMessage\", \"AssistantMessage\" ])\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/concerns/accountable.rb",
    "content": "module Accountable\n  extend ActiveSupport::Concern\n\n  TYPES = %w[Depository Investment Crypto Property Vehicle OtherAsset CreditCard Loan OtherLiability]\n\n  # Define empty hash to ensure all accountables have this defined\n  SUBTYPES = {}.freeze\n\n  def self.from_type(type)\n    return nil unless TYPES.include?(type)\n    type.constantize\n  end\n\n  included do\n    include Enrichable\n\n    has_one :account, as: :accountable, touch: true\n  end\n\n  class_methods do\n    def classification\n      raise NotImplementedError, \"Accountable must implement #classification\"\n    end\n\n    def icon\n      raise NotImplementedError, \"Accountable must implement #icon\"\n    end\n\n    def color\n      raise NotImplementedError, \"Accountable must implement #color\"\n    end\n\n    # Given a subtype, look up the label for this accountable type\n    def subtype_label_for(subtype, format: :short)\n      return nil if subtype.nil?\n\n      label_type = format == :long ? :long : :short\n      self::SUBTYPES[subtype]&.fetch(label_type, nil)\n    end\n\n    # Convenience method for getting the short label\n    def short_subtype_label_for(subtype)\n      subtype_label_for(subtype, format: :short)\n    end\n\n    # Convenience method for getting the long label\n    def long_subtype_label_for(subtype)\n      subtype_label_for(subtype, format: :long)\n    end\n\n    def favorable_direction\n      classification == \"asset\" ? \"up\" : \"down\"\n    end\n\n    def display_name\n      self.name.pluralize.titleize\n    end\n\n    def balance_money(family)\n      family.accounts\n            .active\n            .joins(sanitize_sql_array([\n              \"LEFT JOIN exchange_rates ON exchange_rates.date = :current_date AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = :family_currency\",\n              { current_date: Date.current.to_s, family_currency: family.currency }\n            ]))\n            .where(accountable_type: self.name)\n            .sum(\"accounts.balance * COALESCE(exchange_rates.rate, 1)\")\n    end\n  end\n\n  def display_name\n    self.class.display_name\n  end\n\n  def balance_display_name\n    \"account value\"\n  end\n\n  def opening_balance_display_name\n    \"opening balance\"\n  end\n\n  def icon\n    self.class.icon\n  end\n\n  def color\n    self.class.color\n  end\n\n  def classification\n    self.class.classification\n  end\nend\n"
  },
  {
    "path": "app/models/concerns/enrichable.rb",
    "content": "# Enrichable models can have 1+ of their fields enriched by various\n# external sources (i.e. Plaid) or internal sources (i.e. Rules)\n#\n# This module defines how models should, lock, unlock, and edit attributes\n# based on the source of the edit.  User edits always take highest precedence.\n#\n# For example:\n#\n# If a Rule tells us to set the category to \"Groceries\", but the user later overrides\n# a transaction with a category of \"Food\", we should not override the category again.\n#\nmodule Enrichable\n  extend ActiveSupport::Concern\n\n  InvalidAttributeError = Class.new(StandardError)\n\n  included do\n    scope :enrichable, ->(attrs) {\n      attrs = Array(attrs).map(&:to_s)\n      json_condition = attrs.each_with_object({}) { |attr, hash| hash[attr] = true }\n      where.not(Arel.sql(\"#{table_name}.locked_attributes ?| array[:keys]\"), keys: attrs)\n    }\n  end\n\n  # Convenience method for a single attribute\n  def enrich_attribute(attr, value, source:, metadata: {})\n    enrich_attributes({ attr => value }, source:, metadata:)\n  end\n\n  # Enriches and logs all attributes that:\n  # - Are not locked\n  # - Are not ignored\n  # - Have changed value from the last saved value\n  def enrich_attributes(attrs, source:, metadata: {})\n    enrichable_attrs = Array(attrs).reject do |attr_key, attr_value|\n      locked?(attr_key) || ignored_enrichable_attributes.include?(attr_key) || self[attr_key.to_s] == attr_value\n    end\n\n    ActiveRecord::Base.transaction do\n      enrichable_attrs.each do |attr, value|\n        self.send(\"#{attr}=\", value)\n\n        # If it's a new record, this isn't technically an \"enrichment\".  No logging necessary.\n        unless self.new_record?\n          log_enrichment(attribute_name: attr, attribute_value: value, source: source, metadata: metadata)\n        end\n      end\n\n      save\n    end\n  end\n\n  def locked?(attr)\n    locked_attributes[attr.to_s].present?\n  end\n\n  def enrichable?(attr)\n    !locked?(attr)\n  end\n\n  def lock_attr!(attr)\n    update!(locked_attributes: locked_attributes.merge(attr.to_s => Time.current))\n  end\n\n  def unlock_attr!(attr)\n    update!(locked_attributes: locked_attributes.except(attr.to_s))\n  end\n\n  def lock_saved_attributes!\n    saved_changes.keys.reject { |attr| ignored_enrichable_attributes.include?(attr) }.each do |attr|\n      lock_attr!(attr)\n    end\n  end\n\n  private\n    def log_enrichment(attribute_name:, attribute_value:, source:, metadata: {})\n      de = DataEnrichment.find_or_create_by(\n        enrichable: self,\n        attribute_name: attribute_name,\n        source: source,\n      )\n\n      de.value = attribute_value\n      de.metadata = metadata\n      de.save\n    end\n\n    def ignored_enrichable_attributes\n      %w[id updated_at created_at]\n    end\nend\n"
  },
  {
    "path": "app/models/concerns/monetizable.rb",
    "content": "module Monetizable\n  extend ActiveSupport::Concern\n\n  class_methods do\n    def monetize(*fields)\n      fields.each do |field|\n        define_method(\"#{field}_money\") do |**args|\n          value = self.send(field, **args)\n\n          return nil if value.nil? || monetizable_currency.nil?\n\n          Money.new(value, monetizable_currency)\n        end\n      end\n    end\n  end\n\n  private\n    def monetizable_currency\n      currency\n    end\nend\n"
  },
  {
    "path": "app/models/concerns/syncable.rb",
    "content": "module Syncable\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :syncs, as: :syncable, dependent: :destroy\n  end\n\n  def syncing?\n    syncs.visible.any?\n  end\n\n  # Schedules a sync for syncable.  If there is an existing sync pending/syncing for this syncable,\n  # we do not create a new sync, and attempt to expand the sync window if needed.\n  def sync_later(parent_sync: nil, window_start_date: nil, window_end_date: nil)\n    Sync.transaction do\n      with_lock do\n        sync = self.syncs.incomplete.first\n\n        if sync\n          Rails.logger.info(\"There is an existing sync, expanding window if needed (#{sync.id})\")\n          sync.expand_window_if_needed(window_start_date, window_end_date)\n        else\n          sync = self.syncs.create!(\n            parent: parent_sync,\n            window_start_date: window_start_date,\n            window_end_date: window_end_date\n          )\n\n          SyncJob.perform_later(sync)\n        end\n\n        sync\n      end\n    end\n  end\n\n  def perform_sync(sync)\n    syncer.perform_sync(sync)\n  end\n\n  def perform_post_sync\n    syncer.perform_post_sync\n  end\n\n  def broadcast_sync_complete\n    sync_broadcaster.broadcast\n  end\n\n  def sync_error\n    latest_sync&.error || latest_sync&.children&.map(&:error)&.compact&.first\n  end\n\n  def last_synced_at\n    latest_sync&.completed_at\n  end\n\n  def last_sync_created_at\n    latest_sync&.created_at\n  end\n\n  private\n    def latest_sync\n      syncs.ordered.first\n    end\n\n    def syncer\n      self.class::Syncer.new(self)\n    end\n\n    def sync_broadcaster\n      self.class::SyncCompleteEvent.new(self)\n    end\nend\n"
  },
  {
    "path": "app/models/credit_card.rb",
    "content": "class CreditCard < ApplicationRecord\n  include Accountable\n\n  SUBTYPES = {\n    \"credit_card\" => { short: \"Credit Card\", long: \"Credit Card\" }\n  }.freeze\n\n  class << self\n    def color\n      \"#F13636\"\n    end\n\n    def icon\n      \"credit-card\"\n    end\n\n    def classification\n      \"liability\"\n    end\n  end\n\n  def available_credit_money\n    available_credit ? Money.new(available_credit, account.currency) : nil\n  end\n\n  def minimum_payment_money\n    minimum_payment ? Money.new(minimum_payment, account.currency) : nil\n  end\n\n  def annual_fee_money\n    annual_fee ? Money.new(annual_fee, account.currency) : nil\n  end\nend\n"
  },
  {
    "path": "app/models/crypto.rb",
    "content": "class Crypto < ApplicationRecord\n  include Accountable\n\n  class << self\n    def color\n      \"#737373\"\n    end\n\n    def classification\n      \"asset\"\n    end\n\n    def icon\n      \"bitcoin\"\n    end\n\n    def display_name\n      \"Crypto\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/current.rb",
    "content": "class Current < ActiveSupport::CurrentAttributes\n  attribute :user_agent, :ip_address\n\n  attribute :session\n\n  delegate :family, to: :user, allow_nil: true\n\n  def user\n    impersonated_user || session&.user\n  end\n\n  def impersonated_user\n    session&.active_impersonator_session&.impersonated\n  end\n\n  def true_user\n    session&.user\n  end\nend\n"
  },
  {
    "path": "app/models/data_enrichment.rb",
    "content": "class DataEnrichment < ApplicationRecord\n  belongs_to :enrichable, polymorphic: true\n\n  enum :source, { rule: \"rule\", plaid: \"plaid\", synth: \"synth\", ai: \"ai\" }\nend\n"
  },
  {
    "path": "app/models/demo/data_cleaner.rb",
    "content": "# SAFETY: Only operates in development/test environments to prevent data loss\nclass Demo::DataCleaner\n  SAFE_ENVIRONMENTS = %w[development test]\n\n  def initialize\n    ensure_safe_environment!\n  end\n\n  # Main entry point for destroying all demo data\n  def destroy_everything!\n    Family.destroy_all\n    Setting.destroy_all\n    InviteCode.destroy_all\n    ExchangeRate.destroy_all\n    Security.destroy_all\n    Security::Price.destroy_all\n\n    puts \"Data cleared\"\n  end\n\n  private\n\n    def ensure_safe_environment!\n      unless SAFE_ENVIRONMENTS.include?(Rails.env)\n        raise SecurityError, \"Demo::DataCleaner can only be used in #{SAFE_ENVIRONMENTS.join(', ')} environments. Current: #{Rails.env}\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/demo/generator.rb",
    "content": "class Demo::Generator\n  # @param seed [Integer, String, nil] Seed value used to initialise the internal PRNG. If nil, the ENV variable DEMO_DATA_SEED will\n  #   be honoured and default to a random seed when not present.\n  #\n  # Initialising an explicit PRNG gives us repeatable demo datasets while still\n  #   allowing truly random data when the caller does not care about\n  #   determinism.  The global `Kernel.rand` and helpers like `Array#sample`\n  #   will also be seeded so that *all* random behaviour inside this object –\n  #   including library helpers that rely on Ruby's global RNG – follow the\n  #   same deterministic sequence.\n  def initialize(seed: ENV.fetch(\"DEMO_DATA_SEED\", nil))\n    # Convert the seed to an Integer if one was provided, otherwise fall back\n    # to a random, but memoised, seed so the generator instance can report it\n    # back to callers when needed (e.g. for debugging a specific run).\n    @seed = seed.present? ? seed.to_i : Random.new_seed\n\n    # Internal PRNG instance – use this instead of the global RNG wherever we\n    # explicitly call `rand` inside the class.  We override `rand` below so\n    # existing method bodies automatically delegate here without requiring\n    # widespread refactors.\n    @rng = Random.new(@seed)\n\n    # Also seed Ruby's global RNG so helpers that rely on it (e.g.\n    # Array#sample, Kernel.rand in invoked libraries, etc.) remain\n    # deterministic for the lifetime of this generator instance.\n    srand(@seed)\n  end\n\n  # Expose the seed so callers can reproduce a run if necessary.\n  attr_reader :seed\n\n  # Generate empty family - no financial data\n  def generate_empty_data!(skip_clear: false)\n    with_timing(__method__) do\n      unless skip_clear\n        puts \"🧹 Clearing existing data...\"\n        clear_all_data!\n      end\n\n      puts \"👥 Creating empty family...\"\n      create_family_and_users!(\"Demo Family\", \"user@maybe.local\", onboarded: true, subscribed: true)\n\n      puts \"✅ Empty demo data loaded successfully!\"\n    end\n  end\n\n  # Generate new user family - no financial data, needs onboarding\n  def generate_new_user_data!(skip_clear: false)\n    with_timing(__method__) do\n      unless skip_clear\n        puts \"🧹 Clearing existing data...\"\n        clear_all_data!\n      end\n\n      puts \"👥 Creating new user family...\"\n      create_family_and_users!(\"Demo Family\", \"user@maybe.local\", onboarded: false, subscribed: false)\n\n      puts \"✅ New user demo data loaded successfully!\"\n    end\n  end\n\n  # Generate comprehensive realistic demo data with multi-currency\n  def generate_default_data!(skip_clear: false, email: \"user@maybe.local\")\n    if skip_clear\n      puts \"⏭️  Skipping data clearing (appending new family)...\"\n    else\n      puts \"🧹 Clearing existing data...\"\n      clear_all_data!\n    end\n\n    with_timing(__method__, max_seconds: 1000) do\n      puts \"👥 Creating demo family...\"\n      family = create_family_and_users!(\"Demo Family\", email, onboarded: true, subscribed: true)\n\n      puts \"📊 Creating realistic financial data...\"\n      create_realistic_categories!(family)\n      create_realistic_accounts!(family)\n      create_realistic_transactions!(family)\n      # Auto-fill current-month budget based on recent spending averages\n      generate_budget_auto_fill!(family)\n\n      puts \"✅ Realistic demo data loaded successfully!\"\n    end\n  end\n\n  private\n\n    # Simple timing helper. Pass a descriptive label and a block; the runtime\n    # will be printed automatically when the block completes.\n    # If max_seconds is provided, raise RuntimeError when the block exceeds that\n    # duration.  Useful to keep CI/dev machines honest about demo-data perf.\n    def with_timing(label, max_seconds: nil)\n      start = Process.clock_gettime(Process::CLOCK_MONOTONIC)\n      result = yield\n      duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start\n      puts \"⏱️  #{label} completed in #{duration.round(2)}s\"\n\n      if max_seconds && duration > max_seconds\n        raise \"Demo::Generator ##{label} exceeded #{max_seconds}s (#{duration.round(2)}s)\"\n      end\n\n      result\n    end\n\n    # Override Kernel#rand so *all* `rand` calls inside this instance (including\n    # those already present in the file) are routed through the seeded PRNG.\n    def rand(*args)\n      @rng.rand(*args)\n    end\n\n\n\n    def clear_all_data!\n      family_count = Family.count\n      if family_count > 50\n        raise \"Too much data to clear efficiently (#{family_count} families). Run 'rails db:reset' instead.\"\n      end\n      Demo::DataCleaner.new.destroy_everything!\n    end\n\n    def create_family_and_users!(family_name, email, onboarded:, subscribed:)\n      family = Family.create!(\n        name: family_name,\n        currency: \"USD\",\n        locale: \"en\",\n        country: \"US\",\n        timezone: \"America/New_York\",\n        date_format: \"%m-%d-%Y\"\n      )\n\n      family.start_subscription!(\"sub_demo_123\") if subscribed\n\n      # Admin user\n      family.users.create!(\n        email: email,\n        first_name: \"Demo (admin)\",\n        last_name: \"Maybe\",\n        role: \"admin\",\n        password: \"password\",\n        onboarded_at: onboarded ? Time.current : nil\n      )\n\n      # Member user\n      family.users.create!(\n        email: \"partner_#{email}\",\n        first_name: \"Demo (member)\",\n        last_name: \"Maybe\",\n        role: \"member\",\n        password: \"password\",\n        onboarded_at: onboarded ? Time.current : nil\n      )\n\n      family\n    end\n\n    def create_realistic_categories!(family)\n      # Income categories (3 total)\n      @salary_cat = family.categories.create!(name: \"Salary\", color: \"#10b981\", classification: \"income\")\n      @freelance_cat = family.categories.create!(name: \"Freelance\", color: \"#059669\", classification: \"income\")\n      @investment_income_cat = family.categories.create!(name: \"Investment Income\", color: \"#047857\", classification: \"income\")\n\n      # Expense categories with subcategories (12 total)\n      @housing_cat = family.categories.create!(name: \"Housing\", color: \"#dc2626\", classification: \"expense\")\n      @rent_cat = family.categories.create!(name: \"Rent/Mortgage\", parent: @housing_cat, color: \"#b91c1c\", classification: \"expense\")\n      @utilities_cat = family.categories.create!(name: \"Utilities\", parent: @housing_cat, color: \"#991b1b\", classification: \"expense\")\n\n      @food_cat = family.categories.create!(name: \"Food & Dining\", color: \"#ea580c\", classification: \"expense\")\n      @groceries_cat = family.categories.create!(name: \"Groceries\", parent: @food_cat, color: \"#c2410c\", classification: \"expense\")\n      @restaurants_cat = family.categories.create!(name: \"Restaurants\", parent: @food_cat, color: \"#9a3412\", classification: \"expense\")\n      @coffee_cat = family.categories.create!(name: \"Coffee & Takeout\", parent: @food_cat, color: \"#7c2d12\", classification: \"expense\")\n\n      @transportation_cat = family.categories.create!(name: \"Transportation\", color: \"#2563eb\", classification: \"expense\")\n      @gas_cat = family.categories.create!(name: \"Gas\", parent: @transportation_cat, color: \"#1d4ed8\", classification: \"expense\")\n      @car_payment_cat = family.categories.create!(name: \"Car Payment\", parent: @transportation_cat, color: \"#1e40af\", classification: \"expense\")\n\n      @entertainment_cat = family.categories.create!(name: \"Entertainment\", color: \"#7c3aed\", classification: \"expense\")\n      @healthcare_cat = family.categories.create!(name: \"Healthcare\", color: \"#db2777\", classification: \"expense\")\n      @shopping_cat = family.categories.create!(name: \"Shopping\", color: \"#059669\", classification: \"expense\")\n      @travel_cat = family.categories.create!(name: \"Travel\", color: \"#0891b2\", classification: \"expense\")\n      @personal_care_cat = family.categories.create!(name: \"Personal Care\", color: \"#be185d\", classification: \"expense\")\n\n      # Additional high-level expense categories to reach 13 top-level items\n      @insurance_cat = family.categories.create!(name: \"Insurance\", color: \"#6366f1\", classification: \"expense\")\n      @misc_cat      = family.categories.create!(name: \"Miscellaneous\", color: \"#6b7280\", classification: \"expense\")\n\n      # Interest expense bucket\n      @interest_cat = family.categories.create!(name: \"Loan Interest\", color: \"#475569\", classification: \"expense\")\n    end\n\n    def create_realistic_accounts!(family)\n      # Checking accounts (USD)\n      @chase_checking = family.accounts.create!(accountable: Depository.new, name: \"Chase Premier Checking\", balance: 0, currency: \"USD\")\n      @ally_checking = family.accounts.create!(accountable: Depository.new, name: \"Ally Online Checking\", balance: 0, currency: \"USD\")\n\n      # Savings account (USD)\n      @marcus_savings = family.accounts.create!(accountable: Depository.new, name: \"Marcus High-Yield Savings\", balance: 0, currency: \"USD\")\n\n      # EUR checking (EUR)\n      @eu_checking = family.accounts.create!(accountable: Depository.new, name: \"Deutsche Bank EUR Account\", balance: 0, currency: \"EUR\")\n\n      # Credit cards (USD)\n      @amex_gold = family.accounts.create!(accountable: CreditCard.new, name: \"Amex Gold Card\", balance: 0, currency: \"USD\")\n      @chase_sapphire = family.accounts.create!(accountable: CreditCard.new, name: \"Chase Sapphire Reserve\", balance: 0, currency: \"USD\")\n\n      # Investment accounts (USD + GBP)\n      @vanguard_401k     = family.accounts.create!(accountable: Investment.new, name: \"Vanguard 401(k)\", balance: 0, currency: \"USD\")\n      @schwab_brokerage  = family.accounts.create!(accountable: Investment.new, name: \"Charles Schwab Brokerage\", balance: 0, currency: \"USD\")\n      @fidelity_roth_ira = family.accounts.create!(accountable: Investment.new, name: \"Fidelity Roth IRA\", balance: 0, currency: \"USD\")\n      @hsa_investment    = family.accounts.create!(accountable: Investment.new, name: \"Fidelity HSA Investment\", balance: 0, currency: \"USD\")\n      @uk_isa           = family.accounts.create!(accountable: Investment.new, name: \"Vanguard UK ISA\", balance: 0, currency: \"GBP\")\n\n      # Property (USD)\n      @home = family.accounts.create!(accountable: Property.new, name: \"Primary Residence\", balance: 0, currency: \"USD\")\n\n      # Vehicles (USD)\n      @honda_accord = family.accounts.create!(accountable: Vehicle.new, name: \"2016 Honda Accord\", balance: 0, currency: \"USD\")\n      @tesla_model3 = family.accounts.create!(accountable: Vehicle.new, name: \"2021 Tesla Model 3\", balance: 0, currency: \"USD\")\n\n      # Crypto (USD)\n      @coinbase_usdc = family.accounts.create!(accountable: Crypto.new, name: \"Coinbase USDC\", balance: 0, currency: \"USD\")\n\n      # Loans / Liabilities (USD)\n      @mortgage      = family.accounts.create!(accountable: Loan.new, name: \"Home Mortgage\", balance: 0, currency: \"USD\")\n      @car_loan      = family.accounts.create!(accountable: Loan.new, name: \"Car Loan\", balance: 0, currency: \"USD\")\n      @student_loan  = family.accounts.create!(accountable: Loan.new, name: \"Student Loan\", balance: 0, currency: \"USD\")\n\n      @personal_loc  = family.accounts.create!(accountable: OtherLiability.new, name: \"Personal Line of Credit\", balance: 0, currency: \"USD\")\n\n      # Other asset (USD)\n      @jewelry = family.accounts.create!(accountable: OtherAsset.new, name: \"Jewelry Collection\", balance: 0, currency: \"USD\")\n    end\n\n    def create_realistic_transactions!(family)\n      load_securities!\n\n      puts \"   📈 Generating salary history (12 years)...\"\n      generate_salary_history!\n\n      puts \"   🏠 Generating housing transactions...\"\n      generate_housing_transactions!\n\n      puts \"   🍕 Generating food & dining transactions...\"\n      generate_food_transactions!\n\n      puts \"   🚗 Generating transportation transactions...\"\n      generate_transportation_transactions!\n\n      puts \"   🎬 Generating entertainment transactions...\"\n      generate_entertainment_transactions!\n\n      puts \"   🛒 Generating shopping transactions...\"\n      generate_shopping_transactions!\n\n      puts \"   ⚕️ Generating healthcare transactions...\"\n      generate_healthcare_transactions!\n\n      puts \"   ✈️ Generating travel transactions...\"\n      generate_travel_transactions!\n\n      puts \"   💅 Generating personal care transactions...\"\n      generate_personal_care_transactions!\n\n      puts \"   💰 Generating investment transactions...\"\n      generate_investment_transactions!\n\n      puts \"   🏡 Generating major purchases...\"\n      generate_major_purchases!\n\n      puts \"   💳 Generating transfers and payments...\"\n      generate_transfers_and_payments!\n\n      puts \"   🏦 Generating loan payments...\"\n      generate_loan_payments!\n\n      puts \"   🧾 Generating regular expense baseline...\"\n      generate_regular_expenses!\n\n      puts \"   🗄️  Generating legacy historical data...\"\n      generate_legacy_transactions!\n\n      puts \"   🔒 Generating crypto & misc asset transactions...\"\n      generate_crypto_and_misc_assets!\n\n      puts \"   ✅ Reconciling balances to target snapshot...\"\n      reconcile_balances!(family)\n\n      puts \"   📊 Generated approximately #{Entry.joins(:account).where(accounts: { family_id: family.id }).count} transactions\"\n\n      puts \"🔄 Final sync to calculate adjusted balances...\"\n      sync_family_accounts!(family)\n    end\n\n    # Auto-fill current-month budget based on recent spending averages\n    def generate_budget_auto_fill!(family)\n      current_month   = Date.current.beginning_of_month\n      analysis_start  = (current_month - 3.months).beginning_of_month\n      analysis_period = analysis_start..(current_month - 1.day)\n\n      # Fetch expense transactions in the analysis period\n      txns = Entry.joins(\"INNER JOIN transactions ON transactions.id = entries.entryable_id\")\n                  .joins(\"INNER JOIN categories ON categories.id = transactions.category_id\")\n                  .where(entries: { entryable_type: \"Transaction\", date: analysis_period })\n                  .where(categories: { classification: \"expense\" })\n\n      spend_per_cat = txns.group(\"categories.id\").sum(\"entries.amount\")\n\n      budget = family.budgets.where(start_date: current_month).first_or_initialize\n      budget.update!(\n        end_date: current_month.end_of_month,\n        currency: \"USD\",\n        budgeted_spending: spend_per_cat.values.sum / 3.0, # placeholder, refine below\n        expected_income: 0 # Could compute similarly if desired\n      )\n\n      spend_per_cat.each do |cat_id, total|\n        avg = total / 3.0\n        rounded = ((avg / 25.0).round) * 25\n        category = Category.find(cat_id)\n        budget.budget_categories.find_or_create_by!(category: category) do |bc|\n          bc.budgeted_spending = rounded\n          bc.currency = \"USD\"\n        end\n      end\n\n      # Update aggregate budgeted_spending to sum of categories\n      budget.update!(budgeted_spending: budget.budget_categories.sum(:budgeted_spending))\n    end\n\n    # Helper method to get weighted random date (favoring recent years)\n    def weighted_random_date\n      # Focus on last 3 years for transaction generation\n      rand(3.years.ago.to_date..Date.current)\n    end\n\n    # Helper method to get random accounts for transactions\n    def random_checking_account\n      [ @chase_checking, @ally_checking ].sample\n    end\n\n    # ---------------------------------------------------------------------------\n    # Payroll system — 156 deterministic deposits (bi-weekly, six years)\n    # ---------------------------------------------------------------------------\n    def generate_salary_history!\n      deposit_amount = 8_500  # Increased from 4,200 to ~$200k annually\n      total_deposits = 78     # Reduced from 156 (only 3 years instead of 6)\n\n      # Find first Friday ≥ 3.years.ago so the cadence remains bi-weekly.\n      first_date = 3.years.ago.to_date\n      first_date += 1 until first_date.friday?\n\n      total_deposits.times do |i|\n        date = first_date + (14 * i)\n        break if date > Date.current # safety\n\n        amount = -jitter(deposit_amount, 0.02).round # negative inflow per conventions\n        create_transaction!(@chase_checking, amount, \"Acme Corp Payroll\", @salary_cat, date)\n\n        # 10 % automated savings transfer to Marcus Savings same day\n        savings_amount = (-amount * 0.10).round\n        create_transfer!(@chase_checking, @marcus_savings, savings_amount, \"Auto-Save 10% of Paycheck\", date)\n      end\n\n      # Add freelance income to help balance expenses\n      15.times do\n        date = weighted_random_date\n        amount = -rand(1500..4000)  # Negative for income\n        create_transaction!(@chase_checking, amount, \"Freelance Project\", @freelance_cat, date)\n      end\n\n      # Add quarterly investment dividends\n      (3.years.ago.to_date..Date.current).each do |date|\n        next unless date.day == 15 && [ 3, 6, 9, 12 ].include?(date.month) # Quarterly\n        dividend_amount = -rand(800..1500)  # Negative for income\n        create_transaction!(@chase_checking, dividend_amount, \"Investment Dividends\", @investment_income_cat, date)\n      end\n\n      # Add more regular freelance income to maintain positive checking balance\n      40.times do  # Increased from 15\n        date = weighted_random_date\n        amount = -rand(800..2500)  # More frequent, smaller freelance income\n        create_transaction!(@chase_checking, amount, \"Freelance Payment\", @freelance_cat, date)\n      end\n\n      # Add side income streams\n      25.times do\n        date = weighted_random_date\n        amount = -rand(200..800)\n        income_types = [ \"Cash Tips\", \"Selling Items\", \"Refund\", \"Rebate\", \"Gift Card Cash Out\" ]\n        create_transaction!(@chase_checking, amount, income_types.sample, @freelance_cat, date)\n      end\n    end\n\n    def generate_housing_transactions!\n      start_date = 3.years.ago.to_date  # Reduced from 12 years\n      base_rent = 2500 # Higher starting amount for higher income family\n\n      # Monthly rent/mortgage payments\n      (start_date..Date.current).each do |date|\n        next unless date.day == 1 # First of month\n\n        # Mortgage payment from checking account (positive expense)\n        create_transaction!(@chase_checking, 2800, \"Mortgage Payment\", @rent_cat, date)\n        # Principal payment reduces mortgage debt (negative transaction)\n        principal_payment = 800 # ~$800 goes to principal\n        create_transaction!(@mortgage, -principal_payment, \"Principal Payment\", nil, date)\n      end\n\n      # Monthly utilities (reduced frequency)\n      utilities = [\n        { name: \"ConEd Electric\", range: 150..300 },\n        { name: \"Verizon Internet\", range: 85..105 },\n        { name: \"Water & Sewer\", range: 60..90 },\n        { name: \"Gas Bill\", range: 80..220 }\n      ]\n\n      utilities.each do |utility|\n        (start_date..Date.current).each do |date|\n          next unless date.day.between?(5, 15) && rand < 0.9 # Monthly with higher frequency\n          amount = rand(utility[:range])\n          create_transaction!(@chase_checking, amount, utility[:name], @utilities_cat, date)\n        end\n      end\n    end\n\n    def generate_food_transactions!\n      # Weekly groceries (increased volume but kept amounts reasonable)\n      120.times do  # Increased from 60\n        date = weighted_random_date\n        amount = rand(60..180) # Reduced max from 220\n        stores = [ \"Whole Foods\", \"Trader Joe's\", \"Safeway\", \"Stop & Shop\", \"Fresh Market\" ]\n        create_transaction!(@chase_checking, amount, \"#{stores.sample} Market\", @groceries_cat, date)\n      end\n\n      # Restaurant dining (increased volume)\n      100.times do  # Increased from 50\n        date = weighted_random_date\n        amount = rand(25..65) # Reduced max from 80\n        restaurants = [ \"Pizza Corner\", \"Sushi Place\", \"Italian Kitchen\", \"Mexican Grill\", \"Greek Taverna\" ]\n        create_transaction!(@chase_checking, amount, restaurants.sample, @restaurants_cat, date)\n      end\n\n      # Coffee & takeout (increased volume)\n      80.times do  # Increased from 40\n        date = weighted_random_date\n        amount = rand(8..20) # Reduced from 10-25\n        places = [ \"Local Coffee\", \"Dunkin'\", \"Corner Deli\", \"Food Truck\" ]\n        create_transaction!(@chase_checking, amount, places.sample, @coffee_cat, date)\n      end\n    end\n\n    def generate_transportation_transactions!\n      # Gas stations (checking account only)\n      60.times do\n        date = weighted_random_date\n        amount = rand(35..75)\n        stations = [ \"Shell\", \"Exxon\", \"BP\", \"Chevron\", \"Mobil\", \"Sunoco\" ]\n        create_transaction!(@chase_checking, amount, \"#{stations.sample} Gas\", @gas_cat, date)\n      end\n\n      # Car payment (monthly for 6 years)\n      car_payment_start = 6.years.ago.to_date\n      car_payment_end = 1.year.ago.to_date\n\n      (car_payment_start..car_payment_end).each do |date|\n        next unless date.day == 15 # 15th of month\n        create_transaction!(@chase_checking, 385, \"Auto Loan Payment\", @car_payment_cat, date)\n      end\n    end\n\n    def generate_entertainment_transactions!\n      # Monthly subscriptions (increased timeframe)\n      subscriptions = [\n        { name: \"Netflix\", amount: 15 },\n        { name: \"Spotify Premium\", amount: 12 },\n        { name: \"Disney+\", amount: 8 },\n        { name: \"HBO Max\", amount: 16 },\n        { name: \"Amazon Prime\", amount: 14 }\n      ]\n\n      subscriptions.each do |sub|\n        (3.years.ago.to_date..Date.current).each do |date| # Reduced from 12 years\n          next unless date.day == rand(1..28) && rand < 0.9 # Higher frequency for active subscriptions\n          create_transaction!(@chase_checking, sub[:amount], sub[:name], @entertainment_cat, date)\n        end\n      end\n\n      # Random entertainment (increased volume)\n      60.times do  # Increased from 25\n        date = weighted_random_date\n        amount = rand(15..60) # Reduced from 20-80\n        activities = [ \"Movie Theater\", \"Sports Game\", \"Museum\", \"Comedy Club\", \"Bowling\", \"Mini Golf\", \"Arcade\" ]\n        create_transaction!(@chase_checking, amount, activities.sample, @entertainment_cat, date)\n      end\n    end\n\n    def generate_shopping_transactions!\n      # Online shopping (increased volume)\n      80.times do  # Increased from 40\n        date = weighted_random_date\n        amount = rand(30..90) # Reduced max from 120\n        stores = [ \"Target.com\", \"Walmart\", \"Costco\" ]\n        create_transaction!(@chase_checking, amount, \"#{stores.sample} Purchase\", @shopping_cat, date)\n      end\n\n      # In-store shopping (increased volume)\n      60.times do  # Increased from 25\n        date = weighted_random_date\n        amount = rand(35..80) # Reduced max from 100\n        stores = [ \"Target\", \"REI\", \"Barnes & Noble\", \"GameStop\" ]\n        create_transaction!(@chase_checking, amount, stores.sample, @shopping_cat, date)\n      end\n    end\n\n    def generate_healthcare_transactions!\n      # Doctor visits (increased volume)\n      45.times do  # Increased from 25\n        date = weighted_random_date\n        amount = rand(150..350) # Reduced from 180-450\n        providers = [ \"Dr. Smith\", \"Dr. Johnson\", \"Dr. Williams\", \"Specialist Visit\", \"Urgent Care\" ]\n        create_transaction!(@chase_checking, amount, providers.sample, @healthcare_cat, date)\n      end\n\n      # Pharmacy (increased volume)\n      80.times do  # Increased from 40\n        date = weighted_random_date\n        amount = rand(12..65) # Reduced from 15-85\n        pharmacies = [ \"CVS Pharmacy\", \"Walgreens\", \"Rite Aid\", \"Local Pharmacy\" ]\n        create_transaction!(@chase_checking, amount, pharmacies.sample, @healthcare_cat, date)\n      end\n    end\n\n    def generate_travel_transactions!\n      # Major vacations (reduced count - premium travel handled in credit card cycles)\n      8.times do\n        date = weighted_random_date\n\n        # Smaller local trips from checking\n        hotel_amount = rand(200..500)\n        hotels = [ \"Local Hotel\", \"B&B\", \"Nearby Resort\" ]\n        if rand < 0.3 && date > 3.years.ago.to_date # Some EUR transactions\n          create_transaction!(@eu_checking, hotel_amount, hotels.sample, @travel_cat, date)\n        else\n          create_transaction!(@chase_checking, hotel_amount, hotels.sample, @travel_cat, date)\n        end\n\n        # Domestic flights (smaller amounts)\n        flight_amount = rand(200..400)\n        create_transaction!(@chase_checking, flight_amount, \"Domestic Flight\", @travel_cat, date + rand(1..5).days)\n\n        # Local activities\n        activity_amount = rand(50..150)\n        activities = [ \"Local Tour\", \"Museum Tickets\", \"Activity Pass\" ]\n        create_transaction!(@chase_checking, activity_amount, activities.sample, @travel_cat, date + rand(1..7).days)\n      end\n    end\n\n    def generate_personal_care_transactions!\n      # Gym membership\n      (12.years.ago.to_date..Date.current).each do |date|\n        next unless date.day == 1 && rand < 0.8 # Monthly\n        create_transaction!(@chase_checking, 45, \"Gym Membership\", @personal_care_cat, date)\n      end\n\n      # Beauty/grooming (checking account only)\n      40.times do\n        date = weighted_random_date\n        amount = rand(25..80)\n        services = [ \"Hair Salon\", \"Barber Shop\", \"Nail Salon\" ]\n        create_transaction!(@chase_checking, amount, services.sample, @personal_care_cat, date)\n      end\n    end\n\n    def generate_investment_transactions!\n      security = Security.first || Security.create!(ticker: \"VTI\", name: \"Vanguard Total Stock Market ETF\", country_code: \"US\")\n\n      generate_401k_trades!(security)\n      generate_brokerage_trades!(security)\n      generate_roth_trades!(security)\n      generate_uk_isa_trades!(security)\n    end\n\n    # ---------------------------------------------------- 401k (180 trades) --\n    def generate_401k_trades!(security)\n      payroll_dates = collect_payroll_dates.first(90) # 90 paydays ⇒ 180 trades\n\n      payroll_dates.each do |date|\n        # Employee contribution $1 200\n        create_trade_for(@vanguard_401k, security, 1_200, date, \"401k Employee\")\n\n        # Employer match $300\n        create_trade_for(@vanguard_401k, security, 300, date, \"401k Employer Match\")\n      end\n    end\n\n    # -------------------------------------------- Brokerage (144 trades) -----\n    def generate_brokerage_trades!(security)\n      date_cursor = 36.months.ago.beginning_of_month\n      while date_cursor <= Date.current\n        4.times do |i|\n          trade_date = date_cursor + i * 7.days # roughly spread within month\n          create_trade_for(@schwab_brokerage, security, rand(400..1_000), trade_date, \"Brokerage Purchase\")\n        end\n        date_cursor = date_cursor.next_month.beginning_of_month\n      end\n    end\n\n    # ----------------------------------------------- Roth IRA (108 trades) ---\n    def generate_roth_trades!(security)\n      date_cursor = 36.months.ago.beginning_of_month\n      while date_cursor <= Date.current\n        # Split $500 monthly across 3 staggered trades\n        3.times do |i|\n          trade_date = date_cursor + i * 10.days\n          create_trade_for(@fidelity_roth_ira, security, (500 / 3.0), trade_date, \"Roth IRA Contribution\")\n        end\n        date_cursor = date_cursor.next_month.beginning_of_month\n      end\n    end\n\n    # ------------------------------------------------- UK ISA (108 trades) ----\n    def generate_uk_isa_trades!(security)\n      date_cursor = 36.months.ago.beginning_of_month\n      while date_cursor <= Date.current\n        3.times do |i|\n          trade_date = date_cursor + i * 10.days\n          create_trade_for(@uk_isa, security, (400 / 3.0), trade_date, \"ISA Investment\", price_range: 60..150)\n        end\n        date_cursor = date_cursor.next_month.beginning_of_month\n      end\n    end\n\n    # --------------------------- Helpers for investment trade generation -----\n    def collect_payroll_dates\n      dates = []\n      d = 36.months.ago.to_date\n      d += 1 until d.friday?\n      while d <= Date.current\n        dates << d if d.cweek.even?\n        d += 14 # next bi-weekly\n      end\n      dates\n    end\n\n    def create_trade_for(account, security, investment_amount, date, memo, price_range: 80..200)\n      price = rand(price_range)\n      qty   = (investment_amount.to_f / price).round(2)\n      create_investment_transaction!(account, security, qty, price, date, memo)\n    end\n\n    def generate_major_purchases!\n      # Home purchase (5 years ago) - only record the down payment, not full value\n      # Property value will be set by valuation in reconcile_balances!\n      home_date = 5.years.ago.to_date\n      create_transaction!(@chase_checking, 70_000, \"Home Down Payment\", @housing_cat, home_date)\n      create_transaction!(@mortgage, 320_000, \"Mortgage Principal\", nil, home_date) # Initial mortgage debt\n\n      # Initial account funding (realistic amounts)\n      create_transaction!(@chase_checking, -5_000, \"Initial Deposit\", @salary_cat, 12.years.ago.to_date)\n      create_transaction!(@ally_checking, -2_000, \"Initial Deposit\", @salary_cat, 12.years.ago.to_date)\n      create_transaction!(@marcus_savings, -10_000, \"Initial Savings\", @salary_cat, 12.years.ago.to_date)\n      create_transaction!(@eu_checking, -5_000, \"EUR Account Opening\", nil, 4.years.ago.to_date)\n\n      # Car purchases (realistic amounts)\n      create_transaction!(@chase_checking, 3_000, \"Car Down Payment\", @transportation_cat, 6.years.ago.to_date)\n      create_transaction!(@chase_checking, 2_500, \"Second Car Down Payment\", @transportation_cat, 8.years.ago.to_date)\n\n      # Major but realistic expenses\n      create_transaction!(@chase_checking, 8_000, \"Kitchen Renovation\", @utilities_cat, 2.years.ago.to_date)\n      create_transaction!(@chase_checking, 5_000, \"Bathroom Remodel\", @utilities_cat, 1.year.ago.to_date)\n      create_transaction!(@chase_checking, 12_000, \"Roof Replacement\", @utilities_cat, 3.years.ago.to_date)\n      create_transaction!(@chase_checking, 8_000, \"Family Emergency\", @healthcare_cat, 4.years.ago.to_date)\n      create_transaction!(@chase_checking, 15_000, \"Wedding Expenses\", @entertainment_cat, 9.years.ago.to_date)\n    end\n\n    def generate_transfers_and_payments!\n      generate_credit_card_cycles!\n\n      generate_monthly_ally_transfers!\n      generate_quarterly_fx_transfers!\n      generate_additional_savings_transfers!\n    end\n\n    # Additional savings transfers to improve income/expense balance\n    def generate_additional_savings_transfers!\n      # Monthly extra savings transfers\n      (3.years.ago.to_date..Date.current).each do |date|\n        next unless date.day == 15 && rand < 0.7 # Semi-monthly savings\n        amount = rand(500..1500)\n        create_transfer!(@chase_checking, @marcus_savings, amount, \"Extra Savings Transfer\", date)\n      end\n\n      # Quarterly HSA contributions\n      (3.years.ago.to_date..Date.current).each do |date|\n        next unless date.day == 1 && [ 1, 4, 7, 10 ].include?(date.month) # Quarterly\n        amount = rand(1000..2000)\n        create_transfer!(@chase_checking, @hsa_investment, amount, \"HSA Contribution\", date)\n      end\n\n      # Occasional windfalls (tax refunds, bonuses, etc.)\n      8.times do\n        date = weighted_random_date\n        amount = rand(2000..8000)\n        create_transaction!(@chase_checking, -amount, \"Tax Refund/Bonus\", @salary_cat, date)\n      end\n\n      # CRITICAL: Regular transfers FROM savings TO checking to maintain positive balance\n      # This is realistic - people move money from savings to checking regularly\n      (3.years.ago.to_date..Date.current).each do |date|\n        next unless date.day == rand(20..28) && rand < 0.8 # Monthly transfers from savings\n        amount = rand(2000..5000)\n        create_transfer!(@marcus_savings, @chase_checking, amount, \"Transfer from Savings\", date)\n      end\n\n      # Weekly smaller transfers from savings for cash flow\n      (3.years.ago.to_date..Date.current).each do |date|\n        next unless date.wday == 1 && rand < 0.4 # Some Mondays\n        amount = rand(500..1200)\n        create_transfer!(@marcus_savings, @chase_checking, amount, \"Weekly Cash Flow\", date)\n      end\n    end\n\n    # $300 from Chase Checking to Ally Checking on the first business day of each\n    # month for the past 36 months.\n    def generate_monthly_ally_transfers!\n      date_cursor = 36.months.ago.beginning_of_month\n      while date_cursor <= Date.current\n        transfer_date = first_business_day(date_cursor)\n        create_transfer!(@chase_checking, @ally_checking, 300, \"Monthly Ally Transfer\", transfer_date)\n        date_cursor = date_cursor.next_month.beginning_of_month\n      end\n    end\n\n    # Quarterly $2 000 FX transfer from Chase Checking to EUR account\n    def generate_quarterly_fx_transfers!\n      date_cursor = 36.months.ago.beginning_of_quarter\n      while date_cursor <= Date.current\n        transfer_date = date_cursor + 2.days # arbitrary within quarter start\n        create_transfer!(@chase_checking, @eu_checking, 2_000, \"Quarterly FX Transfer\", transfer_date)\n        date_cursor = date_cursor.next_quarter.beginning_of_quarter\n      end\n    end\n\n    # Returns the first weekday (Mon-Fri) of the month containing +date+.\n    def first_business_day(date)\n      d = date.beginning_of_month\n      d += 1.day while d.saturday? || d.sunday?\n      d\n    end\n\n    def generate_credit_card_cycles!\n      # REDUCED: 30-45 charges per month across both cards for 36 months (≈1,400 total)\n      # This is still significant but more realistic than 80-120/month\n      # Pay 90-95 % of new balance 5 days post-cycle; final balances should\n      # be ~$2 500 (Amex) and ~$4 200 (Sapphire).\n\n      start_date = 36.months.ago.beginning_of_month\n      end_date   = Date.current.end_of_month\n\n      amex_balance      = 0\n      sapphire_balance  = 0\n\n      charges_this_run  = 0\n      payments_this_run = 0\n\n      date_cursor = start_date\n      while date_cursor <= end_date\n        # --- Charge generation (REDUCED FOR BALANCE) -------------------------\n        month_charge_target = rand(30..45)  # Reduced from 80-120 to 30-45\n        # Split roughly evenly but add a little variance.\n        amex_count     = (month_charge_target * rand(0.45..0.55)).to_i\n        sapphire_count = month_charge_target - amex_count\n\n        amex_total     = generate_credit_card_charges(@amex_gold,     date_cursor, amex_count)\n        sapphire_total = generate_credit_card_charges(@chase_sapphire, date_cursor, sapphire_count)\n\n        amex_balance     += amex_total\n        sapphire_balance += sapphire_total\n\n        charges_this_run += (amex_count + sapphire_count)\n\n        # --- Monthly payments (5 days after month end) ------------------------\n        payment_date = (date_cursor.end_of_month + 5.days)\n\n        if amex_total.positive?\n          amex_payment = (amex_total * rand(0.90..0.95)).round\n          create_transfer!(@chase_checking, @amex_gold, amex_payment, \"Amex Payment\", payment_date)\n          amex_balance -= amex_payment\n          payments_this_run += 1\n        end\n\n        if sapphire_total.positive?\n          sapphire_payment = (sapphire_total * rand(0.90..0.95)).round\n          create_transfer!(@chase_checking, @chase_sapphire, sapphire_payment, \"Sapphire Payment\", payment_date)\n          sapphire_balance -= sapphire_payment\n          payments_this_run += 1\n        end\n\n        date_cursor = date_cursor.next_month.beginning_of_month\n      end\n\n      # -----------------------------------------------------------------------\n      # Re-balance to hit target ending balances (tolerance ±$250)\n      # -----------------------------------------------------------------------\n      target_amex     = 2_500\n      target_sapphire = 4_200\n\n      diff_amex     = amex_balance - target_amex\n      diff_sapphire = sapphire_balance - target_sapphire\n\n      if diff_amex.abs > 250\n        adjust_payment = diff_amex.positive? ? diff_amex : 0\n        create_transfer!(@chase_checking, @amex_gold, adjust_payment, \"Amex Balance Adjust\", Date.current)\n        amex_balance -= adjust_payment\n      end\n\n      if diff_sapphire.abs > 250\n        adjust_payment = diff_sapphire.positive? ? diff_sapphire : 0\n        create_transfer!(@chase_checking, @chase_sapphire, adjust_payment, \"Sapphire Balance Adjust\", Date.current)\n        sapphire_balance -= adjust_payment\n      end\n\n      puts \"   💳 Charges generated: #{charges_this_run} | Payments: #{payments_this_run}\"\n      puts \"   💳 Final Amex balance: ~$#{amex_balance} | target ~$#{target_amex}\"\n      puts \"   💳 Final Sapphire balance: ~$#{sapphire_balance} | target ~$#{target_sapphire}\"\n    end\n\n    # Generate exactly +count+ charges on +account+ within the month of +base_date+.\n    # Returns total charge amount.\n    def generate_credit_card_charges(account, base_date, count)\n      total = 0\n\n      count.times do\n        charge_date = base_date + rand(0..27).days\n\n        amount = rand(15..80) # Reduced from 25..150 due to higher frequency\n        # bias amounts to achieve reasonable monthly totals\n        amount = jitter(amount, 0.15).round\n\n        merchant = if account == @amex_gold\n          pick(%w[WholeFoods Starbucks UberEats Netflix LocalBistro AirBnB])\n        else\n          pick([ \"Delta Airlines\", \"Hilton Hotels\", \"Expedia\", \"Apple\", \"BestBuy\", \"Amazon\" ])\n        end\n\n        create_transaction!(account, amount, merchant, random_expense_category, charge_date)\n        total += amount\n      end\n\n      total\n    end\n\n    def random_expense_category\n      [ @food_cat, @entertainment_cat, @shopping_cat, @travel_cat, @transportation_cat ].sample\n    end\n\n    def create_transaction!(account, amount, name, category, date)\n      # For credit cards (liabilities), positive amounts = charges (increase debt)\n      # For checking accounts (assets), positive amounts = expenses (decrease balance)\n      # The amount is already signed correctly by the caller\n      account.entries.create!(\n        entryable: Transaction.new(category: category),\n        amount: amount,\n        name: name,\n        currency: account.currency,\n        date: date\n      )\n    end\n\n    def create_investment_transaction!(account, security, qty, price, date, name)\n      account.entries.create!(\n        entryable: Trade.new(security: security, qty: qty, price: price, currency: account.currency),\n        amount: -(qty * price),\n        name: name,\n        currency: account.currency,\n        date: date\n      )\n    end\n\n    def create_transfer!(from_account, to_account, amount, name, date)\n      outflow = from_account.entries.create!(\n        entryable: Transaction.new,\n        amount: amount,\n        name: name,\n        currency: from_account.currency,\n        date: date\n      )\n      inflow = to_account.entries.create!(\n        entryable: Transaction.new,\n        amount: -amount,\n        name: name,\n        currency: to_account.currency,\n        date: date\n      )\n      Transfer.create!(inflow_transaction: inflow.entryable, outflow_transaction: outflow.entryable)\n    end\n\n    def load_securities!\n      return if Security.exists?\n\n      Security.create!([\n        { ticker: \"VTI\", name: \"Vanguard Total Stock Market ETF\", country_code: \"US\" },\n        { ticker: \"VXUS\", name: \"Vanguard Total International Stock ETF\", country_code: \"US\" },\n        { ticker: \"BND\", name: \"Vanguard Total Bond Market ETF\", country_code: \"US\" }\n      ])\n    end\n\n    def sync_family_accounts!(family)\n      family.accounts.each do |account|\n        sync = Sync.create!(syncable: account)\n        sync.perform\n      end\n    end\n\n    # ---------------------------------------------------------------------------\n    #                         Deterministic helper methods\n    # ---------------------------------------------------------------------------\n\n    # Deterministically walk through the elements of +array+, returning the next\n    # element each time it is called with the *same* array instance.\n    #\n    # Example:\n    #   colours = %w[red green blue]\n    #   4.times.map { pick(colours) } #=> [\"red\", \"green\", \"blue\", \"red\"]\n    def pick(array)\n      @pick_indices ||= Hash.new(0)\n      idx = @pick_indices[array.object_id]\n      @pick_indices[array.object_id] += 1\n      array[idx % array.length]\n    end\n\n    # Adds a small random variation (±pct, default 3%) to +num+.  Useful for\n    # making otherwise deterministic amounts look more natural while retaining\n    # overall reproducibility via the seeded RNG.\n    def jitter(num, pct = 0.03)\n      variation = num * pct * (rand * 2 - 1) # rand(-pct..pct)\n      (num + variation).round(2)\n    end\n\n    # ---------------------------------------------------------------------------\n    # Loan payments (Task 8)\n    # ---------------------------------------------------------------------------\n    def generate_loan_payments!\n      date_cursor = 36.months.ago.beginning_of_month\n      while date_cursor <= Date.current\n        payment_date = first_business_day(date_cursor)\n\n        # Mortgage\n        make_loan_payment!(\n          principal_account: @mortgage,\n          principal_amount: 600,\n          interest_amount: 1_100,\n          interest_category: @housing_cat,\n          date: payment_date,\n          memo: \"Mortgage Payment\"\n        )\n\n        # Student loan\n        make_loan_payment!(\n          principal_account: @student_loan,\n          principal_amount: 350,\n          interest_amount: 100,\n          interest_category: @interest_cat,\n          date: payment_date,\n          memo: \"Student Loan Payment\"\n        )\n\n        # Car loan – assume 300 principal / 130 interest\n        make_loan_payment!(\n          principal_account: @car_loan,\n          principal_amount: 300,\n          interest_amount: 130,\n          interest_category: @transportation_cat,\n          date: payment_date,\n          memo: \"Auto Loan Payment\"\n        )\n\n        date_cursor = date_cursor.next_month.beginning_of_month\n      end\n    end\n\n    def make_loan_payment!(principal_account:, principal_amount:, interest_amount:, interest_category:, date:, memo:)\n      # Principal portion – transfer from checking to loan account\n      create_transfer!(@chase_checking, principal_account, principal_amount, memo, date)\n\n      # Interest portion – expense from checking\n      create_transaction!(@chase_checking, interest_amount, \"#{memo} Interest\", interest_category, date)\n    end\n\n    # Generate additional baseline expenses to reach 8k-12k transaction target\n    def generate_regular_expenses!\n      expense_generators = [\n        ->(date) { create_transaction!(@chase_checking, jitter(rand(150..220), 0.05).round, pick([ \"ConEd Electric\", \"National Grid\", \"Gas & Power\" ]), @utilities_cat, date) },\n        ->(date) { create_transaction!(@chase_checking, jitter(rand(10..20), 0.1).round, pick([ \"Spotify\", \"Netflix\", \"Hulu\", \"Apple One\" ]), @entertainment_cat, date) },\n        ->(date) { create_transaction!(@chase_checking, jitter(rand(45..90), 0.1).round, pick([ \"Whole Foods\", \"Trader Joe's\", \"Safeway\" ])+\" Market\", @groceries_cat, date) },\n        ->(date) { create_transaction!(@chase_checking, jitter(rand(25..50), 0.1).round, pick([ \"Shell Gas\", \"BP Gas\", \"Exxon\" ]), @gas_cat, date) },\n        ->(date) { create_transaction!(@chase_checking, jitter(rand(15..40), 0.1).round, pick([ \"Movie Streaming\", \"Book Purchase\", \"Mobile Game\" ]), @entertainment_cat, date) }\n      ]\n\n      desired = 600  # Increased from 300 to help reach 8k\n      current = Entry.joins(:account).where(accounts: { id: [ @chase_checking.id ] }, entryable_type: \"Transaction\").count\n      to_create = [ desired - current, 0 ].max\n\n      to_create.times do\n        date = weighted_random_date\n        expense_generators.sample.call(date)\n      end\n\n      # Add high-volume, low-impact transactions to reach 8k minimum\n      generate_micro_transactions!\n    end\n\n    # Generate many small transactions to reach volume target\n    def generate_micro_transactions!\n      # ATM withdrawals and fees (reduced)\n      120.times do  # Reduced from 200\n        date = weighted_random_date\n        amount = rand(20..60)\n        create_transaction!(@chase_checking, amount, \"ATM Withdrawal\", @misc_cat, date)\n        # Small ATM fee\n        create_transaction!(@chase_checking, rand(2..4), \"ATM Fee\", @misc_cat, date)\n      end\n\n      # Small convenience store purchases (reduced)\n      200.times do  # Reduced from 300\n        date = weighted_random_date\n        amount = rand(3..15)\n        stores = [ \"7-Eleven\", \"Wawa\", \"Circle K\", \"Quick Stop\", \"Corner Store\" ]\n        create_transaction!(@chase_checking, amount, stores.sample, @shopping_cat, date)\n      end\n\n      # Small digital purchases (reduced)\n      120.times do  # Reduced from 200\n        date = weighted_random_date\n        amount = rand(1..10)\n        items = [ \"App Store\", \"Google Play\", \"iTunes\", \"Steam\", \"Kindle Book\" ]\n        create_transaction!(@chase_checking, amount, items.sample, @entertainment_cat, date)\n      end\n\n      # Parking meters and tolls (reduced)\n      100.times do  # Reduced from 150\n        date = weighted_random_date\n        amount = rand(2..8)\n        create_transaction!(@chase_checking, amount, pick([ \"Parking Meter\", \"Bridge Toll\", \"Tunnel Toll\" ]), @transportation_cat, date)\n      end\n\n      # Small cash transactions (reduced)\n      150.times do  # Reduced from 250\n        date = weighted_random_date\n        amount = rand(5..25)\n        vendors = [ \"Food Truck\", \"Farmer's Market\", \"Street Vendor\", \"Tip\", \"Donation\" ]\n        create_transaction!(@chase_checking, amount, vendors.sample, @misc_cat, date)\n      end\n\n      # Vending machine purchases (reduced)\n      60.times do  # Reduced from 100\n        date = weighted_random_date\n        amount = rand(1..5)\n        create_transaction!(@chase_checking, amount, \"Vending Machine\", @shopping_cat, date)\n      end\n\n      # Public transportation (reduced)\n      120.times do  # Reduced from 180\n        date = weighted_random_date\n        amount = rand(2..8)\n        transit = [ \"Metro Card\", \"Bus Fare\", \"Train Ticket\", \"Uber/Lyft\" ]\n        create_transaction!(@chase_checking, amount, transit.sample, @transportation_cat, date)\n      end\n\n      # Additional small transactions to ensure we reach 8k minimum (reduced)\n      400.times do  # Reduced from 600\n        date = weighted_random_date\n        amount = rand(1..12)\n        merchants = [\n          \"Newsstand\", \"Coffee Cart\", \"Tip Jar\", \"Donation Box\", \"Laundromat\",\n          \"Car Wash\", \"Redbox\", \"PayPhone\", \"Photo Booth\", \"Arcade Game\",\n          \"Postage\", \"Newspaper\", \"Lottery Ticket\", \"Gumball Machine\", \"Ice Cream Truck\"\n        ]\n        create_transaction!(@chase_checking, amount, merchants.sample, @misc_cat, date)\n      end\n\n      # Extra small transactions to ensure 8k+ volume\n      500.times do\n        date = weighted_random_date\n        amount = rand(1..8)\n        tiny_merchants = [\n          \"Candy Machine\", \"Sticker Machine\", \"Penny Scale\", \"Charity Donation\",\n          \"Busker Tip\", \"Church Offering\", \"Lemonade Stand\", \"Girl Scout Cookies\",\n          \"Raffle Ticket\", \"Bake Sale\", \"Car Wash Tip\", \"Street Performer\"\n        ]\n        create_transaction!(@chase_checking, amount, tiny_merchants.sample, @misc_cat, date)\n      end\n    end\n\n    # ---------------------------------------------------------------------------\n    # Legacy historical transactions (Task 11)\n    # ---------------------------------------------------------------------------\n    def generate_legacy_transactions!\n      # Small recent legacy transactions (3-6 years ago)\n      count = rand(40..60)  # Increased from 20-30\n      count.times do\n        years_ago = rand(3..6)\n        date = years_ago.years.ago.to_date - rand(0..364).days\n\n        base_amount = rand(12..45)  # Reduced from 15-60\n        discount    = (1 - 0.02 * [ years_ago - 3, 0 ].max)\n        amount      = (base_amount * discount).round\n\n        account = [ @chase_checking, @ally_checking ].sample\n        category = pick([ @groceries_cat, @utilities_cat, @gas_cat, @restaurants_cat, @shopping_cat ])\n\n        merchant = case category\n        when @groceries_cat then pick(%w[Walmart Kroger Safeway]) + \" Market\"\n        when @utilities_cat then pick([ \"Local Electric\", \"City Water\", \"Gas Co.\" ])\n        when @gas_cat then pick(%w[Shell Exxon BP])\n        when @restaurants_cat then pick([ \"Diner\", \"Burger Grill\", \"Pizza Place\" ])\n        else pick([ \"General Store\", \"Department Shop\", \"Outlet\" ])\n        end\n\n        create_transaction!(account, amount, merchant, category, date)\n      end\n\n      # Very old transactions (7-15 years ago) - just scattered outliers\n      count = rand(25..40)  # Increased from 15-25\n      count.times do\n        years_ago = rand(7..15)\n        date = years_ago.years.ago.to_date - rand(0..364).days\n\n        base_amount = rand(8..30)  # Reduced from 10-40\n        discount    = (1 - 0.03 * [ years_ago - 7, 0 ].max)  # More discount for very old\n        amount      = (base_amount * discount).round.clamp(5, 25)  # Reduced max from 35\n\n        account = @chase_checking  # Just use main checking for simplicity\n        category = pick([ @groceries_cat, @gas_cat, @restaurants_cat ])\n\n        merchant = case category\n        when @groceries_cat then pick(%w[Walmart Kroger]) + \" Market\"\n        when @gas_cat then pick(%w[Shell Exxon])\n        else pick([ \"Old Diner\", \"Local Restaurant\" ])\n        end\n\n        create_transaction!(account, amount, \"#{merchant} (#{years_ago}y ago)\", category, date)\n      end\n\n      # Additional small transactions to reach 8k minimum if needed\n      additional_needed = [ 400, 0 ].max  # Increased from 200\n      additional_needed.times do\n        years_ago = rand(4..12)\n        date = years_ago.years.ago.to_date - rand(0..364).days\n        amount = rand(6..20)  # Reduced from 8-25\n\n        account = [ @chase_checking, @ally_checking ].sample\n        category = pick([ @groceries_cat, @gas_cat, @utilities_cat ])\n\n        merchant = \"Legacy #{pick(%w[Store Gas Electric])}\"\n        create_transaction!(account, amount, merchant, category, date)\n      end\n    end\n\n    # ---------------------------------------------------------------------------\n    # Crypto & misc assets (Task 12)\n    # ---------------------------------------------------------------------------\n    def generate_crypto_and_misc_assets!\n      # One-time USDC deposit 18 months ago\n      deposit_date = 18.months.ago.to_date\n      create_transaction!(@coinbase_usdc, -3_500, \"Initial USDC Deposit\", nil, deposit_date)\n    end\n\n    # ---------------------------------------------------------------------------\n    # Balance Reconciliation (Task 14)\n    # ---------------------------------------------------------------------------\n    def reconcile_balances!(family)\n      # Use valuations only for property/vehicle accounts that should have specific values\n      # All other accounts should reach target balances through natural transaction flow\n\n      # Property valuations (these accounts are valued, not transaction-driven)\n      @home.entries.create!(\n        entryable: Valuation.new(kind: \"current_anchor\"),\n        amount: 350_000,\n        name: Valuation.build_current_anchor_name(@home.accountable_type),\n        currency: \"USD\",\n        date: Date.current\n      )\n\n      # Vehicle valuations (these depreciate over time)\n      @honda_accord.entries.create!(\n        entryable: Valuation.new(kind: \"current_anchor\"),\n        amount: 18_000,\n        name: Valuation.build_current_anchor_name(@honda_accord.accountable_type),\n        currency: \"USD\",\n        date: Date.current\n      )\n\n      @tesla_model3.entries.create!(\n        entryable: Valuation.new(kind: \"current_anchor\"),\n        amount: 4_500,\n        name: Valuation.build_current_anchor_name(@tesla_model3.accountable_type),\n        currency: \"USD\",\n        date: Date.current\n      )\n\n      @jewelry.entries.create!(\n        entryable: Valuation.new(kind: \"reconciliation\"),\n        amount: 2000,\n        name: Valuation.build_reconciliation_name(@jewelry.accountable_type),\n        currency: \"USD\",\n        date: 90.days.ago.to_date\n      )\n\n      @personal_loc.entries.create!(\n        entryable: Valuation.new(kind: \"reconciliation\"),\n        amount: 800,\n        name: Valuation.build_reconciliation_name(@personal_loc.accountable_type),\n        currency: \"USD\",\n        date: 120.days.ago.to_date\n      )\n\n      puts \"   ✅ Set property and vehicle valuations\"\n    end\nend\n"
  },
  {
    "path": "app/models/depository.rb",
    "content": "class Depository < ApplicationRecord\n  include Accountable\n\n  SUBTYPES = {\n    \"checking\" => { short: \"Checking\", long: \"Checking\" },\n    \"savings\" => { short: \"Savings\", long: \"Savings\" },\n    \"hsa\" => { short: \"HSA\", long: \"Health Savings Account\" },\n    \"cd\" => { short: \"CD\", long: \"Certificate of Deposit\" },\n    \"money_market\" => { short: \"MM\", long: \"Money Market\" }\n  }.freeze\n\n  class << self\n    def display_name\n      \"Cash\"\n    end\n\n    def color\n      \"#875BF7\"\n    end\n\n    def classification\n      \"asset\"\n    end\n\n    def icon\n      \"landmark\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/developer_message.rb",
    "content": "class DeveloperMessage < Message\n  def role\n    \"developer\"\n  end\n\n  private\n    def broadcast?\n      chat.debug_mode?\n    end\nend\n"
  },
  {
    "path": "app/models/entry.rb",
    "content": "class Entry < ApplicationRecord\n  include Monetizable, Enrichable\n\n  monetize :amount\n\n  belongs_to :account\n  belongs_to :transfer, optional: true\n  belongs_to :import, optional: true\n\n  delegated_type :entryable, types: Entryable::TYPES, dependent: :destroy\n  accepts_nested_attributes_for :entryable\n\n  validates :date, :name, :amount, :currency, presence: true\n  validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? }\n  validates :date, comparison: { greater_than: -> { min_supported_date } }\n\n  scope :visible, -> {\n    joins(:account).where(accounts: { status: [ \"draft\", \"active\" ] })\n  }\n\n  scope :chronological, -> {\n    order(\n      date: :asc,\n      Arel.sql(\"CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END\") => :asc,\n      created_at: :asc\n    )\n  }\n\n  scope :reverse_chronological, -> {\n    order(\n      date: :desc,\n      Arel.sql(\"CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END\") => :desc,\n      created_at: :desc\n    )\n  }\n\n  def classification\n    amount.negative? ? \"income\" : \"expense\"\n  end\n\n  def lock_saved_attributes!\n    super\n    entryable.lock_saved_attributes!\n  end\n\n  def sync_account_later\n    sync_start_date = [ date_previously_was, date ].compact.min unless destroyed?\n    account.sync_later(window_start_date: sync_start_date)\n  end\n\n  def entryable_name_short\n    entryable_type.demodulize.underscore\n  end\n\n  def balance_trend(entries, balances)\n    Balance::TrendCalculator.new(self, entries, balances).trend\n  end\n\n  def linked?\n    plaid_id.present?\n  end\n\n  class << self\n    def search(params)\n      EntrySearch.new(params).build_query(all)\n    end\n\n    # arbitrary cutoff date to avoid expensive sync operations\n    def min_supported_date\n      30.years.ago.to_date\n    end\n\n    def bulk_update!(bulk_update_params)\n      bulk_attributes = {\n        date: bulk_update_params[:date],\n        notes: bulk_update_params[:notes],\n        entryable_attributes: {\n          category_id: bulk_update_params[:category_id],\n          merchant_id: bulk_update_params[:merchant_id],\n          tag_ids: bulk_update_params[:tag_ids]\n        }.compact_blank\n      }.compact_blank\n\n      return 0 if bulk_attributes.blank?\n\n      transaction do\n        all.each do |entry|\n          bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present?\n          entry.update! bulk_attributes\n\n          entry.lock_saved_attributes!\n          entry.entryable.lock_attr!(:tag_ids) if entry.transaction? && entry.transaction.tags.any?\n        end\n      end\n\n      all.size\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/entry_search.rb",
    "content": "class EntrySearch\n  include ActiveModel::Model\n  include ActiveModel::Attributes\n\n  attribute :search, :string\n  attribute :amount, :string\n  attribute :amount_operator, :string\n  attribute :types, :string\n  attribute :accounts, array: true\n  attribute :account_ids, array: true\n  attribute :start_date, :string\n  attribute :end_date, :string\n\n  class << self\n    def apply_search_filter(scope, search)\n      return scope if search.blank?\n\n      query = scope\n      query = query.where(\"entries.name ILIKE :search\",\n        search: \"%#{ActiveRecord::Base.sanitize_sql_like(search)}%\"\n      )\n      query\n    end\n\n    def apply_date_filters(scope, start_date, end_date)\n      return scope if start_date.blank? && end_date.blank?\n\n      query = scope\n      query = query.where(\"entries.date >= ?\", start_date) if start_date.present?\n      query = query.where(\"entries.date <= ?\", end_date) if end_date.present?\n      query\n    end\n\n    def apply_amount_filter(scope, amount, amount_operator)\n      return scope if amount.blank? || amount_operator.blank?\n\n      query = scope\n\n      case amount_operator\n      when \"equal\"\n        query = query.where(\"ABS(ABS(entries.amount) - ?) <= 0.01\", amount.to_f.abs)\n      when \"less\"\n        query = query.where(\"ABS(entries.amount) < ?\", amount.to_f.abs)\n      when \"greater\"\n        query = query.where(\"ABS(entries.amount) > ?\", amount.to_f.abs)\n      end\n\n      query\n    end\n\n    def apply_accounts_filter(scope, accounts, account_ids)\n      return scope if accounts.blank? && account_ids.blank?\n\n      query = scope\n      query = query.where(accounts: { name: accounts }) if accounts.present?\n      query = query.where(accounts: { id: account_ids }) if account_ids.present?\n      query\n    end\n  end\n\n  def build_query(scope)\n    query = scope.joins(:account)\n    query = self.class.apply_search_filter(query, search)\n    query = self.class.apply_date_filters(query, start_date, end_date)\n    query = self.class.apply_amount_filter(query, amount, amount_operator)\n    query = self.class.apply_accounts_filter(query, accounts, account_ids)\n    query\n  end\nend\n"
  },
  {
    "path": "app/models/entryable.rb",
    "content": "module Entryable\n  extend ActiveSupport::Concern\n\n  TYPES = %w[Valuation Transaction Trade]\n\n  def self.from_type(entryable_type)\n    entryable_type.presence_in(TYPES).constantize\n  end\n\n  included do\n    include Enrichable\n\n    has_one :entry, as: :entryable, touch: true\n\n    scope :with_entry, -> { joins(:entry) }\n\n    scope :visible, -> { with_entry.merge(Entry.visible) }\n\n    scope :in_period, ->(period) {\n      with_entry.where(entries: { date: period.start_date..period.end_date })\n    }\n\n    scope :reverse_chronological, -> {\n      with_entry.merge(Entry.reverse_chronological)\n    }\n\n    scope :chronological, -> {\n      with_entry.merge(Entry.chronological)\n    }\n  end\nend\n"
  },
  {
    "path": "app/models/exchange_rate/importer.rb",
    "content": "class ExchangeRate::Importer\n  MissingExchangeRateError = Class.new(StandardError)\n  MissingStartRateError = Class.new(StandardError)\n\n  def initialize(exchange_rate_provider:, from:, to:, start_date:, end_date:, clear_cache: false)\n    @exchange_rate_provider = exchange_rate_provider\n    @from = from\n    @to = to\n    @start_date = start_date\n    @end_date = normalize_end_date(end_date)\n    @clear_cache = clear_cache\n  end\n\n  # Constructs a daily series of rates for the given currency pair for date range\n  def import_provider_rates\n    if !clear_cache && all_rates_exist?\n      Rails.logger.info(\"No new rates to sync for #{from} to #{to} between #{start_date} and #{end_date}, skipping\")\n      return\n    end\n\n    if provider_rates.empty?\n      Rails.logger.warn(\"Could not fetch rates for #{from} to #{to} between #{start_date} and #{end_date} because provider returned no rates\")\n      return\n    end\n\n    prev_rate_value = start_rate_value\n\n    unless prev_rate_value.present?\n      error = MissingStartRateError.new(\"Could not find a start rate for #{from} to #{to} between #{start_date} and #{end_date}\")\n      Rails.logger.error(error.message)\n      Sentry.capture_exception(error)\n      return\n    end\n\n    gapfilled_rates = effective_start_date.upto(end_date).map do |date|\n      db_rate_value = db_rates[date]&.rate\n      provider_rate_value = provider_rates[date]&.rate\n\n      chosen_rate = if clear_cache\n        provider_rate_value || db_rate_value   # overwrite when possible\n      else\n        db_rate_value || provider_rate_value   # fill gaps\n      end\n\n      # Gapfill with LOCF strategy (last observation carried forward)\n      if chosen_rate.nil?\n        chosen_rate = prev_rate_value\n      end\n\n      prev_rate_value = chosen_rate\n\n      {\n        from_currency: from,\n        to_currency: to,\n        date: date,\n        rate: chosen_rate\n      }\n    end\n\n    upsert_rows(gapfilled_rates)\n  end\n\n  private\n    attr_reader :exchange_rate_provider, :from, :to, :start_date, :end_date, :clear_cache\n\n    def upsert_rows(rows)\n      batch_size = 200\n\n      total_upsert_count = 0\n\n      rows.each_slice(batch_size) do |batch|\n        upserted_ids = ExchangeRate.upsert_all(\n          batch,\n          unique_by: %i[from_currency to_currency date],\n          returning: [ \"id\" ]\n        )\n\n        total_upsert_count += upserted_ids.count\n      end\n\n      total_upsert_count\n    end\n\n    # Since provider may not return values on weekends and holidays, we grab the first rate from the provider that is on or before the start date\n    def start_rate_value\n      provider_rate_value = provider_rates.select { |date, _| date <= start_date }.max_by { |date, _| date }&.last\n      db_rate_value = db_rates[start_date]&.rate\n      provider_rate_value || db_rate_value\n    end\n\n    # No need to fetch/upsert rates for dates that we already have in the DB\n    def effective_start_date\n      return start_date if clear_cache\n\n      first_missing_date = nil\n\n      start_date.upto(end_date) do |date|\n        unless db_rates.key?(date)\n          first_missing_date = date\n          break\n        end\n      end\n\n      first_missing_date || end_date\n    end\n\n    def provider_rates\n      @provider_rates ||= begin\n        # Always fetch with a 5 day buffer to ensure we have a starting rate (for weekends and holidays)\n        provider_fetch_start_date = effective_start_date - 5.days\n\n        provider_response = exchange_rate_provider.fetch_exchange_rates(\n          from: from,\n          to: to,\n          start_date: provider_fetch_start_date,\n          end_date: end_date\n        )\n\n        if provider_response.success?\n          provider_response.data.index_by(&:date)\n        else\n          message = \"#{exchange_rate_provider.class.name} could not fetch exchange rate pair from: #{from} to: #{to} between: #{effective_start_date} and: #{Date.current}.  Provider error: #{provider_response.error.message}\"\n          Rails.logger.warn(message)\n          Sentry.capture_exception(MissingExchangeRateError.new(message), level: :warning)\n          {}\n        end\n      end\n    end\n\n    def all_rates_exist?\n      db_count == expected_count\n    end\n\n    def expected_count\n      (start_date..end_date).count\n    end\n\n    def db_count\n      db_rates.count\n    end\n\n    def db_rates\n      @db_rates ||= ExchangeRate.where(from_currency: from, to_currency: to, date: start_date..end_date)\n                  .order(:date)\n                  .to_a\n                  .index_by(&:date)\n    end\n\n    # Normalizes an end date so that it never exceeds today's date in the\n    # America/New_York timezone. If the caller passes a future date we clamp\n    # it to today so that upstream provider calls remain valid and predictable.\n    def normalize_end_date(requested_end_date)\n      today_est = Date.current.in_time_zone(\"America/New_York\").to_date\n      [ requested_end_date, today_est ].min\n    end\nend\n"
  },
  {
    "path": "app/models/exchange_rate/provided.rb",
    "content": "module ExchangeRate::Provided\n  extend ActiveSupport::Concern\n\n  class_methods do\n    def provider\n      registry = Provider::Registry.for_concept(:exchange_rates)\n      registry.get_provider(:synth)\n    end\n\n    def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)\n      rate = find_by(from_currency: from, to_currency: to, date: date)\n      return rate if rate.present?\n\n      return nil unless provider.present? # No provider configured (some self-hosted apps)\n\n      response = provider.fetch_exchange_rate(from: from, to: to, date: date)\n\n      return nil unless response.success? # Provider error\n\n      rate = response.data\n      ExchangeRate.find_or_create_by!(\n        from_currency: rate.from,\n        to_currency: rate.to,\n        date: rate.date,\n        rate: rate.rate\n      ) if cache\n      rate\n    end\n\n    # @return [Integer] The number of exchange rates synced\n    def import_provider_rates(from:, to:, start_date:, end_date:, clear_cache: false)\n      unless provider.present?\n        Rails.logger.warn(\"No provider configured for ExchangeRate.import_provider_rates\")\n        return 0\n      end\n\n      ExchangeRate::Importer.new(\n        exchange_rate_provider: provider,\n        from: from,\n        to: to,\n        start_date: start_date,\n        end_date: end_date,\n        clear_cache: clear_cache\n      ).import_provider_rates\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/exchange_rate.rb",
    "content": "class ExchangeRate < ApplicationRecord\n  include Provided\n\n  validates :from_currency, :to_currency, :date, :rate, presence: true\n  validates :date, uniqueness: { scope: %i[from_currency to_currency] }\nend\n"
  },
  {
    "path": "app/models/family/auto_categorizer.rb",
    "content": "class Family::AutoCategorizer\n  Error = Class.new(StandardError)\n\n  def initialize(family, transaction_ids: [])\n    @family = family\n    @transaction_ids = transaction_ids\n  end\n\n  def auto_categorize\n    raise Error, \"No LLM provider for auto-categorization\" unless llm_provider\n\n    if scope.none?\n      Rails.logger.info(\"No transactions to auto-categorize for family #{family.id}\")\n      return\n    else\n      Rails.logger.info(\"Auto-categorizing #{scope.count} transactions for family #{family.id}\")\n    end\n\n    result = llm_provider.auto_categorize(\n      transactions: transactions_input,\n      user_categories: user_categories_input\n    )\n\n    unless result.success?\n      Rails.logger.error(\"Failed to auto-categorize transactions for family #{family.id}: #{result.error.message}\")\n      return\n    end\n\n    scope.each do |transaction|\n      auto_categorization = result.data.find { |c| c.transaction_id == transaction.id }\n\n      category_id = user_categories_input.find { |c| c[:name] == auto_categorization&.category_name }&.dig(:id)\n\n      if category_id.present?\n        transaction.enrich_attribute(\n          :category_id,\n          category_id,\n          source: \"ai\"\n        )\n      end\n\n      transaction.lock_attr!(:category_id)\n    end\n  end\n\n  private\n    attr_reader :family, :transaction_ids\n\n    # For now, OpenAI only, but this should work with any LLM concept provider\n    def llm_provider\n      Provider::Registry.get_provider(:openai)\n    end\n\n    def user_categories_input\n      family.categories.map do |category|\n        {\n          id: category.id,\n          name: category.name,\n          is_subcategory: category.subcategory?,\n          parent_id: category.parent_id,\n          classification: category.classification\n        }\n      end\n    end\n\n    def transactions_input\n      scope.map do |transaction|\n        {\n          id: transaction.id,\n          amount: transaction.entry.amount.abs,\n          classification: transaction.entry.classification,\n          description: transaction.entry.name,\n          merchant: transaction.merchant&.name\n        }\n      end\n    end\n\n    def scope\n      family.transactions.where(id: transaction_ids, category_id: nil)\n                         .enrichable(:category_id)\n                         .includes(:category, :merchant, :entry)\n    end\nend\n"
  },
  {
    "path": "app/models/family/auto_merchant_detector.rb",
    "content": "class Family::AutoMerchantDetector\n  Error = Class.new(StandardError)\n\n  def initialize(family, transaction_ids: [])\n    @family = family\n    @transaction_ids = transaction_ids\n  end\n\n  def auto_detect\n    raise \"No LLM provider for auto-detecting merchants\" unless llm_provider\n\n    if scope.none?\n      Rails.logger.info(\"No transactions to auto-detect merchants for family #{family.id}\")\n      return\n    else\n      Rails.logger.info(\"Auto-detecting merchants for #{scope.count} transactions for family #{family.id}\")\n    end\n\n    result = llm_provider.auto_detect_merchants(\n      transactions: transactions_input,\n      user_merchants: user_merchants_input\n    )\n\n    unless result.success?\n      Rails.logger.error(\"Failed to auto-detect merchants for family #{family.id}: #{result.error.message}\")\n      return\n    end\n\n    scope.each do |transaction|\n      auto_detection = result.data.find { |c| c.transaction_id == transaction.id }\n\n      merchant_id = user_merchants_input.find { |m| m[:name] == auto_detection&.business_name }&.dig(:id)\n\n      if merchant_id.nil? && auto_detection&.business_url.present? && auto_detection&.business_name.present?\n        ai_provider_merchant = ProviderMerchant.find_or_create_by!(\n          source: \"ai\",\n          name: auto_detection.business_name,\n          website_url: auto_detection.business_url,\n        ) do |pm|\n          pm.logo_url = \"#{default_logo_provider_url}/#{auto_detection.business_url}\"\n        end\n      end\n\n      merchant_id = merchant_id || ai_provider_merchant&.id\n\n      if merchant_id.present?\n        transaction.enrich_attribute(\n          :merchant_id,\n          merchant_id,\n          source: \"ai\"\n        )\n\n      end\n\n      # We lock the attribute so that this Rule doesn't try to run again\n      transaction.lock_attr!(:merchant_id)\n    end\n  end\n\n  private\n    attr_reader :family, :transaction_ids\n\n    # For now, OpenAI only, but this should work with any LLM concept provider\n    def llm_provider\n      Provider::Registry.get_provider(:openai)\n    end\n\n    def default_logo_provider_url\n      \"https://logo.synthfinance.com\"\n    end\n\n    def user_merchants_input\n      family.merchants.map do |merchant|\n        {\n          id: merchant.id,\n          name: merchant.name\n        }\n      end\n    end\n\n    def transactions_input\n      scope.map do |transaction|\n        {\n          id: transaction.id,\n          amount: transaction.entry.amount.abs,\n          classification: transaction.entry.classification,\n          description: transaction.entry.name,\n          merchant: transaction.merchant&.name\n        }\n      end\n    end\n\n    def scope\n      family.transactions.where(id: transaction_ids, merchant_id: nil)\n                         .enrichable(:merchant_id)\n                         .includes(:merchant, :entry)\n    end\nend\n"
  },
  {
    "path": "app/models/family/auto_transfer_matchable.rb",
    "content": "module Family::AutoTransferMatchable\n  def transfer_match_candidates\n    Entry.select([\n      \"inflow_candidates.entryable_id as inflow_transaction_id\",\n      \"outflow_candidates.entryable_id as outflow_transaction_id\",\n      \"ABS(inflow_candidates.date - outflow_candidates.date) as date_diff\"\n    ]).from(\"entries inflow_candidates\")\n      .joins(\"\n        JOIN entries outflow_candidates ON (\n          inflow_candidates.amount < 0 AND\n          outflow_candidates.amount > 0 AND\n          inflow_candidates.account_id <> outflow_candidates.account_id AND\n          inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4\n        )\n      \").joins(\"\n        LEFT JOIN transfers existing_transfers ON (\n          existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id OR\n          existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id\n        )\n      \")\n      .joins(\"LEFT JOIN rejected_transfers ON (\n        rejected_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND\n        rejected_transfers.outflow_transaction_id = outflow_candidates.entryable_id\n      )\")\n      .joins(\"LEFT JOIN exchange_rates ON (\n        exchange_rates.date = outflow_candidates.date AND\n        exchange_rates.from_currency = outflow_candidates.currency AND\n        exchange_rates.to_currency = inflow_candidates.currency\n      )\")\n      .joins(\"JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id\")\n      .joins(\"JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id\")\n      .where(\"inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?\", self.id, self.id)\n      .where(\"inflow_accounts.status IN ('draft', 'active')\")\n      .where(\"outflow_accounts.status IN ('draft', 'active')\")\n      .where(\"inflow_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'\")\n      .where(\"\n        (\n          inflow_candidates.currency = outflow_candidates.currency AND\n          inflow_candidates.amount = -outflow_candidates.amount\n        ) OR (\n          inflow_candidates.currency <> outflow_candidates.currency AND\n          ABS(inflow_candidates.amount / NULLIF(outflow_candidates.amount * exchange_rates.rate, 0)) BETWEEN 0.95 AND 1.05\n        )\n      \")\n      .where(existing_transfers: { id: nil })\n      .order(\"date_diff ASC\") # Closest matches first\n  end\n\n  def auto_match_transfers!\n    # Exclude already matched transfers\n    candidates_scope = transfer_match_candidates.where(rejected_transfers: { id: nil })\n\n    # Track which transactions we've already matched to avoid duplicates\n    used_transaction_ids = Set.new\n\n    candidates = []\n\n    Transfer.transaction do\n      candidates_scope.each do |match|\n        next if used_transaction_ids.include?(match.inflow_transaction_id) ||\n               used_transaction_ids.include?(match.outflow_transaction_id)\n\n        Transfer.create!(\n          inflow_transaction_id: match.inflow_transaction_id,\n          outflow_transaction_id: match.outflow_transaction_id,\n        )\n\n        Transaction.find(match.inflow_transaction_id).update!(kind: \"funds_movement\")\n        Transaction.find(match.outflow_transaction_id).update!(kind: Transfer.kind_for_account(Transaction.find(match.outflow_transaction_id).entry.account))\n\n        used_transaction_ids << match.inflow_transaction_id\n        used_transaction_ids << match.outflow_transaction_id\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/family/data_exporter.rb",
    "content": "require \"zip\"\nrequire \"csv\"\n\nclass Family::DataExporter\n  def initialize(family)\n    @family = family\n  end\n\n  def generate_export\n    # Create a StringIO to hold the zip data in memory\n    zip_data = Zip::OutputStream.write_buffer do |zipfile|\n      # Add accounts.csv\n      zipfile.put_next_entry(\"accounts.csv\")\n      zipfile.write generate_accounts_csv\n\n      # Add transactions.csv\n      zipfile.put_next_entry(\"transactions.csv\")\n      zipfile.write generate_transactions_csv\n\n      # Add trades.csv\n      zipfile.put_next_entry(\"trades.csv\")\n      zipfile.write generate_trades_csv\n\n      # Add categories.csv\n      zipfile.put_next_entry(\"categories.csv\")\n      zipfile.write generate_categories_csv\n\n      # Add all.ndjson\n      zipfile.put_next_entry(\"all.ndjson\")\n      zipfile.write generate_ndjson\n    end\n\n    # Rewind and return the StringIO\n    zip_data.rewind\n    zip_data\n  end\n\n  private\n\n    def generate_accounts_csv\n      CSV.generate do |csv|\n        csv << [ \"id\", \"name\", \"type\", \"subtype\", \"balance\", \"currency\", \"created_at\" ]\n\n        # Only export accounts belonging to this family\n        @family.accounts.includes(:accountable).find_each do |account|\n          csv << [\n            account.id,\n            account.name,\n            account.accountable_type,\n            account.subtype,\n            account.balance.to_s,\n            account.currency,\n            account.created_at.iso8601\n          ]\n        end\n      end\n    end\n\n    def generate_transactions_csv\n      CSV.generate do |csv|\n        csv << [ \"date\", \"account_name\", \"amount\", \"name\", \"category\", \"tags\", \"notes\", \"currency\" ]\n\n        # Only export transactions from accounts belonging to this family\n        @family.transactions\n          .includes(:category, :tags, entry: :account)\n          .find_each do |transaction|\n            csv << [\n              transaction.entry.date.iso8601,\n              transaction.entry.account.name,\n              transaction.entry.amount.to_s,\n              transaction.entry.name,\n              transaction.category&.name,\n              transaction.tags.pluck(:name).join(\",\"),\n              transaction.entry.notes,\n              transaction.entry.currency\n            ]\n          end\n      end\n    end\n\n    def generate_trades_csv\n      CSV.generate do |csv|\n        csv << [ \"date\", \"account_name\", \"ticker\", \"quantity\", \"price\", \"amount\", \"currency\" ]\n\n        # Only export trades from accounts belonging to this family\n        @family.trades\n          .includes(:security, entry: :account)\n          .find_each do |trade|\n            csv << [\n              trade.entry.date.iso8601,\n              trade.entry.account.name,\n              trade.security.ticker,\n              trade.qty.to_s,\n              trade.price.to_s,\n              trade.entry.amount.to_s,\n              trade.currency\n            ]\n          end\n      end\n    end\n\n    def generate_categories_csv\n      CSV.generate do |csv|\n        csv << [ \"name\", \"color\", \"parent_category\", \"classification\" ]\n\n        # Only export categories belonging to this family\n        @family.categories.includes(:parent).find_each do |category|\n          csv << [\n            category.name,\n            category.color,\n            category.parent&.name,\n            category.classification\n          ]\n        end\n      end\n    end\n\n    def generate_ndjson\n      lines = []\n\n      # Export accounts with full accountable data\n      @family.accounts.includes(:accountable).find_each do |account|\n        lines << {\n          type: \"Account\",\n          data: account.as_json(\n            include: {\n              accountable: {}\n            }\n          )\n        }.to_json\n      end\n\n      # Export categories\n      @family.categories.find_each do |category|\n        lines << {\n          type: \"Category\",\n          data: category.as_json\n        }.to_json\n      end\n\n      # Export tags\n      @family.tags.find_each do |tag|\n        lines << {\n          type: \"Tag\",\n          data: tag.as_json\n        }.to_json\n      end\n\n      # Export merchants (only family merchants)\n      @family.merchants.find_each do |merchant|\n        lines << {\n          type: \"Merchant\",\n          data: merchant.as_json\n        }.to_json\n      end\n\n      # Export transactions with full data\n      @family.transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction|\n        lines << {\n          type: \"Transaction\",\n          data: {\n            id: transaction.id,\n            entry_id: transaction.entry.id,\n            account_id: transaction.entry.account_id,\n            date: transaction.entry.date,\n            amount: transaction.entry.amount,\n            currency: transaction.entry.currency,\n            name: transaction.entry.name,\n            notes: transaction.entry.notes,\n            excluded: transaction.entry.excluded,\n            category_id: transaction.category_id,\n            merchant_id: transaction.merchant_id,\n            tag_ids: transaction.tag_ids,\n            kind: transaction.kind,\n            created_at: transaction.created_at,\n            updated_at: transaction.updated_at\n          }\n        }.to_json\n      end\n\n      # Export trades with full data\n      @family.trades.includes(:security, entry: :account).find_each do |trade|\n        lines << {\n          type: \"Trade\",\n          data: {\n            id: trade.id,\n            entry_id: trade.entry.id,\n            account_id: trade.entry.account_id,\n            security_id: trade.security_id,\n            ticker: trade.security.ticker,\n            date: trade.entry.date,\n            qty: trade.qty,\n            price: trade.price,\n            amount: trade.entry.amount,\n            currency: trade.currency,\n            created_at: trade.created_at,\n            updated_at: trade.updated_at\n          }\n        }.to_json\n      end\n\n      # Export valuations\n      @family.entries.valuations.includes(:account, :entryable).find_each do |entry|\n        lines << {\n          type: \"Valuation\",\n          data: {\n            id: entry.entryable.id,\n            entry_id: entry.id,\n            account_id: entry.account_id,\n            date: entry.date,\n            amount: entry.amount,\n            currency: entry.currency,\n            name: entry.name,\n            created_at: entry.created_at,\n            updated_at: entry.updated_at\n          }\n        }.to_json\n      end\n\n      # Export budgets\n      @family.budgets.find_each do |budget|\n        lines << {\n          type: \"Budget\",\n          data: budget.as_json\n        }.to_json\n      end\n\n      # Export budget categories\n      @family.budget_categories.includes(:budget, :category).find_each do |budget_category|\n        lines << {\n          type: \"BudgetCategory\",\n          data: budget_category.as_json\n        }.to_json\n      end\n\n      lines.join(\"\\n\")\n    end\nend\n"
  },
  {
    "path": "app/models/family/plaid_connectable.rb",
    "content": "module Family::PlaidConnectable\n  extend ActiveSupport::Concern\n\n  included do\n    has_many :plaid_items, dependent: :destroy\n  end\n\n  def can_connect_plaid_us?\n    plaid(:us).present?\n  end\n\n  # If Plaid provider is configured and user is in the EU region\n  def can_connect_plaid_eu?\n    plaid(:eu).present? && self.eu?\n  end\n\n  def create_plaid_item!(public_token:, item_name:, region:)\n    public_token_response = plaid(region).exchange_public_token(public_token)\n\n    plaid_item = plaid_items.create!(\n      name: item_name,\n      plaid_id: public_token_response.item_id,\n      access_token: public_token_response.access_token,\n      plaid_region: region\n    )\n\n    plaid_item.sync_later\n\n    plaid_item\n  end\n\n  def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil)\n    return nil unless plaid(region)\n\n    plaid(region).get_link_token(\n      user_id: self.id,\n      webhooks_url: webhooks_url,\n      redirect_url: redirect_url,\n      accountable_type: accountable_type,\n      access_token: access_token\n    ).link_token\n  end\n\n  private\n    def plaid(region)\n      Provider::Registry.plaid_provider_for_region(region)\n    end\nend\n"
  },
  {
    "path": "app/models/family/subscribeable.rb",
    "content": "module Family::Subscribeable\n  extend ActiveSupport::Concern\n\n  included do\n    has_one :subscription, dependent: :destroy\n  end\n\n  def billing_email\n    primary_admin = users.admin.order(:created_at).first || users.super_admin.order(:created_at).first\n\n    unless primary_admin.present?\n      raise \"No primary admin found for family #{id}.  This is an invalid data state and should never occur.\"\n    end\n\n    primary_admin.email\n  end\n\n  def upgrade_required?\n    return false if self_hoster?\n    return false if subscription&.active? || subscription&.trialing?\n\n    true\n  end\n\n  def can_start_trial?\n    subscription&.trial_ends_at.blank?\n  end\n\n  def start_trial_subscription!\n    create_subscription!(\n      status: \"trialing\",\n      trial_ends_at: Subscription.new_trial_ends_at\n    )\n  end\n\n  def trialing?\n    subscription&.trialing? && days_left_in_trial.positive?\n  end\n\n  def has_active_subscription?\n    subscription&.active?\n  end\n\n  def needs_subscription?\n    subscription.nil? && !self_hoster?\n  end\n\n  def next_billing_date\n    subscription&.current_period_ends_at\n  end\n\n  def start_subscription!(stripe_subscription_id)\n    if subscription.present?\n      subscription.update!(status: \"active\", stripe_id: stripe_subscription_id)\n    else\n      create_subscription!(status: \"active\", stripe_id: stripe_subscription_id)\n    end\n  end\n\n  def days_left_in_trial\n    return -1 unless subscription.present?\n    ((subscription.trial_ends_at - Time.current).to_i / 86400) + 1\n  end\n\n  def percentage_of_trial_remaining\n    return 0 unless subscription.present?\n    (days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100\n  end\n\n  def percentage_of_trial_completed\n    return 0 unless subscription.present?\n    (1 - days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100\n  end\n\n  def sync_trial_status!\n    if subscription&.status == \"trialing\" && days_left_in_trial < 0\n      subscription.update!(status: \"paused\")\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/family/sync_complete_event.rb",
    "content": "class Family::SyncCompleteEvent\n  attr_reader :family\n\n  def initialize(family)\n    @family = family\n  end\n\n  def broadcast\n    family.broadcast_replace(\n      target: \"balance-sheet\",\n      partial: \"pages/dashboard/balance_sheet\",\n      locals: { balance_sheet: family.balance_sheet }\n    )\n\n    family.broadcast_replace(\n      target: \"net-worth-chart\",\n      partial: \"pages/dashboard/net_worth_chart\",\n      locals: { balance_sheet: family.balance_sheet, period: Period.last_30_days }\n    )\n  end\nend\n"
  },
  {
    "path": "app/models/family/syncer.rb",
    "content": "class Family::Syncer\n  attr_reader :family\n\n  def initialize(family)\n    @family = family\n  end\n\n  def perform_sync(sync)\n    # We don't rely on this value to guard the app, but keep it eventually consistent\n    family.sync_trial_status!\n\n    Rails.logger.info(\"Applying rules for family #{family.id}\")\n    family.rules.each do |rule|\n      rule.apply_later\n    end\n\n    # Schedule child syncs\n    child_syncables.each do |syncable|\n      syncable.sync_later(parent_sync: sync, window_start_date: sync.window_start_date, window_end_date: sync.window_end_date)\n    end\n  end\n\n  def perform_post_sync\n    family.auto_match_transfers!\n  end\n\n  private\n    def child_syncables\n      family.plaid_items + family.accounts.manual\n    end\nend\n"
  },
  {
    "path": "app/models/family.rb",
    "content": "class Family < ApplicationRecord\n  include PlaidConnectable, Syncable, AutoTransferMatchable, Subscribeable\n\n  DATE_FORMATS = [\n    [ \"MM-DD-YYYY\", \"%m-%d-%Y\" ],\n    [ \"DD.MM.YYYY\", \"%d.%m.%Y\" ],\n    [ \"DD-MM-YYYY\", \"%d-%m-%Y\" ],\n    [ \"YYYY-MM-DD\", \"%Y-%m-%d\" ],\n    [ \"DD/MM/YYYY\", \"%d/%m/%Y\" ],\n    [ \"YYYY/MM/DD\", \"%Y/%m/%d\" ],\n    [ \"MM/DD/YYYY\", \"%m/%d/%Y\" ],\n    [ \"D/MM/YYYY\", \"%e/%m/%Y\" ],\n    [ \"YYYY.MM.DD\", \"%Y.%m.%d\" ]\n  ].freeze\n\n  has_many :users, dependent: :destroy\n  has_many :accounts, dependent: :destroy\n  has_many :invitations, dependent: :destroy\n\n  has_many :imports, dependent: :destroy\n  has_many :family_exports, dependent: :destroy\n\n  has_many :entries, through: :accounts\n  has_many :transactions, through: :accounts\n  has_many :rules, dependent: :destroy\n  has_many :trades, through: :accounts\n  has_many :holdings, through: :accounts\n\n  has_many :tags, dependent: :destroy\n  has_many :categories, dependent: :destroy\n  has_many :merchants, dependent: :destroy, class_name: \"FamilyMerchant\"\n\n  has_many :budgets, dependent: :destroy\n  has_many :budget_categories, through: :budgets\n\n  validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }\n  validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }\n\n  def assigned_merchants\n    merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq\n    Merchant.where(id: merchant_ids)\n  end\n\n  def auto_categorize_transactions_later(transactions)\n    AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id))\n  end\n\n  def auto_categorize_transactions(transaction_ids)\n    AutoCategorizer.new(self, transaction_ids: transaction_ids).auto_categorize\n  end\n\n  def auto_detect_transaction_merchants_later(transactions)\n    AutoDetectMerchantsJob.perform_later(self, transaction_ids: transactions.pluck(:id))\n  end\n\n  def auto_detect_transaction_merchants(transaction_ids)\n    AutoMerchantDetector.new(self, transaction_ids: transaction_ids).auto_detect\n  end\n\n  def balance_sheet\n    @balance_sheet ||= BalanceSheet.new(self)\n  end\n\n  def income_statement\n    @income_statement ||= IncomeStatement.new(self)\n  end\n\n  def eu?\n    country != \"US\" && country != \"CA\"\n  end\n\n  def requires_data_provider?\n    # If family has any trades, they need a provider for historical prices\n    return true if trades.any?\n\n    # If family has any accounts not denominated in the family's currency, they need a provider for historical exchange rates\n    return true if accounts.where.not(currency: self.currency).any?\n\n    # If family has any entries in different currencies, they need a provider for historical exchange rates\n    uniq_currencies = entries.pluck(:currency).uniq\n    return true if uniq_currencies.count > 1\n    return true if uniq_currencies.count > 0 && uniq_currencies.first != self.currency\n\n    false\n  end\n\n  def missing_data_provider?\n    requires_data_provider? && Provider::Registry.get_provider(:synth).nil?\n  end\n\n  def oldest_entry_date\n    entries.order(:date).first&.date || Date.current\n  end\n\n  # Used for invalidating family / balance sheet related aggregation queries\n  def build_cache_key(key, invalidate_on_data_updates: false)\n    # Our data sync process updates this timestamp whenever any family account successfully completes a data update.\n    # By including it in the cache key, we can expire caches every time family account data changes.\n    data_invalidation_key = invalidate_on_data_updates ? latest_sync_completed_at : nil\n\n    [\n      id,\n      key,\n      data_invalidation_key,\n      accounts.maximum(:updated_at)\n    ].compact.join(\"_\")\n  end\n\n  # Used for invalidating entry related aggregation queries\n  def entries_cache_version\n    @entries_cache_version ||= begin\n      ts = entries.maximum(:updated_at)\n      ts.present? ? ts.to_i : 0\n    end\n  end\n\n  def self_hoster?\n    Rails.application.config.app_mode.self_hosted?\n  end\nend\n"
  },
  {
    "path": "app/models/family_export.rb",
    "content": "class FamilyExport < ApplicationRecord\n  belongs_to :family\n\n  has_one_attached :export_file\n\n  enum :status, {\n    pending: \"pending\",\n    processing: \"processing\",\n    completed: \"completed\",\n    failed: \"failed\"\n  }, default: :pending, validate: true\n\n  scope :ordered, -> { order(created_at: :desc) }\n\n  def filename\n    \"maybe_export_#{created_at.strftime('%Y%m%d_%H%M%S')}.zip\"\n  end\n\n  def downloadable?\n    completed? && export_file.attached?\n  end\nend\n"
  },
  {
    "path": "app/models/family_merchant.rb",
    "content": "class FamilyMerchant < Merchant\n  COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]\n\n  belongs_to :family\n\n  before_validation :set_default_color\n\n  validates :color, presence: true\n  validates :name, uniqueness: { scope: :family }\n\n  private\n    def set_default_color\n      self.color = COLORS.sample\n    end\nend\n"
  },
  {
    "path": "app/models/holding/forward_calculator.rb",
    "content": "class Holding::ForwardCalculator\n  attr_reader :account\n\n  def initialize(account)\n    @account = account\n  end\n\n  def calculate\n    Rails.logger.tagged(\"Holding::ForwardCalculator\") do\n      current_portfolio = generate_starting_portfolio\n      next_portfolio = {}\n      holdings = []\n\n      account.start_date.upto(Date.current).each do |date|\n        trades = portfolio_cache.get_trades(date: date)\n        next_portfolio = transform_portfolio(current_portfolio, trades, direction: :forward)\n        holdings += build_holdings(next_portfolio, date)\n        current_portfolio = next_portfolio\n      end\n\n      Holding.gapfill(holdings)\n    end\n  end\n\n  private\n    def portfolio_cache\n      @portfolio_cache ||= Holding::PortfolioCache.new(account)\n    end\n\n    def empty_portfolio\n      securities = portfolio_cache.get_securities\n      securities.each_with_object({}) { |security, hash| hash[security.id] = 0 }\n    end\n\n    def generate_starting_portfolio\n      empty_portfolio\n    end\n\n    def transform_portfolio(previous_portfolio, trade_entries, direction: :forward)\n      new_quantities = previous_portfolio.dup\n\n      trade_entries.each do |trade_entry|\n        trade = trade_entry.entryable\n        security_id = trade.security_id\n        qty_change = trade.qty\n        qty_change = qty_change * -1 if direction == :reverse\n        new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change\n      end\n\n      new_quantities\n    end\n\n    def build_holdings(portfolio, date, price_source: nil)\n      portfolio.map do |security_id, qty|\n        price = portfolio_cache.get_price(security_id, date, source: price_source)\n\n        if price.nil?\n          next\n        end\n\n        Holding.new(\n          account_id: account.id,\n          security_id: security_id,\n          date: date,\n          qty: qty,\n          price: price.price,\n          currency: price.currency,\n          amount: qty * price.price\n        )\n      end.compact\n    end\nend\n"
  },
  {
    "path": "app/models/holding/gapfillable.rb",
    "content": "module Holding::Gapfillable\n  extend ActiveSupport::Concern\n\n  class_methods do\n    def gapfill(holdings)\n      filled_holdings = []\n\n      holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|\n        next if security_holdings.empty?\n\n        sorted = security_holdings.sort_by(&:date)\n        previous_holding = sorted.first\n\n        sorted.first.date.upto(Date.current) do |date|\n          holding = security_holdings.find { |h| h.date == date }\n\n          if holding\n            filled_holdings << holding\n            previous_holding = holding\n          else\n            # Create a new holding based on the previous day's data\n            filled_holdings << Holding.new(\n              account: previous_holding.account,\n              security: previous_holding.security,\n              date: date,\n              qty: previous_holding.qty,\n              price: previous_holding.price,\n              currency: previous_holding.currency,\n              amount: previous_holding.amount\n            )\n          end\n        end\n      end\n\n      filled_holdings\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/holding/materializer.rb",
    "content": "# \"Materializes\" holdings (similar to a DB materialized view, but done at the app level)\n# into a series of records we can easily query and join with other data.\nclass Holding::Materializer\n  def initialize(account, strategy:)\n    @account = account\n    @strategy = strategy\n  end\n\n  def materialize_holdings\n    calculate_holdings\n\n    Rails.logger.info(\"Persisting #{@holdings.size} holdings\")\n    persist_holdings\n\n    if strategy == :forward\n      purge_stale_holdings\n    end\n\n    @holdings\n  end\n\n  private\n    attr_reader :account, :strategy\n\n    def calculate_holdings\n      @holdings = calculator.calculate\n    end\n\n    def persist_holdings\n      current_time = Time.now\n\n      account.holdings.upsert_all(\n        @holdings.map { |h| h.attributes\n               .slice(\"date\", \"currency\", \"qty\", \"price\", \"amount\", \"security_id\")\n               .merge(\"account_id\" => account.id, \"updated_at\" => current_time) },\n        unique_by: %i[account_id security_id date currency]\n      )\n    end\n\n    def purge_stale_holdings\n      portfolio_security_ids = account.entries.trades.map { |entry| entry.entryable.security_id }.uniq\n\n      # If there are no securities in the portfolio, delete all holdings\n      if portfolio_security_ids.empty?\n        Rails.logger.info(\"Clearing all holdings (no securities)\")\n        account.holdings.delete_all\n      else\n        deleted_count = account.holdings.delete_by(\"date < ? OR security_id NOT IN (?)\", account.start_date, portfolio_security_ids)\n        Rails.logger.info(\"Purged #{deleted_count} stale holdings\") if deleted_count > 0\n      end\n    end\n\n    def calculator\n      if strategy == :reverse\n        portfolio_snapshot = Holding::PortfolioSnapshot.new(account)\n        Holding::ReverseCalculator.new(account, portfolio_snapshot: portfolio_snapshot)\n      else\n        Holding::ForwardCalculator.new(account)\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/holding/portfolio_cache.rb",
    "content": "class Holding::PortfolioCache\n  attr_reader :account, :use_holdings\n\n  class SecurityNotFound < StandardError\n    def initialize(security_id, account_id)\n      super(\"Security id=#{security_id} not found in portfolio cache for account #{account_id}.  This should not happen unless securities were preloaded incorrectly.\")\n    end\n  end\n\n  def initialize(account, use_holdings: false)\n    @account = account\n    @use_holdings = use_holdings\n    load_prices\n  end\n\n  def get_trades(date: nil)\n    if date.blank?\n      trades\n    else\n      trades.select { |t| t.date == date }\n    end\n  end\n\n  def get_price(security_id, date, source: nil)\n    security = @security_cache[security_id]\n    raise SecurityNotFound.new(security_id, account.id) unless security\n\n    if source.present?\n      price = security[:prices].select { |p| p.price.date == date && p.source == source }.min_by(&:priority)&.price\n    else\n      price = security[:prices].select { |p| p.price.date == date }.min_by(&:priority)&.price\n    end\n\n    return nil unless price\n\n    price_money = Money.new(price.price, price.currency)\n\n    converted_amount = price_money.exchange_to(account.currency, fallback_rate: 1).amount\n\n    Security::Price.new(\n      security_id: security_id,\n      date: price.date,\n      price: converted_amount,\n      currency: account.currency\n    )\n  end\n\n  def get_securities\n    @security_cache.map { |_, v| v[:security] }\n  end\n\n  private\n    PriceWithPriority = Data.define(:price, :priority, :source)\n\n    def trades\n      @trades ||= account.entries.includes(entryable: :security).trades.chronological.to_a\n    end\n\n    def holdings\n      @holdings ||= account.holdings.chronological.to_a\n    end\n\n    def collect_unique_securities\n      unique_securities_from_trades = trades.map(&:entryable).map(&:security).uniq\n\n      return unique_securities_from_trades unless use_holdings\n\n      unique_securities_from_holdings = holdings.map(&:security).uniq\n\n      (unique_securities_from_trades + unique_securities_from_holdings).uniq\n    end\n\n    # Loads all known prices for all securities in the account with priority based on source:\n    # 1 - DB or provider prices\n    # 2 - Trade prices\n    # 3 - Holding prices\n    def load_prices\n      @security_cache = {}\n      securities = collect_unique_securities\n\n      Rails.logger.info \"Preloading #{securities.size} securities for account #{account.id}\"\n\n      securities.each do |security|\n        Rails.logger.info \"Loading security: ID=#{security.id} Ticker=#{security.ticker}\"\n\n        # High priority prices from DB (synced from provider)\n        db_prices = security.prices.where(date: account.start_date..Date.current).map do |price|\n          PriceWithPriority.new(\n            price: price,\n            priority: 1,\n            source: \"db\"\n          )\n        end\n\n        # Medium priority prices from trades\n        trade_prices = trades\n          .select { |t| t.entryable.security_id == security.id }\n          .map do |trade|\n            PriceWithPriority.new(\n              price: Security::Price.new(\n                security: security,\n                price: trade.entryable.price,\n                currency: trade.entryable.currency,\n                date: trade.date\n              ),\n              priority: 2,\n              source: \"trade\"\n            )\n          end\n\n        # Low priority prices from holdings (if applicable)\n        holding_prices = if use_holdings\n          holdings.select { |h| h.security_id == security.id }.map do |holding|\n            PriceWithPriority.new(\n              price: Security::Price.new(\n                security: security,\n                price: holding.price,\n                currency: holding.currency,\n                date: holding.date\n              ),\n              priority: 3,\n              source: \"holding\"\n            )\n          end\n        else\n          []\n        end\n\n        @security_cache[security.id] = {\n          security: security,\n          prices: db_prices + trade_prices + holding_prices\n        }\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/holding/portfolio_snapshot.rb",
    "content": "# Captures the most recent holding quantities for each security in an account's portfolio.\n# Returns a portfolio hash compatible with the reverse calculator's format.\nclass Holding::PortfolioSnapshot\n  attr_reader :account\n\n  def initialize(account)\n    @account = account\n  end\n\n  # Returns a hash of {security_id => qty} representing today's starting portfolio.\n  # Includes all securities from trades (with 0 qty if no holdings exist).\n  def to_h\n    @portfolio ||= build_portfolio\n  end\n\n  private\n    def build_portfolio\n      # Start with all securities from trades initialized to 0\n      portfolio = account.trades\n        .pluck(:security_id)\n        .uniq\n        .each_with_object({}) { |security_id, hash| hash[security_id] = 0 }\n\n      # Get the most recent holding for each security and update quantities\n      account.holdings\n        .select(\"DISTINCT ON (security_id) security_id, qty\")\n        .order(:security_id, date: :desc)\n        .each { |holding| portfolio[holding.security_id] = holding.qty }\n\n      portfolio\n    end\nend\n"
  },
  {
    "path": "app/models/holding/reverse_calculator.rb",
    "content": "class Holding::ReverseCalculator\n  attr_reader :account, :portfolio_snapshot\n\n  def initialize(account, portfolio_snapshot:)\n    @account = account\n    @portfolio_snapshot = portfolio_snapshot\n  end\n\n  def calculate\n    Rails.logger.tagged(\"Holding::ReverseCalculator\") do\n      holdings = calculate_holdings\n      Holding.gapfill(holdings)\n    end\n  end\n\n  private\n    # Reverse calculators will use the existing holdings as a source of security ids and prices\n    # since it is common for a provider to supply \"current day\" holdings but not all the historical\n    # trades that make up those holdings.\n    def portfolio_cache\n      @portfolio_cache ||= Holding::PortfolioCache.new(account, use_holdings: true)\n    end\n\n    def calculate_holdings\n      # Start with the portfolio snapshot passed in from the materializer\n      current_portfolio = portfolio_snapshot.to_h\n      previous_portfolio = {}\n\n      holdings = []\n\n      Date.current.downto(account.start_date).each do |date|\n        today_trades = portfolio_cache.get_trades(date: date)\n        previous_portfolio = transform_portfolio(current_portfolio, today_trades, direction: :reverse)\n\n        # If current day, always use holding prices (since that's what Plaid gives us).  For historical values, use market data (since Plaid doesn't supply historical prices)\n        holdings += build_holdings(current_portfolio, date, price_source: date == Date.current ? \"holding\" : nil)\n        current_portfolio = previous_portfolio\n      end\n\n      holdings\n    end\n\n    def transform_portfolio(previous_portfolio, trade_entries, direction: :forward)\n      new_quantities = previous_portfolio.dup\n\n      trade_entries.each do |trade_entry|\n        trade = trade_entry.entryable\n        security_id = trade.security_id\n        qty_change = trade.qty\n        qty_change = qty_change * -1 if direction == :reverse\n        new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change\n      end\n\n      new_quantities\n    end\n\n    def build_holdings(portfolio, date, price_source: nil)\n      portfolio.map do |security_id, qty|\n        price = portfolio_cache.get_price(security_id, date, source: price_source)\n\n        if price.nil?\n          next\n        end\n\n        Holding.new(\n          account_id: account.id,\n          security_id: security_id,\n          date: date,\n          qty: qty,\n          price: price.price,\n          currency: price.currency,\n          amount: qty * price.price\n        )\n      end.compact\n    end\nend\n"
  },
  {
    "path": "app/models/holding.rb",
    "content": "class Holding < ApplicationRecord\n  include Monetizable, Gapfillable\n\n  monetize :amount\n\n  belongs_to :account\n  belongs_to :security\n\n  validates :qty, :currency, :date, :price, :amount, presence: true\n  validates :qty, :price, :amount, numericality: { greater_than_or_equal_to: 0 }\n\n  scope :chronological, -> { order(:date) }\n  scope :for, ->(security) { where(security_id: security).order(:date) }\n\n  delegate :ticker, to: :security\n\n  def name\n    security.name || ticker\n  end\n\n  def weight\n    return nil unless amount\n    return 0 if amount.zero?\n\n    account.balance.zero? ? 1 : amount / account.balance * 100\n  end\n\n  # Basic approximation of cost-basis\n  def avg_cost\n    avg_cost = account.trades\n      .with_entry\n      .joins(ActiveRecord::Base.sanitize_sql_array([\n        \"LEFT JOIN exchange_rates ON (\n          exchange_rates.date = entries.date AND\n          exchange_rates.from_currency = trades.currency AND\n          exchange_rates.to_currency = ?\n        )\", account.currency\n      ]))\n      .where(security_id: security.id)\n      .where(\"trades.qty > 0 AND entries.date <= ?\", date)\n      .average(\"trades.price * COALESCE(exchange_rates.rate, 1)\")\n\n    Money.new(avg_cost || price, currency)\n  end\n\n  def trend\n    @trend ||= calculate_trend\n  end\n\n  def trades\n    account.entries.where(entryable: account.trades.where(security: security)).reverse_chronological\n  end\n\n  def destroy_holding_and_entries!\n    transaction do\n      account.entries.where(entryable: account.trades.where(security: security)).destroy_all\n      destroy\n    end\n\n    account.sync_later\n  end\n\n  private\n    def calculate_trend\n      return nil unless amount_money\n\n      start_amount = qty * avg_cost\n\n      Trend.new \\\n        current: amount_money,\n        previous: start_amount\n    end\nend\n"
  },
  {
    "path": "app/models/impersonation_session.rb",
    "content": "class ImpersonationSession < ApplicationRecord\n  belongs_to :impersonator, class_name: \"User\"\n  belongs_to :impersonated, class_name: \"User\"\n\n  has_many :logs, class_name: \"ImpersonationSessionLog\"\n\n  enum :status, { pending: \"pending\", in_progress: \"in_progress\", complete: \"complete\", rejected: \"rejected\" }\n\n  scope :initiated, -> { where(status: [ :pending, :in_progress ]) }\n\n  validate :impersonator_is_super_admin\n  validate :impersonated_is_not_super_admin\n  validate :impersonator_different_from_impersonated\n\n  def approve!\n    update! status: :in_progress\n  end\n\n  def reject!\n    update! status: :rejected\n  end\n\n  def complete!\n    update! status: :complete\n  end\n\n  private\n    def impersonator_is_super_admin\n      errors.add(:impersonator, \"must be a super admin to impersonate\") unless impersonator.super_admin?\n    end\n\n    def impersonated_is_not_super_admin\n      errors.add(:impersonated, \"cannot be a super admin\") if impersonated.super_admin?\n    end\n\n    def impersonator_different_from_impersonated\n      errors.add(:impersonator, \"cannot be the same as the impersonated user\") if impersonator == impersonated\n    end\nend\n"
  },
  {
    "path": "app/models/impersonation_session_log.rb",
    "content": "class ImpersonationSessionLog < ApplicationRecord\n  belongs_to :impersonation_session\nend\n"
  },
  {
    "path": "app/models/import/account_mapping.rb",
    "content": "class Import::AccountMapping < Import::Mapping\n  validates :mappable, presence: true, if: :requires_mapping?\n\n  class << self\n    def mappables_by_key(import)\n      unique_values = import.rows.map(&:account).uniq\n      accounts = import.family.accounts.where(name: unique_values).index_by(&:name)\n\n      unique_values.index_with { |value| accounts[value] }\n    end\n  end\n\n  def selectable_values\n    family_accounts = import.family.accounts.manual.alphabetically.map { |account| [ account.name, account.id ] }\n\n    unless key.blank?\n      family_accounts.unshift [ \"Add as new account\", CREATE_NEW_KEY ]\n    end\n\n    family_accounts\n  end\n\n  def requires_selection?\n    true\n  end\n\n  def values_count\n    import.rows.where(account: key).count\n  end\n\n  def mappable_class\n    Account\n  end\n\n  def create_mappable!\n    return unless creatable?\n\n    account = import.family.accounts.create_or_find_by!(name: key) do |new_account|\n      new_account.balance = 0\n      new_account.import = import\n      new_account.currency = import.family.currency\n      new_account.accountable = Depository.new\n    end\n\n    self.mappable = account\n    save!\n  end\n\n  private\n    def requires_mapping?\n      (key.blank? || !create_when_empty) && import.account.nil?\n    end\nend\n"
  },
  {
    "path": "app/models/import/account_type_mapping.rb",
    "content": "class Import::AccountTypeMapping < Import::Mapping\n  validates :value, presence: true\n\n  class << self\n    def mappables_by_key(import)\n      import.rows.map(&:entity_type).uniq.index_with { nil }\n    end\n  end\n\n  def selectable_values\n    Accountable::TYPES.map { |type| [ type.titleize, type ] }\n  end\n\n  def requires_selection?\n    true\n  end\n\n  def values_count\n    import.rows.where(entity_type: key).count\n  end\n\n  def create_mappable!\n    # no-op\n  end\nend\n"
  },
  {
    "path": "app/models/import/category_mapping.rb",
    "content": "class Import::CategoryMapping < Import::Mapping\n  class << self\n    def mappables_by_key(import)\n      unique_values = import.rows.map(&:category).uniq\n      categories = import.family.categories.where(name: unique_values).index_by(&:name)\n\n      unique_values.index_with { |value| categories[value] }\n    end\n  end\n\n  def selectable_values\n    family_categories = import.family.categories.alphabetically.map { |category| [ category.name, category.id ] }\n\n    unless key.blank?\n      family_categories.unshift [ \"Add as new category\", CREATE_NEW_KEY ]\n    end\n\n    family_categories\n  end\n\n  def requires_selection?\n    false\n  end\n\n  def values_count\n    import.rows.where(category: key).count\n  end\n\n  def mappable_class\n    Category\n  end\n\n  def create_mappable!\n    return unless creatable?\n\n    self.mappable = import.family.categories.find_or_create_by!(name: key)\n    save!\n  end\nend\n"
  },
  {
    "path": "app/models/import/mapping.rb",
    "content": "class Import::Mapping < ApplicationRecord\n  CREATE_NEW_KEY = \"internal_new_resource\"\n\n  belongs_to :import\n  belongs_to :mappable, polymorphic: true, optional: true\n\n  validates :key, presence: true, uniqueness: { scope: [ :import_id, :type ] }, allow_blank: true\n\n  scope :for_import, ->(import) { where(import: import) }\n  scope :creational, -> { where(create_when_empty: true, mappable: nil) }\n  scope :categories, -> { where(type: \"Import::CategoryMapping\") }\n  scope :tags, -> { where(type: \"Import::TagMapping\") }\n  scope :accounts, -> { where(type: \"Import::AccountMapping\") }\n  scope :account_types, -> { where(type: \"Import::AccountTypeMapping\") }\n\n  class << self\n    def mappable_for(key)\n      find_by(key: key)&.mappable\n    end\n\n    def mappables_by_key(import)\n      raise NotImplementedError, \"Subclass must implement mappables_by_key\"\n    end\n  end\n\n  def selectable_values\n    raise NotImplementedError, \"Subclass must implement selectable_values\"\n  end\n\n  def values_count\n    raise NotImplementedError, \"Subclass must implement values_count\"\n  end\n\n  def mappable_class\n    nil\n  end\n\n  def creatable?\n    mappable.nil? && key.present? && create_when_empty\n  end\n\n  def create_mappable!\n    raise NotImplementedError, \"Subclass must implement create_mappable!\"\n  end\nend\n"
  },
  {
    "path": "app/models/import/row.rb",
    "content": "class Import::Row < ApplicationRecord\n  belongs_to :import\n\n  validates :amount, numericality: true, allow_blank: true\n  validates :currency, presence: true\n\n  validate :date_valid\n  validate :required_columns\n  validate :currency_is_valid\n\n  scope :ordered, -> { order(:id) }\n\n  def tags_list\n    if tags.blank?\n      [ \"\" ]\n    else\n      tags.split(\"|\").map(&:strip)\n    end\n  end\n\n  def date_iso\n    Date.strptime(date, import.date_format).iso8601\n  end\n\n  def signed_amount\n    if import.type == \"TradeImport\"\n      price.to_d * apply_trade_signage_convention(qty.to_d)\n    else\n      apply_transaction_signage_convention(amount.to_d)\n    end\n  end\n\n  def update_and_sync(params)\n    assign_attributes(params)\n    save!(validate: false)\n    import.sync_mappings\n  end\n\n  private\n    # In the Maybe system, positive quantities == \"inflows\"\n    def apply_trade_signage_convention(value)\n      value * (import.signage_convention == \"inflows_positive\" ? 1 : -1)\n    end\n\n    # In the Maybe system, positive amounts == \"outflows\", so we must reverse signage\n    def apply_transaction_signage_convention(value)\n      if import.amount_type_strategy == \"signed_amount\"\n        value * (import.signage_convention == \"inflows_positive\" ? -1 : 1)\n      elsif import.amount_type_strategy == \"custom_column\"\n        inflow_value = import.amount_type_inflow_value\n\n        if entity_type == inflow_value\n          value * -1\n        else\n          value\n        end\n      else\n        raise \"Unknown amount type strategy for import: #{import.amount_type_strategy}\"\n      end\n    end\n\n    def required_columns\n      import.required_column_keys.each do |required_key|\n        errors.add(required_key, \"is required\") if self[required_key].blank?\n      end\n    end\n\n    def date_valid\n      return if date.blank?\n\n      parsed_date = Date.strptime(date, import.date_format) rescue nil\n\n      if parsed_date.nil?\n        errors.add(:date, \"must exactly match the format: #{import.date_format}\")\n        return\n      end\n\n      min_date = Entry.min_supported_date\n      max_date = Date.current\n\n      if parsed_date < min_date || parsed_date > max_date\n        errors.add(:date, \"must be between #{min_date} and #{max_date}\")\n      end\n    end\n\n    def currency_is_valid\n      return true if currency.blank?\n\n      begin\n        Money::Currency.new(currency)\n      rescue Money::Currency::UnknownCurrencyError\n        errors.add(:currency, \"is not a valid currency code\")\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/import/tag_mapping.rb",
    "content": "class Import::TagMapping < Import::Mapping\n  class << self\n    def mappables_by_key(import)\n      unique_values = import.rows.map(&:tags_list).flatten.uniq\n\n      tags = import.family.tags.where(name: unique_values).index_by(&:name)\n\n      unique_values.index_with { |value| tags[value] }\n    end\n  end\n\n  def selectable_values\n    family_tags = import.family.tags.alphabetically.map { |tag| [ tag.name, tag.id ] }\n\n    unless key.blank?\n      family_tags.unshift [ \"Add as new tag\", CREATE_NEW_KEY ]\n    end\n\n    family_tags\n  end\n\n  def requires_selection?\n    false\n  end\n\n  def values_count\n    import.rows.map(&:tags_list).flatten.count { |tag| tag == key }\n  end\n\n  def mappable_class\n    Tag\n  end\n\n  def create_mappable!\n    return unless creatable?\n\n    self.mappable = import.family.tags.find_or_create_by!(name: key)\n    save!\n  end\nend\n"
  },
  {
    "path": "app/models/import.rb",
    "content": "class Import < ApplicationRecord\n  MaxRowCountExceededError = Class.new(StandardError)\n\n  TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze\n  SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative]\n  SEPARATORS = [ [ \"Comma (,)\", \",\" ], [ \"Semicolon (;)\", \";\" ] ].freeze\n\n  NUMBER_FORMATS = {\n    \"1,234.56\" => { separator: \".\", delimiter: \",\" },  # US/UK/Asia\n    \"1.234,56\" => { separator: \",\", delimiter: \".\" },  # Most of Europe\n    \"1 234,56\" => { separator: \",\", delimiter: \" \" },  # French/Scandinavian\n    \"1,234\"    => { separator: \"\",  delimiter: \",\" }   # Zero-decimal currencies like JPY\n  }.freeze\n\n  AMOUNT_TYPE_STRATEGIES = %w[signed_amount custom_column].freeze\n\n  belongs_to :family\n  belongs_to :account, optional: true\n\n  before_validation :set_default_number_format\n\n  scope :ordered, -> { order(created_at: :desc) }\n\n  enum :status, {\n    pending: \"pending\",\n    complete: \"complete\",\n    importing: \"importing\",\n    reverting: \"reverting\",\n    revert_failed: \"revert_failed\",\n    failed: \"failed\"\n  }, validate: true, default: \"pending\"\n\n  validates :type, inclusion: { in: TYPES }\n  validates :amount_type_strategy, inclusion: { in: AMOUNT_TYPE_STRATEGIES }\n  validates :col_sep, inclusion: { in: SEPARATORS.map(&:last) }\n  validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }, allow_nil: true\n  validates :number_format, presence: true, inclusion: { in: NUMBER_FORMATS.keys }\n\n  has_many :rows, dependent: :destroy\n  has_many :mappings, dependent: :destroy\n  has_many :accounts, dependent: :destroy\n  has_many :entries, dependent: :destroy\n\n  class << self\n    def parse_csv_str(csv_str, col_sep: \",\")\n      CSV.parse(\n        (csv_str || \"\").strip,\n        headers: true,\n        col_sep: col_sep,\n        converters: [ ->(str) { str&.strip } ],\n        liberal_parsing: true\n      )\n    end\n  end\n\n  def publish_later\n    raise MaxRowCountExceededError if row_count_exceeded?\n    raise \"Import is not publishable\" unless publishable?\n\n    update! status: :importing\n\n    ImportJob.perform_later(self)\n  end\n\n  def publish\n    raise MaxRowCountExceededError if row_count_exceeded?\n\n    import!\n\n    family.sync_later\n\n    update! status: :complete\n  rescue => error\n    update! status: :failed, error: error.message\n  end\n\n  def revert_later\n    raise \"Import is not revertable\" unless revertable?\n\n    update! status: :reverting\n\n    RevertImportJob.perform_later(self)\n  end\n\n  def revert\n    Import.transaction do\n      accounts.destroy_all\n      entries.destroy_all\n    end\n\n    family.sync_later\n\n    update! status: :pending\n  rescue => error\n    update! status: :revert_failed, error: error.message\n  end\n\n  def csv_rows\n    @csv_rows ||= parsed_csv\n  end\n\n  def csv_headers\n    parsed_csv.headers\n  end\n\n  def csv_sample\n    @csv_sample ||= parsed_csv.first(2)\n  end\n\n  def dry_run\n    mappings = {\n      transactions: rows.count,\n      categories: Import::CategoryMapping.for_import(self).creational.count,\n      tags: Import::TagMapping.for_import(self).creational.count\n    }\n\n    mappings.merge(\n      accounts: Import::AccountMapping.for_import(self).creational.count,\n    ) if account.nil?\n\n    mappings\n  end\n\n  def required_column_keys\n    []\n  end\n\n  def column_keys\n    raise NotImplementedError, \"Subclass must implement column_keys\"\n  end\n\n  def generate_rows_from_csv\n    rows.destroy_all\n\n    mapped_rows = csv_rows.map do |row|\n      {\n        account: row[account_col_label].to_s,\n        date: row[date_col_label].to_s,\n        qty: sanitize_number(row[qty_col_label]).to_s,\n        ticker: row[ticker_col_label].to_s,\n        exchange_operating_mic: row[exchange_operating_mic_col_label].to_s,\n        price: sanitize_number(row[price_col_label]).to_s,\n        amount: sanitize_number(row[amount_col_label]).to_s,\n        currency: (row[currency_col_label] || default_currency).to_s,\n        name: (row[name_col_label] || default_row_name).to_s,\n        category: row[category_col_label].to_s,\n        tags: row[tags_col_label].to_s,\n        entity_type: row[entity_type_col_label].to_s,\n        notes: row[notes_col_label].to_s\n      }\n    end\n\n    rows.insert_all!(mapped_rows)\n  end\n\n  def sync_mappings\n    transaction do\n      mapping_steps.each do |mapping_class|\n        mappables_by_key = mapping_class.mappables_by_key(self)\n\n        updated_mappings = mappables_by_key.map do |key, mappable|\n          mapping = mappings.find_or_initialize_by(key: key, import: self, type: mapping_class.name)\n          mapping.mappable = mappable\n          mapping.create_when_empty = key.present? && mappable.nil?\n          mapping\n        end\n\n        updated_mappings.each { |m| m.save(validate: false) }\n        mapping_class.where.not(id: updated_mappings.map(&:id)).destroy_all\n      end\n    end\n  end\n\n  def mapping_steps\n    []\n  end\n\n  def uploaded?\n    raw_file_str.present?\n  end\n\n  def configured?\n    uploaded? && rows.any?\n  end\n\n  def cleaned?\n    configured? && rows.all?(&:valid?)\n  end\n\n  def publishable?\n    cleaned? && mappings.all?(&:valid?)\n  end\n\n  def revertable?\n    complete? || revert_failed?\n  end\n\n  def has_unassigned_account?\n    mappings.accounts.where(key: \"\").any?\n  end\n\n  def requires_account?\n    family.accounts.empty? && has_unassigned_account?\n  end\n\n  # Used to optionally pre-fill the configuration for the current import\n  def suggested_template\n    family.imports\n          .complete\n          .where(account: account, type: type)\n          .order(created_at: :desc)\n          .first\n  end\n\n  def apply_template!(import_template)\n    update!(\n      import_template.attributes.slice(\n        \"date_col_label\", \"amount_col_label\", \"name_col_label\",\n        \"category_col_label\", \"tags_col_label\", \"account_col_label\",\n        \"qty_col_label\", \"ticker_col_label\", \"price_col_label\",\n        \"entity_type_col_label\", \"notes_col_label\", \"currency_col_label\",\n        \"date_format\", \"signage_convention\", \"number_format\",\n        \"exchange_operating_mic_col_label\"\n      )\n    )\n  end\n\n  def max_row_count\n    10000\n  end\n\n  private\n    def row_count_exceeded?\n      rows.count > max_row_count\n    end\n\n    def import!\n      # no-op, subclasses can implement for customization of algorithm\n    end\n\n    def default_row_name\n      \"Imported item\"\n    end\n\n    def default_currency\n      family.currency\n    end\n\n    def parsed_csv\n      @parsed_csv ||= self.class.parse_csv_str(raw_file_str, col_sep: col_sep)\n    end\n\n    def sanitize_number(value)\n      return \"\" if value.nil?\n\n      format = NUMBER_FORMATS[number_format]\n      return \"\" unless format\n\n      # First, normalize spaces and remove any characters that aren't numbers, delimiters, separators, or minus signs\n      sanitized = value.to_s.strip\n\n      # Handle French/Scandinavian format specially\n      if format[:delimiter] == \" \"\n        sanitized = sanitized.gsub(/\\s+/, \"\") # Remove all spaces first\n      else\n        sanitized = sanitized.gsub(/[^\\d#{Regexp.escape(format[:delimiter])}#{Regexp.escape(format[:separator])}\\-]/, \"\")\n\n        # Replace delimiter with empty string\n        if format[:delimiter].present?\n          sanitized = sanitized.gsub(format[:delimiter], \"\")\n        end\n      end\n\n      # Replace separator with period for proper float parsing\n      if format[:separator].present?\n        sanitized = sanitized.gsub(format[:separator], \".\")\n      end\n\n      # Return empty string if not a valid number\n      unless sanitized =~ /\\A-?\\d+\\.?\\d*\\z/\n        return \"\"\n      end\n\n      sanitized\n    end\n\n    def set_default_number_format\n      self.number_format ||= \"1,234.56\" # Default to US/UK format\n    end\nend\n"
  },
  {
    "path": "app/models/income_statement/category_stats.rb",
    "content": "class IncomeStatement::CategoryStats\n  def initialize(family, interval: \"month\")\n    @family = family\n    @interval = interval\n  end\n\n  def call\n    ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|\n      StatRow.new(\n        category_id: row[\"category_id\"],\n        classification: row[\"classification\"],\n        median: row[\"median\"],\n        avg: row[\"avg\"]\n      )\n    end\n  end\n\n  private\n    StatRow = Data.define(:category_id, :classification, :median, :avg)\n\n    def sanitized_query_sql\n      ActiveRecord::Base.sanitize_sql_array([\n        query_sql,\n        {\n          target_currency: @family.currency,\n          interval: @interval,\n          family_id: @family.id\n        }\n      ])\n    end\n\n    def query_sql\n      <<~SQL\n        WITH period_totals AS (\n          SELECT\n            c.id as category_id,\n            date_trunc(:interval, ae.date) as period,\n            CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,\n            SUM(ae.amount * COALESCE(er.rate, 1)) as total\n          FROM transactions t\n          JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction'\n          JOIN accounts a ON a.id = ae.account_id\n          LEFT JOIN categories c ON c.id = t.category_id\n          LEFT JOIN exchange_rates er ON (\n            er.date = ae.date AND\n            er.from_currency = ae.currency AND\n            er.to_currency = :target_currency\n          )\n          WHERE a.family_id = :family_id\n            AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')\n            AND ae.excluded = false\n          GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END\n        )\n        SELECT\n          category_id,\n          classification,\n          ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,\n          ABS(AVG(total)) as avg\n        FROM period_totals\n        GROUP BY category_id, classification;\n      SQL\n    end\nend\n"
  },
  {
    "path": "app/models/income_statement/family_stats.rb",
    "content": "class IncomeStatement::FamilyStats\n  def initialize(family, interval: \"month\")\n    @family = family\n    @interval = interval\n  end\n\n  def call\n    ActiveRecord::Base.connection.select_all(sanitized_query_sql).map do |row|\n      StatRow.new(\n        classification: row[\"classification\"],\n        median: row[\"median\"],\n        avg: row[\"avg\"]\n      )\n    end\n  end\n\n  private\n    StatRow = Data.define(:classification, :median, :avg)\n\n    def sanitized_query_sql\n      ActiveRecord::Base.sanitize_sql_array([\n        query_sql,\n        {\n          target_currency: @family.currency,\n          interval: @interval,\n          family_id: @family.id\n        }\n      ])\n    end\n\n    def query_sql\n      <<~SQL\n        WITH period_totals AS (\n          SELECT\n            date_trunc(:interval, ae.date) as period,\n            CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,\n            SUM(ae.amount * COALESCE(er.rate, 1)) as total\n          FROM transactions t\n          JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Transaction'\n          JOIN accounts a ON a.id = ae.account_id\n          LEFT JOIN exchange_rates er ON (\n            er.date = ae.date AND\n            er.from_currency = ae.currency AND\n            er.to_currency = :target_currency\n          )\n          WHERE a.family_id = :family_id\n            AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')\n            AND ae.excluded = false\n          GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END\n        )\n        SELECT\n          classification,\n          ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,\n          ABS(AVG(total)) as avg\n        FROM period_totals\n        GROUP BY classification;\n      SQL\n    end\nend\n"
  },
  {
    "path": "app/models/income_statement/totals.rb",
    "content": "class IncomeStatement::Totals\n  def initialize(family, transactions_scope:)\n    @family = family\n    @transactions_scope = transactions_scope\n  end\n\n  def call\n    ActiveRecord::Base.connection.select_all(query_sql).map do |row|\n      TotalsRow.new(\n        parent_category_id: row[\"parent_category_id\"],\n        category_id: row[\"category_id\"],\n        classification: row[\"classification\"],\n        total: row[\"total\"],\n        transactions_count: row[\"transactions_count\"]\n      )\n    end\n  end\n\n  private\n    TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :transactions_count)\n\n    def query_sql\n      ActiveRecord::Base.sanitize_sql_array([\n        optimized_query_sql,\n        sql_params\n      ])\n    end\n\n    # OPTIMIZED: Direct SUM aggregation without unnecessary time bucketing\n    # Eliminates CTE and intermediate date grouping for maximum performance\n    def optimized_query_sql\n      <<~SQL\n        SELECT\n          c.id as category_id,\n          c.parent_id as parent_category_id,\n          CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,\n          ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,\n          COUNT(ae.id) as transactions_count\n        FROM (#{@transactions_scope.to_sql}) at\n        JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'\n        LEFT JOIN categories c ON c.id = at.category_id\n        LEFT JOIN exchange_rates er ON (\n          er.date = ae.date AND\n          er.from_currency = ae.currency AND\n          er.to_currency = :target_currency\n        )\n        WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')\n          AND ae.excluded = false\n        GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;\n      SQL\n    end\n\n    def sql_params\n      {\n        target_currency: @family.currency\n      }\n    end\nend\n"
  },
  {
    "path": "app/models/income_statement.rb",
    "content": "class IncomeStatement\n  include Monetizable\n\n  monetize :median_expense, :median_income\n\n  attr_reader :family\n\n  def initialize(family)\n    @family = family\n  end\n\n  def totals(transactions_scope: nil)\n    transactions_scope ||= family.transactions.visible\n\n    result = totals_query(transactions_scope: transactions_scope)\n\n    total_income = result.select { |t| t.classification == \"income\" }.sum(&:total)\n    total_expense = result.select { |t| t.classification == \"expense\" }.sum(&:total)\n\n    ScopeTotals.new(\n      transactions_count: result.sum(&:transactions_count),\n      income_money: Money.new(total_income, family.currency),\n      expense_money: Money.new(total_expense, family.currency)\n    )\n  end\n\n  def expense_totals(period: Period.current_month)\n    build_period_total(classification: \"expense\", period: period)\n  end\n\n  def income_totals(period: Period.current_month)\n    build_period_total(classification: \"income\", period: period)\n  end\n\n  def median_expense(interval: \"month\", category: nil)\n    if category.present?\n      category_stats(interval: interval).find { |stat| stat.classification == \"expense\" && stat.category_id == category.id }&.median || 0\n    else\n      family_stats(interval: interval).find { |stat| stat.classification == \"expense\" }&.median || 0\n    end\n  end\n\n  def avg_expense(interval: \"month\", category: nil)\n    if category.present?\n      category_stats(interval: interval).find { |stat| stat.classification == \"expense\" && stat.category_id == category.id }&.avg || 0\n    else\n      family_stats(interval: interval).find { |stat| stat.classification == \"expense\" }&.avg || 0\n    end\n  end\n\n  def median_income(interval: \"month\")\n    family_stats(interval: interval).find { |stat| stat.classification == \"income\" }&.median || 0\n  end\n\n  private\n    ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money)\n    PeriodTotal = Data.define(:classification, :total, :currency, :category_totals)\n    CategoryTotal = Data.define(:category, :total, :currency, :weight)\n\n    def categories\n      @categories ||= family.categories.all.to_a\n    end\n\n    def build_period_total(classification:, period:)\n      totals = totals_query(transactions_scope: family.transactions.visible.in_period(period)).select { |t| t.classification == classification }\n      classification_total = totals.sum(&:total)\n\n      uncategorized_category = family.categories.uncategorized\n\n      category_totals = [ *categories, uncategorized_category ].map do |category|\n        subcategory = categories.find { |c| c.id == category.parent_id }\n\n        parent_category_total = totals.select { |t| t.category_id == category.id }&.sum(&:total) || 0\n\n        children_totals = if category == uncategorized_category\n          0\n        else\n          totals.select { |t| t.parent_category_id == category.id }&.sum(&:total) || 0\n        end\n\n        category_total = parent_category_total + children_totals\n\n        weight = (category_total.zero? ? 0 : category_total.to_f / classification_total) * 100\n\n        CategoryTotal.new(\n          category: category,\n          total: category_total,\n          currency: family.currency,\n          weight: weight,\n        )\n      end\n\n      PeriodTotal.new(\n        classification: classification,\n        total: category_totals.reject { |ct| ct.category.subcategory? }.sum(&:total),\n        currency: family.currency,\n        category_totals: category_totals\n      )\n    end\n\n    def family_stats(interval: \"month\")\n      @family_stats ||= {}\n      @family_stats[interval] ||= Rails.cache.fetch([\n        \"income_statement\", \"family_stats\", family.id, interval, family.entries_cache_version\n      ]) { FamilyStats.new(family, interval:).call }\n    end\n\n    def category_stats(interval: \"month\")\n      @category_stats ||= {}\n      @category_stats[interval] ||= Rails.cache.fetch([\n        \"income_statement\", \"category_stats\", family.id, interval, family.entries_cache_version\n      ]) { CategoryStats.new(family, interval:).call }\n    end\n\n    def totals_query(transactions_scope:)\n      sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)\n\n      Rails.cache.fetch([\n        \"income_statement\", \"totals_query\", family.id, sql_hash, family.entries_cache_version\n      ]) { Totals.new(family, transactions_scope: transactions_scope).call }\n    end\n\n    def monetizable_currency\n      family.currency\n    end\nend\n"
  },
  {
    "path": "app/models/investment.rb",
    "content": "class Investment < ApplicationRecord\n  include Accountable\n\n  SUBTYPES = {\n    \"brokerage\" => { short: \"Brokerage\", long: \"Brokerage\" },\n    \"pension\" => { short: \"Pension\", long: \"Pension\" },\n    \"retirement\" => { short: \"Retirement\", long: \"Retirement\" },\n    \"401k\" => { short: \"401(k)\", long: \"401(k)\" },\n    \"roth_401k\" => { short: \"Roth 401(k)\", long: \"Roth 401(k)\" },\n    \"529_plan\" => { short: \"529 Plan\", long: \"529 Plan\" },\n    \"hsa\" => { short: \"HSA\", long: \"Health Savings Account\" },\n    \"mutual_fund\" => { short: \"Mutual Fund\", long: \"Mutual Fund\" },\n    \"ira\" => { short: \"IRA\", long: \"Traditional IRA\" },\n    \"roth_ira\" => { short: \"Roth IRA\", long: \"Roth IRA\" },\n    \"angel\" => { short: \"Angel\", long: \"Angel\" }\n  }.freeze\n\n  class << self\n    def color\n      \"#1570EF\"\n    end\n\n    def classification\n      \"asset\"\n    end\n\n    def icon\n      \"line-chart\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/invitation.rb",
    "content": "class Invitation < ApplicationRecord\n  belongs_to :family\n  belongs_to :inviter, class_name: \"User\"\n\n  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }\n  validates :role, presence: true, inclusion: { in: %w[admin member] }\n  validates :token, presence: true, uniqueness: true\n  validates_uniqueness_of :email, scope: :family_id, message: \"has already been invited to this family\"\n  validate :inviter_is_admin\n\n  before_validation :generate_token, on: :create\n  before_create :set_expiration\n\n  scope :pending, -> { where(accepted_at: nil).where(\"expires_at > ?\", Time.current) }\n  scope :accepted, -> { where.not(accepted_at: nil) }\n\n  def pending?\n    accepted_at.nil? && expires_at > Time.current\n  end\n\n  private\n\n    def generate_token\n      loop do\n        self.token = SecureRandom.hex(32)\n        break unless self.class.exists?(token: token)\n      end\n    end\n\n    def set_expiration\n      self.expires_at = 3.days.from_now\n    end\n\n    def inviter_is_admin\n      inviter.admin?\n    end\nend\n"
  },
  {
    "path": "app/models/invite_code.rb",
    "content": "class InviteCode < ApplicationRecord\n  before_validation :generate_token, on: :create\n\n  class << self\n    def claim!(token)\n      if invite_code = find_by(token: token&.downcase)\n        invite_code.destroy!\n        true\n      end\n    end\n\n    def generate!\n      create!.token\n    end\n  end\n\n  private\n\n    def generate_token\n      loop do\n        self.token = SecureRandom.hex(4)\n        break token unless self.class.exists?(token: token)\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/loan.rb",
    "content": "class Loan < ApplicationRecord\n  include Accountable\n\n  SUBTYPES = {\n    \"mortgage\" => { short: \"Mortgage\", long: \"Mortgage\" },\n    \"student\" => { short: \"Student\", long: \"Student Loan\" },\n    \"auto\" => { short: \"Auto\", long: \"Auto Loan\" },\n    \"other\" => { short: \"Other\", long: \"Other Loan\" }\n  }.freeze\n\n  def monthly_payment\n    return nil if term_months.nil? || interest_rate.nil? || rate_type.nil? || rate_type != \"fixed\"\n    return Money.new(0, account.currency) if account.loan.original_balance.amount.zero? || term_months.zero?\n\n    annual_rate = interest_rate / 100.0\n    monthly_rate = annual_rate / 12.0\n\n    if monthly_rate.zero?\n      payment = account.loan.original_balance.amount / term_months\n    else\n      payment = (account.loan.original_balance.amount * monthly_rate * (1 + monthly_rate)**term_months) / ((1 + monthly_rate)**term_months - 1)\n    end\n\n    Money.new(payment.round, account.currency)\n  end\n\n  def original_balance\n    Money.new(account.first_valuation_amount, account.currency)\n  end\n\n  class << self\n    def color\n      \"#D444F1\"\n    end\n\n    def icon\n      \"hand-coins\"\n    end\n\n    def classification\n      \"liability\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/market_data_importer.rb",
    "content": "class MarketDataImporter\n  # By default, our graphs show 1M as the view, so by fetching 31 days,\n  # we ensure we can always show an accurate default graph\n  SNAPSHOT_DAYS = 31\n\n  InvalidModeError = Class.new(StandardError)\n\n  def initialize(mode: :full, clear_cache: false)\n    @mode = set_mode!(mode)\n    @clear_cache = clear_cache\n  end\n\n  def import_all\n    import_security_prices\n    import_exchange_rates\n  end\n\n  # Syncs historical security prices (and details)\n  def import_security_prices\n    unless Security.provider\n      Rails.logger.warn(\"No provider configured for MarketDataImporter.import_security_prices, skipping sync\")\n      return\n    end\n\n    # Import all securities that aren't marked as \"offline\" (i.e. they're available from the provider)\n    Security.online.find_each do |security|\n      security.import_provider_prices(\n        start_date: get_first_required_price_date(security),\n        end_date: end_date,\n        clear_cache: clear_cache\n      )\n\n      security.import_provider_details(clear_cache: clear_cache)\n    end\n  end\n\n  def import_exchange_rates\n    unless ExchangeRate.provider\n      Rails.logger.warn(\"No provider configured for MarketDataImporter.import_exchange_rates, skipping sync\")\n      return\n    end\n\n    required_exchange_rate_pairs.each do |pair|\n      # pair is a Hash with keys :source, :target, and :start_date\n      start_date = snapshot? ? default_start_date : pair[:start_date]\n\n      ExchangeRate.import_provider_rates(\n        from: pair[:source],\n        to: pair[:target],\n        start_date: start_date,\n        end_date: end_date,\n        clear_cache: clear_cache\n      )\n    end\n  end\n\n  private\n    attr_reader :mode, :clear_cache\n\n    def snapshot?\n      mode.to_sym == :snapshot\n    end\n\n    # Builds a unique list of currency pairs with the earliest date we need\n    # exchange rates for.\n    #\n    # Returns: Array of Hashes – [{ source:, target:, start_date: }, ...]\n    def required_exchange_rate_pairs\n      pair_dates = {} # { [source, target] => earliest_date }\n\n      # 1. ENTRY-BASED PAIRS – we need rates from the first entry date\n      Entry.joins(:account)\n           .where.not(\"entries.currency = accounts.currency\")\n           .group(\"entries.currency\", \"accounts.currency\")\n           .minimum(\"entries.date\")\n           .each do |(source, target), date|\n        key = [ source, target ]\n        pair_dates[key] = [ pair_dates[key], date ].compact.min\n      end\n\n      # 2. ACCOUNT-BASED PAIRS – use the account's oldest entry date\n      account_first_entry_dates = Entry.group(:account_id).minimum(:date)\n\n      Account.joins(:family)\n             .where.not(\"families.currency = accounts.currency\")\n             .select(\"accounts.id, accounts.currency AS source, families.currency AS target\")\n             .find_each do |account|\n        earliest_entry_date = account_first_entry_dates[account.id]\n\n        chosen_date = [ earliest_entry_date, default_start_date ].compact.min\n\n        key = [ account.source, account.target ]\n        pair_dates[key] = [ pair_dates[key], chosen_date ].compact.min\n      end\n\n      # Convert to array of hashes for ease of use\n      pair_dates.map do |(source, target), date|\n        { source: source, target: target, start_date: date }\n      end\n    end\n\n    def get_first_required_price_date(security)\n      return default_start_date if snapshot?\n\n      Trade.with_entry.where(security: security).minimum(:date) || default_start_date\n    end\n\n    # An approximation that grabs more than we likely need, but simplifies the logic\n    def get_first_required_exchange_rate_date(from_currency:)\n      return default_start_date if snapshot?\n\n      Entry.where(currency: from_currency).minimum(:date) || default_start_date\n    end\n\n    def default_start_date\n      SNAPSHOT_DAYS.days.ago.to_date\n    end\n\n    # Since we're querying market data from a US-based API, end date should always be today (EST)\n    def end_date\n      Date.current.in_time_zone(\"America/New_York\").to_date\n    end\n\n    def set_mode!(mode)\n      valid_modes = [ :full, :snapshot ]\n\n      unless valid_modes.include?(mode.to_sym)\n        raise InvalidModeError, \"Invalid mode for MarketDataImporter, can only be :full or :snapshot, but was #{mode}\"\n      end\n\n      mode.to_sym\n    end\nend\n"
  },
  {
    "path": "app/models/measurement.rb",
    "content": "class Measurement\n  include ActiveModel::Validations\n\n  attr_reader :value, :unit\n\n  VALID_UNITS = %w[sqft sqm mi km]\n\n  validates :unit, inclusion: { in: VALID_UNITS }\n  validates :value, presence: true\n\n  def initialize(value, unit)\n    @value = value.to_f\n    @unit = unit.to_s.downcase.strip\n    validate!\n  end\n\n  def to_s\n    \"#{@value.to_i} #{@unit}\"\n  end\nend\n"
  },
  {
    "path": "app/models/merchant.rb",
    "content": "class Merchant < ApplicationRecord\n  TYPES = %w[FamilyMerchant ProviderMerchant].freeze\n\n  has_many :transactions, dependent: :nullify\n\n  validates :name, presence: true\n  validates :type, inclusion: { in: TYPES }\n\n  scope :alphabetically, -> { order(:name) }\nend\n"
  },
  {
    "path": "app/models/message.rb",
    "content": "class Message < ApplicationRecord\n  belongs_to :chat\n  has_many :tool_calls, dependent: :destroy\n\n  enum :status, {\n    pending: \"pending\",\n    complete: \"complete\",\n    failed: \"failed\"\n  }\n\n  validates :content, presence: true\n\n  after_create_commit -> { broadcast_append_to chat, target: \"messages\" }, if: :broadcast?\n  after_update_commit -> { broadcast_update_to chat }, if: :broadcast?\n\n  scope :ordered, -> { order(created_at: :asc) }\n\n  private\n    def broadcast?\n      true\n    end\nend\n"
  },
  {
    "path": "app/models/mint_import.rb",
    "content": "class MintImport < Import\n  after_create :set_mappings\n\n  def generate_rows_from_csv\n    rows.destroy_all\n\n    mapped_rows = csv_rows.map do |row|\n      {\n        account: row[account_col_label].to_s,\n        date: row[date_col_label].to_s,\n        amount: signed_csv_amount(row).to_s,\n        currency: (row[currency_col_label] || default_currency).to_s,\n        name: (row[name_col_label] || default_row_name).to_s,\n        category: row[category_col_label].to_s,\n        tags: row[tags_col_label].to_s,\n        notes: row[notes_col_label].to_s\n      }\n    end\n\n    rows.insert_all!(mapped_rows)\n  end\n\n  def import!\n    transaction do\n      mappings.each(&:create_mappable!)\n\n      rows.each do |row|\n        account = mappings.accounts.mappable_for(row.account)\n        category = mappings.categories.mappable_for(row.category)\n        tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact\n\n        entry = account.entries.build \\\n          date: row.date_iso,\n          amount: row.signed_amount,\n          name: row.name,\n          currency: row.currency,\n          notes: row.notes,\n          entryable: Transaction.new(category: category, tags: tags),\n          import: self\n\n        entry.save!\n      end\n    end\n  end\n\n  def mapping_steps\n    [ Import::CategoryMapping, Import::TagMapping, Import::AccountMapping ]\n  end\n\n  def required_column_keys\n    %i[date amount]\n  end\n\n  def column_keys\n    %i[date amount name currency category tags account notes]\n  end\n\n  def csv_template\n    template = <<-CSV\n      Date,Amount,Account Name,Description,Category,Labels,Currency,Notes,Transaction Type\n      01/01/2024,-8.55,Checking,Starbucks,Food & Drink,Coffee|Breakfast,USD,Morning coffee,debit\n      04/15/2024,2000,Savings,Paycheck,Income,,USD,Bi-weekly salary,credit\n    CSV\n\n    CSV.parse(template, headers: true)\n  end\n\n  def signed_csv_amount(csv_row)\n    amount = csv_row[amount_col_label]\n    type = csv_row[\"Transaction Type\"]\n\n    if type == \"credit\"\n      amount.to_d\n    else\n      amount.to_d * -1\n    end\n  end\n\n  private\n    def set_mappings\n      self.signage_convention = \"inflows_positive\"\n      self.date_col_label = \"Date\"\n      self.date_format = \"%m/%d/%Y\"\n      self.name_col_label = \"Description\"\n      self.amount_col_label = \"Amount\"\n      self.currency_col_label = \"Currency\"\n      self.account_col_label = \"Account Name\"\n      self.category_col_label = \"Category\"\n      self.tags_col_label = \"Labels\"\n      self.notes_col_label = \"Notes\"\n      self.entity_type_col_label = \"Transaction Type\"\n\n      save!\n    end\nend\n"
  },
  {
    "path": "app/models/mobile_device.rb",
    "content": "class MobileDevice < ApplicationRecord\n  belongs_to :user\n  belongs_to :oauth_application, class_name: \"Doorkeeper::Application\", optional: true\n\n  validates :device_id, presence: true, uniqueness: { scope: :user_id }\n  validates :device_name, presence: true\n  validates :device_type, presence: true, inclusion: { in: %w[ios android] }\n\n  before_validation :set_last_seen_at, on: :create\n\n  scope :active, -> { where(\"last_seen_at > ?\", 90.days.ago) }\n\n  def active?\n    last_seen_at > 90.days.ago\n  end\n\n  def update_last_seen!\n    update_column(:last_seen_at, Time.current)\n  end\n\n  def create_oauth_application!\n    return oauth_application if oauth_application.present?\n\n    app = Doorkeeper::Application.create!(\n      name: \"Mobile App - #{device_id}\",\n      redirect_uri: \"maybe://oauth/callback\", # Custom scheme for mobile\n      scopes: \"read_write\", # Use the configured scope\n      confidential: false # Public client for mobile\n    )\n\n    # Store the association\n    update!(oauth_application: app)\n    app\n  end\n\n  def active_tokens\n    return Doorkeeper::AccessToken.none unless oauth_application\n\n    Doorkeeper::AccessToken\n      .where(application: oauth_application)\n      .where(resource_owner_id: user_id)\n      .where(revoked_at: nil)\n      .where(\"expires_in IS NULL OR created_at + expires_in * interval '1 second' > ?\", Time.current)\n  end\n\n  def revoke_all_tokens!\n    active_tokens.update_all(revoked_at: Time.current)\n  end\n\n  private\n\n    def set_last_seen_at\n      self.last_seen_at ||= Time.current\n    end\nend\n"
  },
  {
    "path": "app/models/other_asset.rb",
    "content": "class OtherAsset < ApplicationRecord\n  include Accountable\n\n  class << self\n    def color\n      \"#12B76A\"\n    end\n\n    def icon\n      \"plus\"\n    end\n\n    def classification\n      \"asset\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/other_liability.rb",
    "content": "class OtherLiability < ApplicationRecord\n  include Accountable\n\n  class << self\n    def color\n      \"#737373\"\n    end\n\n    def icon\n      \"minus\"\n    end\n\n    def classification\n      \"liability\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/period.rb",
    "content": "class Period\n  include ActiveModel::Validations, Comparable\n\n  class InvalidKeyError < StandardError; end\n\n  attr_reader :key, :start_date, :end_date\n\n  validates :start_date, :end_date, presence: true, if: -> { PERIODS[key].nil? }\n  validates :key, presence: true, if: -> { start_date.nil? || end_date.nil? }\n  validate :must_be_valid_date_range\n\n  PERIODS = {\n    \"last_day\" => {\n      date_range: -> { [ 1.day.ago.to_date, Date.current ] },\n      label_short: \"1D\",\n      label: \"Last Day\",\n      comparison_label: \"vs. yesterday\"\n    },\n    \"current_week\" => {\n      date_range: -> { [ Date.current.beginning_of_week, Date.current ] },\n      label_short: \"WTD\",\n      label: \"Current Week\",\n      comparison_label: \"vs. start of week\"\n    },\n    \"last_7_days\" => {\n      date_range: -> { [ 7.days.ago.to_date, Date.current ] },\n      label_short: \"7D\",\n      label: \"Last 7 Days\",\n      comparison_label: \"vs. last week\"\n    },\n    \"current_month\" => {\n      date_range: -> { [ Date.current.beginning_of_month, Date.current ] },\n      label_short: \"MTD\",\n      label: \"Current Month\",\n      comparison_label: \"vs. start of month\"\n    },\n    \"last_30_days\" => {\n      date_range: -> { [ 30.days.ago.to_date, Date.current ] },\n      label_short: \"30D\",\n      label: \"Last 30 Days\",\n      comparison_label: \"vs. last month\"\n    },\n    \"last_90_days\" => {\n      date_range: -> { [ 90.days.ago.to_date, Date.current ] },\n      label_short: \"90D\",\n      label: \"Last 90 Days\",\n      comparison_label: \"vs. last quarter\"\n    },\n    \"current_year\" => {\n      date_range: -> { [ Date.current.beginning_of_year, Date.current ] },\n      label_short: \"YTD\",\n      label: \"Current Year\",\n      comparison_label: \"vs. start of year\"\n    },\n    \"last_365_days\" => {\n      date_range: -> { [ 365.days.ago.to_date, Date.current ] },\n      label_short: \"365D\",\n      label: \"Last 365 Days\",\n      comparison_label: \"vs. 1 year ago\"\n    },\n    \"last_5_years\" => {\n      date_range: -> { [ 5.years.ago.to_date, Date.current ] },\n      label_short: \"5Y\",\n      label: \"Last 5 Years\",\n      comparison_label: \"vs. 5 years ago\"\n    }\n  }\n\n  class << self\n    def from_key(key)\n      unless PERIODS.key?(key)\n        raise InvalidKeyError, \"Invalid period key: #{key}\"\n      end\n\n      start_date, end_date = PERIODS[key].fetch(:date_range).call\n\n      new(key: key, start_date: start_date, end_date: end_date)\n    end\n\n    def custom(start_date:, end_date:)\n      new(start_date: start_date, end_date: end_date)\n    end\n\n    def all\n      PERIODS.map { |key, period| from_key(key) }\n    end\n\n    def as_options\n      all.map { |period| [ period.label_short, period.key ] }\n    end\n  end\n\n  PERIODS.each do |key, period|\n    define_singleton_method(key) do\n      from_key(key)\n    end\n  end\n\n  def initialize(start_date: nil, end_date: nil, key: nil, date_format: \"%b %d, %Y\")\n    @key = key\n    @start_date = start_date\n    @end_date = end_date\n    @date_format = date_format\n    validate!\n  end\n\n  def <=>(other)\n    [ start_date, end_date ] <=> [ other.start_date, other.end_date ]\n  end\n\n  def date_range\n    start_date..end_date\n  end\n\n  def days\n    (end_date - start_date).to_i + 1\n  end\n\n  def within?(other)\n    start_date >= other.start_date && end_date <= other.end_date\n  end\n\n  def interval\n    if days > 366\n      \"1 week\"\n    else\n      \"1 day\"\n    end\n  end\n\n  def label\n    if key_metadata\n      key_metadata.fetch(:label)\n    else\n      \"Custom Period\"\n    end\n  end\n\n  def label_short\n    if key_metadata\n      key_metadata.fetch(:label_short)\n    else\n      \"Custom\"\n    end\n  end\n\n  def comparison_label\n    if key_metadata\n      key_metadata.fetch(:comparison_label)\n    else\n      \"#{start_date.strftime(@date_format)} to #{end_date.strftime(@date_format)}\"\n    end\n  end\n\n  private\n    def key_metadata\n      @key_metadata ||= PERIODS[key]\n    end\n\n    def must_be_valid_date_range\n      return if start_date.nil? || end_date.nil?\n      unless start_date.is_a?(Date) && end_date.is_a?(Date)\n        errors.add(:start_date, \"must be a valid date, got #{start_date.inspect}\")\n        errors.add(:end_date, \"must be a valid date, got #{end_date.inspect}\")\n        return\n      end\n\n      errors.add(:start_date, \"must be before end date\") if start_date > end_date\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_account/importer.rb",
    "content": "class PlaidAccount::Importer\n  def initialize(plaid_account, account_snapshot:)\n    @plaid_account = plaid_account\n    @account_snapshot = account_snapshot\n  end\n\n  def import\n    import_account_info\n    import_transactions if account_snapshot.transactions_data.present?\n    import_investments if account_snapshot.investments_data.present?\n    import_liabilities if account_snapshot.liabilities_data.present?\n  end\n\n  private\n    attr_reader :plaid_account, :account_snapshot\n\n    def import_account_info\n      plaid_account.upsert_plaid_snapshot!(account_snapshot.account_data)\n    end\n\n    def import_transactions\n      plaid_account.upsert_plaid_transactions_snapshot!(account_snapshot.transactions_data)\n    end\n\n    def import_investments\n      plaid_account.upsert_plaid_investments_snapshot!(account_snapshot.investments_data)\n    end\n\n    def import_liabilities\n      plaid_account.upsert_plaid_liabilities_snapshot!(account_snapshot.liabilities_data)\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_account/investments/balance_calculator.rb",
    "content": "# Plaid Investment balances have a ton of edge cases.  This processor is responsible\n# for deriving \"brokerage cash\" vs. \"total value\" based on Plaid's reported balances and holdings.\nclass PlaidAccount::Investments::BalanceCalculator\n  NegativeCashBalanceError = Class.new(StandardError)\n  NegativeTotalValueError = Class.new(StandardError)\n\n  def initialize(plaid_account, security_resolver:)\n    @plaid_account = plaid_account\n    @security_resolver = security_resolver\n  end\n\n  def balance\n    total_value = total_investment_account_value\n\n    if total_value.negative?\n      Sentry.capture_exception(\n        NegativeTotalValueError.new(\"Total value is negative for plaid investment account\"),\n        level: :warning\n      )\n    end\n\n    total_value\n  end\n\n  # Plaid considers \"brokerage cash\" and \"cash equivalent holdings\" to all be part of \"cash balance\"\n  #\n  # Internally, we DO NOT.  Maybe clearly distinguishes between \"brokerage cash\" vs. \"holdings (i.e. invested cash)\"\n  # For this reason, we must manually calculate the cash balance based on \"total value\" and \"holdings value\"\n  # See PlaidAccount::Investments::SecurityResolver for more details.\n  def cash_balance\n    cash_balance = calculate_investment_brokerage_cash\n\n    if cash_balance.negative?\n      Sentry.capture_exception(\n        NegativeCashBalanceError.new(\"Cash balance is negative for plaid investment account\"),\n        level: :warning\n      )\n    end\n\n    cash_balance\n  end\n\n  private\n    attr_reader :plaid_account, :security_resolver\n\n    def holdings\n      plaid_account.raw_investments_payload[\"holdings\"] || []\n    end\n\n    def calculate_investment_brokerage_cash\n      total_investment_account_value - true_holdings_value\n    end\n\n    # This is our source of truth.  We assume Plaid's `current_balance` reporting is 100% accurate\n    # Plaid guarantees `current_balance` AND/OR `available_balance` is always present, and based on the docs,\n    # `current_balance` should represent \"total account value\".\n    def total_investment_account_value\n      plaid_account.current_balance || plaid_account.available_balance\n    end\n\n    # Plaid holdings summed up, LESS \"brokerage cash\" holdings (that we've manually identified)\n    def true_holdings_value\n      # True holdings are holdings *less* Plaid's \"pseudo-securities\" (e.g. `CUR:USD` brokerage cash \"holding\")\n      true_holdings = holdings.reject do |h|\n        security = security_resolver.resolve(plaid_security_id: h[\"security_id\"])\n        security.brokerage_cash?\n      end\n\n      true_holdings.sum { |h| h[\"quantity\"] * h[\"institution_price\"] }\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_account/investments/holdings_processor.rb",
    "content": "class PlaidAccount::Investments::HoldingsProcessor\n  def initialize(plaid_account, security_resolver:)\n    @plaid_account = plaid_account\n    @security_resolver = security_resolver\n  end\n\n  def process\n    holdings.each do |plaid_holding|\n      resolved_security_result = security_resolver.resolve(plaid_security_id: plaid_holding[\"security_id\"])\n\n      next unless resolved_security_result.security.present?\n\n      security = resolved_security_result.security\n      holding_date = plaid_holding[\"institution_price_as_of\"] || Date.current\n\n      holding = account.holdings.find_or_initialize_by(\n        security: security,\n        date: holding_date,\n        currency: plaid_holding[\"iso_currency_code\"]\n      )\n\n      holding.assign_attributes(\n        qty: plaid_holding[\"quantity\"],\n        price: plaid_holding[\"institution_price\"],\n        amount: plaid_holding[\"quantity\"] * plaid_holding[\"institution_price\"]\n      )\n\n      ActiveRecord::Base.transaction do\n        holding.save!\n\n        # Delete all holdings for this security after the institution price date\n        account.holdings\n          .where(security: security)\n          .where(\"date > ?\", holding_date)\n          .destroy_all\n      end\n    end\n  end\n\n  private\n    attr_reader :plaid_account, :security_resolver\n\n    def account\n      plaid_account.account\n    end\n\n    def holdings\n      plaid_account.raw_investments_payload[\"holdings\"] || []\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_account/investments/security_resolver.rb",
    "content": "# Resolves a Plaid security to an internal Security record, or nil\nclass PlaidAccount::Investments::SecurityResolver\n  UnresolvablePlaidSecurityError = Class.new(StandardError)\n\n  def initialize(plaid_account)\n    @plaid_account = plaid_account\n    @security_cache = {}\n  end\n\n  # Resolves an internal Security record for a given Plaid security ID\n  def resolve(plaid_security_id:)\n    response = @security_cache[plaid_security_id]\n    return response if response.present?\n\n    plaid_security = get_plaid_security(plaid_security_id)\n\n    if plaid_security.nil?\n      report_unresolvable_security(plaid_security_id)\n      response = Response.new(security: nil, cash_equivalent?: false, brokerage_cash?: false)\n    elsif brokerage_cash?(plaid_security)\n      response = Response.new(security: nil, cash_equivalent?: true, brokerage_cash?: true)\n    else\n      security = Security::Resolver.new(\n        plaid_security[\"ticker_symbol\"],\n        exchange_operating_mic: plaid_security[\"market_identifier_code\"]\n      ).resolve\n\n      response = Response.new(\n        security: security,\n        cash_equivalent?: cash_equivalent?(plaid_security),\n        brokerage_cash?: false\n      )\n    end\n\n    @security_cache[plaid_security_id] = response\n\n    response\n  end\n\n  private\n    attr_reader :plaid_account, :security_cache\n\n    Response = Struct.new(:security, :cash_equivalent?, :brokerage_cash?, keyword_init: true)\n\n    def securities\n      plaid_account.raw_investments_payload[\"securities\"] || []\n    end\n\n    # Tries to find security, or returns the \"proxy security\" (common with options contracts that have underlying securities)\n    def get_plaid_security(plaid_security_id)\n      security = securities.find { |s| s[\"security_id\"] == plaid_security_id && s[\"ticker_symbol\"].present? }\n\n      return security if security.present?\n\n      securities.find { |s| s[\"proxy_security_id\"] == plaid_security_id }\n    end\n\n    def report_unresolvable_security(plaid_security_id)\n      Sentry.capture_exception(UnresolvablePlaidSecurityError.new(\"Could not resolve Plaid security from provided data\")) do |scope|\n        scope.set_context(\"plaid_security\", {\n          plaid_security_id: plaid_security_id\n        })\n      end\n    end\n\n    # Plaid treats \"brokerage cash\" differently than us.  Internally, Maybe treats \"brokerage cash\"\n    # as \"uninvested cash\" (i.e. cash that doesn't have a corresponding Security and can be withdrawn).\n    #\n    # Plaid treats everything as a \"holding\" with a corresponding Security.  For example, \"brokerage cash\" (USD)\n    # in Plaids data model would be represented as:\n    #\n    # - A Security with ticker `CUR:USD`\n    # - A holding, linked to the `CUR:USD` Security, with an institution price of $1\n    #\n    # Internally, we store brokerage cash balance as `account.cash_balance`, NOT as a holding + security.\n    # This allows us to properly build historical cash balances and holdings values separately and accurately.\n    #\n    # These help identify these \"special case\" securities for various calculations.\n    #\n    def known_plaid_brokerage_cash_tickers\n      [ \"CUR:USD\" ]\n    end\n\n    def brokerage_cash?(plaid_security)\n      return false unless plaid_security[\"ticker_symbol\"].present?\n      known_plaid_brokerage_cash_tickers.include?(plaid_security[\"ticker_symbol\"])\n    end\n\n    def cash_equivalent?(plaid_security)\n      return false unless plaid_security[\"type\"].present?\n      plaid_security[\"type\"] == \"cash\" || plaid_security[\"is_cash_equivalent\"] == true\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_account/investments/transactions_processor.rb",
    "content": "class PlaidAccount::Investments::TransactionsProcessor\n  SecurityNotFoundError = Class.new(StandardError)\n\n  def initialize(plaid_account, security_resolver:)\n    @plaid_account = plaid_account\n    @security_resolver = security_resolver\n  end\n\n  def process\n    transactions.each do |transaction|\n      if cash_transaction?(transaction)\n        find_or_create_cash_entry(transaction)\n      else\n        find_or_create_trade_entry(transaction)\n      end\n    end\n  end\n\n  private\n    attr_reader :plaid_account, :security_resolver\n\n    def account\n      plaid_account.account\n    end\n\n    def cash_transaction?(transaction)\n      transaction[\"type\"] == \"cash\" || transaction[\"type\"] == \"fee\" || transaction[\"type\"] == \"transfer\"\n    end\n\n    def find_or_create_trade_entry(transaction)\n      resolved_security_result = security_resolver.resolve(plaid_security_id: transaction[\"security_id\"])\n\n      unless resolved_security_result.security.present?\n        Sentry.capture_exception(SecurityNotFoundError.new(\"Could not find security for plaid trade\")) do |scope|\n          scope.set_tags(plaid_account_id: plaid_account.id)\n        end\n\n        return # We can't process a non-cash transaction without a security\n      end\n\n      entry = account.entries.find_or_initialize_by(plaid_id: transaction[\"investment_transaction_id\"]) do |e|\n        e.entryable = Trade.new\n      end\n\n      entry.assign_attributes(\n        amount: derived_qty(transaction) * transaction[\"price\"],\n        currency: transaction[\"iso_currency_code\"],\n        date: transaction[\"date\"]\n      )\n\n      entry.trade.assign_attributes(\n        security: resolved_security_result.security,\n        qty: derived_qty(transaction),\n        price: transaction[\"price\"],\n        currency: transaction[\"iso_currency_code\"]\n      )\n\n      entry.enrich_attribute(\n        :name,\n        transaction[\"name\"],\n        source: \"plaid\"\n      )\n\n      entry.save!\n    end\n\n    def find_or_create_cash_entry(transaction)\n      entry = account.entries.find_or_initialize_by(plaid_id: transaction[\"investment_transaction_id\"]) do |e|\n        e.entryable = Transaction.new\n      end\n\n      entry.assign_attributes(\n        amount: transaction[\"amount\"],\n        currency: transaction[\"iso_currency_code\"],\n        date: transaction[\"date\"]\n      )\n\n      entry.enrich_attribute(\n        :name,\n        transaction[\"name\"],\n        source: \"plaid\"\n      )\n\n      entry.save!\n    end\n\n    def transactions\n      plaid_account.raw_investments_payload[\"transactions\"] || []\n    end\n\n    # Plaid unfortunately returns incorrect signage on some `quantity` values. They claim all \"sell\" transactions\n    # are negative signage, but we have found multiple instances of production data where this is not the case.\n    #\n    # This method attempts to use several Plaid data points to derive the true quantity with the correct signage.\n    def derived_qty(transaction)\n      reported_qty = transaction[\"quantity\"]\n      abs_qty = reported_qty.abs\n\n      if transaction[\"type\"] == \"sell\" || transaction[\"amount\"] < 0\n        -abs_qty\n      elsif transaction[\"type\"] == \"buy\" || transaction[\"amount\"] > 0\n        abs_qty\n      else\n        reported_qty\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_account/liabilities/credit_processor.rb",
    "content": "class PlaidAccount::Liabilities::CreditProcessor\n  def initialize(plaid_account)\n    @plaid_account = plaid_account\n  end\n\n  def process\n    return unless credit_data.present?\n\n    account.credit_card.update!(\n      minimum_payment: credit_data.dig(\"minimum_payment_amount\"),\n      apr: credit_data.dig(\"aprs\", 0, \"apr_percentage\")\n    )\n  end\n\n  private\n    attr_reader :plaid_account\n\n    def account\n      plaid_account.account\n    end\n\n    def credit_data\n      plaid_account.raw_liabilities_payload[\"credit\"]\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_account/liabilities/mortgage_processor.rb",
    "content": "class PlaidAccount::Liabilities::MortgageProcessor\n  def initialize(plaid_account)\n    @plaid_account = plaid_account\n  end\n\n  def process\n    return unless mortgage_data.present?\n\n    account.loan.update!(\n      rate_type: mortgage_data.dig(\"interest_rate\", \"type\"),\n      interest_rate: mortgage_data.dig(\"interest_rate\", \"percentage\")\n    )\n  end\n\n  private\n    attr_reader :plaid_account\n\n    def account\n      plaid_account.account\n    end\n\n    def mortgage_data\n      plaid_account.raw_liabilities_payload[\"mortgage\"]\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_account/liabilities/student_loan_processor.rb",
    "content": "class PlaidAccount::Liabilities::StudentLoanProcessor\n  def initialize(plaid_account)\n    @plaid_account = plaid_account\n  end\n\n  def process\n    return unless student_loan_data.present?\n\n    account.loan.update!(\n      rate_type: \"fixed\",\n      interest_rate: student_loan_data[\"interest_rate_percentage\"],\n      initial_balance: student_loan_data[\"origination_principal_amount\"],\n      term_months: term_months\n    )\n  end\n\n  private\n    attr_reader :plaid_account\n\n    def account\n      plaid_account.account\n    end\n\n    def term_months\n      return nil unless origination_date && expected_payoff_date\n\n      ((expected_payoff_date - origination_date).to_i / 30).to_i\n    end\n\n    def origination_date\n      parse_date(student_loan_data[\"origination_date\"])\n    end\n\n    def expected_payoff_date\n      parse_date(student_loan_data[\"expected_payoff_date\"])\n    end\n\n    def parse_date(value)\n      return value if value.is_a?(Date)\n      return nil unless value.present?\n\n      Date.parse(value.to_s)\n    rescue ArgumentError\n      nil\n    end\n\n    def student_loan_data\n      plaid_account.raw_liabilities_payload[\"student\"]\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_account/processor.rb",
    "content": "class PlaidAccount::Processor\n  include PlaidAccount::TypeMappable\n\n  attr_reader :plaid_account\n\n  def initialize(plaid_account)\n    @plaid_account = plaid_account\n  end\n\n  # Each step represents a different Plaid API endpoint / \"product\"\n  #\n  # Processing the account is the first step and if it fails, we halt the entire processor\n  # Each subsequent step can fail independently, but we continue processing the rest of the steps\n  def process\n    process_account!\n    process_transactions\n    process_investments\n    process_liabilities\n  end\n\n  private\n    def family\n      plaid_account.plaid_item.family\n    end\n\n    # Shared securities reader and resolver\n    def security_resolver\n      @security_resolver ||= PlaidAccount::Investments::SecurityResolver.new(plaid_account)\n    end\n\n    def process_account!\n      PlaidAccount.transaction do\n        account = family.accounts.find_or_initialize_by(\n          plaid_account_id: plaid_account.id\n        )\n\n        # Name and subtype are the only attributes a user can override for Plaid accounts\n        account.enrich_attributes(\n          {\n            name: plaid_account.name,\n            subtype: map_subtype(plaid_account.plaid_type, plaid_account.plaid_subtype)\n          },\n          source: \"plaid\"\n        )\n\n        account.assign_attributes(\n          accountable: map_accountable(plaid_account.plaid_type),\n          balance: balance_calculator.balance,\n          currency: plaid_account.currency,\n          cash_balance: balance_calculator.cash_balance\n        )\n\n        account.save!\n\n        # Create or update the current balance anchor valuation for event-sourced ledger\n        # Note: This is a partial implementation. In the future, we'll introduce HoldingValuation\n        # to properly track the holdings vs. cash breakdown, but for now we're only tracking\n        # the total balance in the current anchor. The cash_balance field on the account model\n        # is still being used for the breakdown.\n        account.set_current_balance(balance_calculator.balance)\n      end\n    end\n\n    def process_transactions\n      PlaidAccount::Transactions::Processor.new(plaid_account).process\n    rescue => e\n      report_exception(e)\n    end\n\n    def process_investments\n      PlaidAccount::Investments::TransactionsProcessor.new(plaid_account, security_resolver: security_resolver).process\n      PlaidAccount::Investments::HoldingsProcessor.new(plaid_account, security_resolver: security_resolver).process\n    rescue => e\n      report_exception(e)\n    end\n\n    def process_liabilities\n      case [ plaid_account.plaid_type, plaid_account.plaid_subtype ]\n      when [ \"credit\", \"credit card\" ]\n        PlaidAccount::Liabilities::CreditProcessor.new(plaid_account).process\n      when [ \"loan\", \"mortgage\" ]\n        PlaidAccount::Liabilities::MortgageProcessor.new(plaid_account).process\n      when [ \"loan\", \"student\" ]\n        PlaidAccount::Liabilities::StudentLoanProcessor.new(plaid_account).process\n      end\n    rescue => e\n      report_exception(e)\n    end\n\n    def balance_calculator\n      if plaid_account.plaid_type == \"investment\"\n        @balance_calculator ||= PlaidAccount::Investments::BalanceCalculator.new(plaid_account, security_resolver: security_resolver)\n      else\n        balance = plaid_account.current_balance || plaid_account.available_balance || 0\n\n        # We don't currently distinguish \"cash\" vs. \"non-cash\" balances for non-investment accounts.\n        OpenStruct.new(\n          balance: balance,\n          cash_balance: balance\n        )\n      end\n    end\n\n    def report_exception(error)\n      Sentry.capture_exception(error) do |scope|\n        scope.set_tags(plaid_account_id: plaid_account.id)\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_account/transactions/category_matcher.rb",
    "content": "# The purpose of this matcher is to auto-match Plaid categories to\n# known internal user categories.  Since we allow users to define their own\n# categories we cannot directly assign Plaid categories as this would overwrite\n# user data and create a confusing experience.\n#\n# Automated category matching in the Maybe app has a hierarchy:\n# 1. Naive string matching via CategoryAliasMatcher\n# 2. Rules-based matching set by user\n# 3. AI-powered matching (also enabled by user via rules)\n#\n# This class is simply a FAST and CHEAP way to match categories that are high confidence.\n# Edge cases will be handled by user-defined rules.\nclass PlaidAccount::Transactions::CategoryMatcher\n  include PlaidAccount::Transactions::CategoryTaxonomy\n\n  def initialize(user_categories = [])\n    @user_categories = user_categories\n  end\n\n  def match(plaid_detailed_category)\n    plaid_category_details = get_plaid_category_details(plaid_detailed_category)\n    return nil unless plaid_category_details\n\n    # Try exact name matches first\n    exact_match = normalized_user_categories.find do |category|\n      category[:name] == plaid_category_details[:key].to_s\n    end\n    return user_categories.find { |c| c.id == exact_match[:id] } if exact_match\n\n    # Try detailed aliases matches with fuzzy matching\n    alias_match = normalized_user_categories.find do |category|\n      name = category[:name]\n      plaid_category_details[:aliases].any? do |a|\n        alias_str = a.to_s\n\n        # Try exact match\n        next true if name == alias_str\n\n        # Try plural forms\n        next true if name.singularize == alias_str || name.pluralize == alias_str\n        next true if alias_str.singularize == name || alias_str.pluralize == name\n\n        # Try common forms\n        normalized_name = name.gsub(/(and|&|\\s+)/, \"\").strip\n        normalized_alias = alias_str.gsub(/(and|&|\\s+)/, \"\").strip\n        normalized_name == normalized_alias\n      end\n    end\n    return user_categories.find { |c| c.id == alias_match[:id] } if alias_match\n\n    # Try parent aliases matches with fuzzy matching\n    parent_match = normalized_user_categories.find do |category|\n      name = category[:name]\n      plaid_category_details[:parent_aliases].any? do |a|\n        alias_str = a.to_s\n\n        # Try exact match\n        next true if name == alias_str\n\n        # Try plural forms\n        next true if name.singularize == alias_str || name.pluralize == alias_str\n        next true if alias_str.singularize == name || alias_str.pluralize == name\n\n        # Try common forms\n        normalized_name = name.gsub(/(and|&|\\s+)/, \"\").strip\n        normalized_alias = alias_str.gsub(/(and|&|\\s+)/, \"\").strip\n        normalized_name == normalized_alias\n      end\n    end\n    return user_categories.find { |c| c.id == parent_match[:id] } if parent_match\n\n    nil\n  end\n\n  private\n    attr_reader :user_categories\n\n    def get_plaid_category_details(plaid_category_name)\n      detailed_plaid_categories.find { |c| c[:key] == plaid_category_name.downcase.to_sym }\n    end\n\n    def detailed_plaid_categories\n      CATEGORIES_MAP.flat_map do |parent_key, parent_data|\n        parent_data[:detailed_categories].map do |child_key, child_data|\n          {\n            key: child_key,\n            classification: child_data[:classification],\n            aliases: child_data[:aliases],\n            parent_key: parent_key,\n            parent_aliases: parent_data[:aliases]\n          }\n        end\n      end\n    end\n\n    def normalized_user_categories\n      user_categories.map do |user_category|\n        {\n          id: user_category.id,\n          classification: user_category.classification,\n          name: normalize_user_category_name(user_category.name)\n        }\n      end\n    end\n\n    def normalize_user_category_name(name)\n      name.to_s.downcase.gsub(/[^a-z0-9]/, \" \").strip\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_account/transactions/category_taxonomy.rb",
    "content": "# https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv\nmodule PlaidAccount::Transactions::CategoryTaxonomy\n  CATEGORIES_MAP = {\n    income: {\n      classification: :income,\n      aliases: [ \"income\", \"revenue\", \"earnings\" ],\n      detailed_categories: {\n        income_dividends: {\n          classification: :income,\n          aliases: [ \"dividend\", \"stock income\", \"dividend income\", \"dividend earnings\" ]\n        },\n        income_interest_earned: {\n          classification: :income,\n          aliases: [ \"interest\", \"bank interest\", \"interest earned\", \"interest income\" ]\n        },\n        income_retirement_pension: {\n          classification: :income,\n          aliases: [ \"retirement\", \"pension\" ]\n        },\n        income_tax_refund: {\n          classification: :income,\n          aliases: [ \"tax refund\" ]\n        },\n        income_unemployment: {\n          classification: :income,\n          aliases: [ \"unemployment\" ]\n        },\n        income_wages: {\n          classification: :income,\n          aliases: [ \"wage\", \"salary\", \"paycheck\" ]\n        },\n        income_other_income: {\n          classification: :income,\n          aliases: [ \"other income\", \"misc income\" ]\n        }\n      }\n    },\n    loan_payments: {\n      classification: :expense,\n      aliases: [ \"loan payment\", \"debt payment\", \"loan\", \"debt\", \"payment\" ],\n      detailed_categories: {\n        loan_payments_car_payment: {\n          classification: :expense,\n          aliases: [ \"car payment\", \"auto loan\" ]\n        },\n        loan_payments_credit_card_payment: {\n          classification: :expense,\n          aliases: [ \"credit card\", \"card payment\" ]\n        },\n        loan_payments_personal_loan_payment: {\n          classification: :expense,\n          aliases: [ \"personal loan\", \"loan payment\" ]\n        },\n        loan_payments_mortgage_payment: {\n          classification: :expense,\n          aliases: [ \"mortgage\", \"home loan\" ]\n        },\n        loan_payments_student_loan_payment: {\n          classification: :expense,\n          aliases: [ \"student loan\", \"education loan\" ]\n        },\n        loan_payments_other_payment: {\n          classification: :expense,\n          aliases: [ \"loan\", \"loan payment\" ]\n        }\n      }\n    },\n    bank_fees: {\n      classification: :expense,\n      aliases: [ \"bank fee\", \"service charge\", \"fee\", \"misc fees\" ],\n      detailed_categories: {\n        bank_fees_atm_fees: {\n          classification: :expense,\n          aliases: [ \"atm fee\", \"withdrawal fee\" ]\n        },\n        bank_fees_foreign_transaction_fees: {\n          classification: :expense,\n          aliases: [ \"foreign fee\", \"international fee\" ]\n        },\n        bank_fees_insufficient_funds: {\n          classification: :expense,\n          aliases: [ \"nsf fee\", \"overdraft\" ]\n        },\n        bank_fees_interest_charge: {\n          classification: :expense,\n          aliases: [ \"interest charge\", \"finance charge\" ]\n        },\n        bank_fees_overdraft_fees: {\n          classification: :expense,\n          aliases: [ \"overdraft fee\" ]\n        },\n        bank_fees_other_bank_fees: {\n          classification: :expense,\n          aliases: [ \"bank fee\", \"service charge\" ]\n        }\n      }\n    },\n    entertainment: {\n      classification: :expense,\n      aliases: [ \"entertainment\", \"recreation\" ],\n      detailed_categories: {\n        entertainment_casinos_and_gambling: {\n          classification: :expense,\n          aliases: [ \"casino\", \"gambling\" ]\n        },\n        entertainment_music_and_audio: {\n          classification: :expense,\n          aliases: [ \"music\", \"concert\" ]\n        },\n        entertainment_sporting_events_amusement_parks_and_museums: {\n          classification: :expense,\n          aliases: [ \"event\", \"amusement\", \"museum\" ]\n        },\n        entertainment_tv_and_movies: {\n          classification: :expense,\n          aliases: [ \"movie\", \"streaming\" ]\n        },\n        entertainment_video_games: {\n          classification: :expense,\n          aliases: [ \"game\", \"gaming\" ]\n        },\n        entertainment_other_entertainment: {\n          classification: :expense,\n          aliases: [ \"entertainment\", \"recreation\" ]\n        }\n      }\n    },\n    food_and_drink: {\n      classification: :expense,\n      aliases: [ \"food\", \"dining\", \"food and drink\", \"food & drink\" ],\n      detailed_categories: {\n        food_and_drink_beer_wine_and_liquor: {\n          classification: :expense,\n          aliases: [ \"alcohol\", \"liquor\", \"beer\", \"wine\", \"bar\", \"pub\" ]\n        },\n        food_and_drink_coffee: {\n          classification: :expense,\n          aliases: [ \"coffee\", \"cafe\", \"coffee shop\" ]\n        },\n        food_and_drink_fast_food: {\n          classification: :expense,\n          aliases: [ \"fast food\", \"takeout\" ]\n        },\n        food_and_drink_groceries: {\n          classification: :expense,\n          aliases: [ \"grocery\", \"supermarket\", \"grocery store\" ]\n        },\n        food_and_drink_restaurant: {\n          classification: :expense,\n          aliases: [ \"restaurant\", \"dining\" ]\n        },\n        food_and_drink_vending_machines: {\n          classification: :expense,\n          aliases: [ \"vending\" ]\n        },\n        food_and_drink_other_food_and_drink: {\n          classification: :expense,\n          aliases: [ \"food\", \"drink\" ]\n        }\n      }\n    },\n    general_merchandise: {\n      classification: :expense,\n      aliases: [ \"shopping\", \"retail\" ],\n      detailed_categories: {\n        general_merchandise_bookstores_and_newsstands: {\n          classification: :expense,\n          aliases: [ \"book\", \"newsstand\" ]\n        },\n        general_merchandise_clothing_and_accessories: {\n          classification: :expense,\n          aliases: [ \"clothing\", \"apparel\" ]\n        },\n        general_merchandise_convenience_stores: {\n          classification: :expense,\n          aliases: [ \"convenience\" ]\n        },\n        general_merchandise_department_stores: {\n          classification: :expense,\n          aliases: [ \"department store\" ]\n        },\n        general_merchandise_discount_stores: {\n          classification: :expense,\n          aliases: [ \"discount store\" ]\n        },\n        general_merchandise_electronics: {\n          classification: :expense,\n          aliases: [ \"electronic\", \"computer\" ]\n        },\n        general_merchandise_gifts_and_novelties: {\n          classification: :expense,\n          aliases: [ \"gift\", \"souvenir\" ]\n        },\n        general_merchandise_office_supplies: {\n          classification: :expense,\n          aliases: [ \"office supply\" ]\n        },\n        general_merchandise_online_marketplaces: {\n          classification: :expense,\n          aliases: [ \"online shopping\" ]\n        },\n        general_merchandise_pet_supplies: {\n          classification: :expense,\n          aliases: [ \"pet supply\", \"pet food\" ]\n        },\n        general_merchandise_sporting_goods: {\n          classification: :expense,\n          aliases: [ \"sporting good\", \"sport\" ]\n        },\n        general_merchandise_superstores: {\n          classification: :expense,\n          aliases: [ \"superstore\", \"retail\" ]\n        },\n        general_merchandise_tobacco_and_vape: {\n          classification: :expense,\n          aliases: [ \"tobacco\", \"smoke\" ]\n        },\n        general_merchandise_other_general_merchandise: {\n          classification: :expense,\n          aliases: [ \"shopping\", \"merchandise\" ]\n        }\n      }\n    },\n    home_improvement: {\n      classification: :expense,\n      aliases: [ \"home\", \"house\", \"house renovation\", \"home improvement\", \"renovation\" ],\n      detailed_categories: {\n        home_improvement_furniture: {\n          classification: :expense,\n          aliases: [ \"furniture\", \"furnishing\" ]\n        },\n        home_improvement_hardware: {\n          classification: :expense,\n          aliases: [ \"hardware\", \"tool\" ]\n        },\n        home_improvement_repair_and_maintenance: {\n          classification: :expense,\n          aliases: [ \"repair\", \"maintenance\" ]\n        },\n        home_improvement_security: {\n          classification: :expense,\n          aliases: [ \"security\", \"alarm\" ]\n        },\n        home_improvement_other_home_improvement: {\n          classification: :expense,\n          aliases: [ \"home improvement\", \"renovation\" ]\n        }\n      }\n    },\n    medical: {\n      classification: :expense,\n      aliases: [ \"medical\", \"healthcare\", \"health\" ],\n      detailed_categories: {\n        medical_dental_care: {\n          classification: :expense,\n          aliases: [ \"dental\", \"dentist\" ]\n        },\n        medical_eye_care: {\n          classification: :expense,\n          aliases: [ \"eye\", \"optometrist\" ]\n        },\n        medical_nursing_care: {\n          classification: :expense,\n          aliases: [ \"nursing\", \"care\" ]\n        },\n        medical_pharmacies_and_supplements: {\n          classification: :expense,\n          aliases: [ \"pharmacy\", \"prescription\" ]\n        },\n        medical_primary_care: {\n          classification: :expense,\n          aliases: [ \"doctor\", \"medical\" ]\n        },\n        medical_veterinary_services: {\n          classification: :expense,\n          aliases: [ \"vet\", \"veterinary\" ]\n        },\n        medical_other_medical: {\n          classification: :expense,\n          aliases: [ \"medical\", \"healthcare\" ]\n        }\n      }\n    },\n    personal_care: {\n      classification: :expense,\n      aliases: [ \"personal care\", \"grooming\" ],\n      detailed_categories: {\n        personal_care_gyms_and_fitness_centers: {\n          classification: :expense,\n          aliases: [ \"gym\", \"fitness\", \"exercise\", \"sport\" ]\n        },\n        personal_care_hair_and_beauty: {\n          classification: :expense,\n          aliases: [ \"salon\", \"beauty\" ]\n        },\n        personal_care_laundry_and_dry_cleaning: {\n          classification: :expense,\n          aliases: [ \"laundry\", \"cleaning\" ]\n        },\n        personal_care_other_personal_care: {\n          classification: :expense,\n          aliases: [ \"personal care\", \"grooming\" ]\n        }\n      }\n    },\n    general_services: {\n      classification: :expense,\n      aliases: [ \"service\", \"professional service\" ],\n      detailed_categories: {\n        general_services_accounting_and_financial_planning: {\n          classification: :expense,\n          aliases: [ \"accountant\", \"financial advisor\" ]\n        },\n        general_services_automotive: {\n          classification: :expense,\n          aliases: [ \"auto repair\", \"mechanic\", \"vehicle\", \"car\", \"car care\", \"car maintenance\", \"vehicle maintenance\" ]\n        },\n        general_services_childcare: {\n          classification: :expense,\n          aliases: [ \"childcare\", \"daycare\" ]\n        },\n        general_services_consulting_and_legal: {\n          classification: :expense,\n          aliases: [ \"legal\", \"attorney\" ]\n        },\n        general_services_education: {\n          classification: :expense,\n          aliases: [ \"education\", \"tuition\" ]\n        },\n        general_services_insurance: {\n          classification: :expense,\n          aliases: [ \"insurance\", \"premium\" ]\n        },\n        general_services_postage_and_shipping: {\n          classification: :expense,\n          aliases: [ \"shipping\", \"postage\" ]\n        },\n        general_services_storage: {\n          classification: :expense,\n          aliases: [ \"storage\" ]\n        },\n        general_services_other_general_services: {\n          classification: :expense,\n          aliases: [ \"service\" ]\n        }\n      }\n    },\n    government_and_non_profit: {\n      classification: :expense,\n      aliases: [ \"government\", \"non-profit\" ],\n      detailed_categories: {\n        government_and_non_profit_donations: {\n          classification: :expense,\n          aliases: [ \"donation\", \"charity\", \"charitable\", \"charitable donation\", \"giving\", \"gifts and donations\", \"gifts & donations\" ]\n        },\n        government_and_non_profit_government_departments_and_agencies: {\n          classification: :expense,\n          aliases: [ \"government\", \"agency\" ]\n        },\n        government_and_non_profit_tax_payment: {\n          classification: :expense,\n          aliases: [ \"tax payment\", \"tax\" ]\n        },\n        government_and_non_profit_other_government_and_non_profit: {\n          classification: :expense,\n          aliases: [ \"government\", \"non-profit\" ]\n        }\n      }\n    },\n    transportation: {\n      classification: :expense,\n      aliases: [ \"transportation\", \"travel\" ],\n      detailed_categories: {\n        transportation_bikes_and_scooters: {\n          classification: :expense,\n          aliases: [ \"bike\", \"scooter\" ]\n        },\n        transportation_gas: {\n          classification: :expense,\n          aliases: [ \"gas\", \"fuel\" ]\n        },\n        transportation_parking: {\n          classification: :expense,\n          aliases: [ \"parking\" ]\n        },\n        transportation_public_transit: {\n          classification: :expense,\n          aliases: [ \"transit\", \"bus\" ]\n        },\n        transportation_taxis_and_ride_shares: {\n          classification: :expense,\n          aliases: [ \"taxi\", \"rideshare\" ]\n        },\n        transportation_tolls: {\n          classification: :expense,\n          aliases: [ \"toll\" ]\n        },\n        transportation_other_transportation: {\n          classification: :expense,\n          aliases: [ \"transportation\", \"travel\" ]\n        }\n      }\n    },\n    travel: {\n      classification: :expense,\n      aliases: [ \"travel\", \"vacation\", \"trip\", \"sabbatical\" ],\n      detailed_categories: {\n        travel_flights: {\n          classification: :expense,\n          aliases: [ \"flight\", \"airfare\" ]\n        },\n        travel_lodging: {\n          classification: :expense,\n          aliases: [ \"hotel\", \"lodging\" ]\n        },\n        travel_rental_cars: {\n          classification: :expense,\n          aliases: [ \"rental car\" ]\n        },\n        travel_other_travel: {\n          classification: :expense,\n          aliases: [ \"travel\", \"trip\" ]\n        }\n      }\n    },\n    rent_and_utilities: {\n      classification: :expense,\n      aliases: [ \"utilities\", \"housing\", \"house\", \"home\", \"rent\", \"rent & utilities\" ],\n      detailed_categories: {\n        rent_and_utilities_gas_and_electricity: {\n          classification: :expense,\n          aliases: [ \"utility\", \"electric\" ]\n        },\n        rent_and_utilities_internet_and_cable: {\n          classification: :expense,\n          aliases: [ \"internet\", \"cable\" ]\n        },\n        rent_and_utilities_rent: {\n          classification: :expense,\n          aliases: [ \"rent\", \"lease\" ]\n        },\n        rent_and_utilities_sewage_and_waste_management: {\n          classification: :expense,\n          aliases: [ \"sewage\", \"waste\" ]\n        },\n        rent_and_utilities_telephone: {\n          classification: :expense,\n          aliases: [ \"phone\", \"telephone\" ]\n        },\n        rent_and_utilities_water: {\n          classification: :expense,\n          aliases: [ \"water\" ]\n        },\n        rent_and_utilities_other_utilities: {\n          classification: :expense,\n          aliases: [ \"utility\" ]\n        }\n      }\n    }\n  }\nend\n"
  },
  {
    "path": "app/models/plaid_account/transactions/processor.rb",
    "content": "class PlaidAccount::Transactions::Processor\n  def initialize(plaid_account)\n    @plaid_account = plaid_account\n  end\n\n  def process\n    # Each entry is processed inside a transaction, but to avoid locking up the DB when\n    # there are hundreds or thousands of transactions, we process them individually.\n    modified_transactions.each do |transaction|\n      PlaidEntry::Processor.new(\n        transaction,\n        plaid_account: plaid_account,\n        category_matcher: category_matcher\n      ).process\n    end\n\n    PlaidAccount.transaction do\n      removed_transactions.each do |transaction|\n        remove_plaid_transaction(transaction)\n      end\n    end\n  end\n\n  private\n    attr_reader :plaid_account\n\n    def category_matcher\n      @category_matcher ||= PlaidAccount::Transactions::CategoryMatcher.new(family_categories)\n    end\n\n    def family_categories\n      @family_categories ||= begin\n        if account.family.categories.none?\n          account.family.categories.bootstrap!\n        end\n\n        account.family.categories\n      end\n    end\n\n    def account\n      plaid_account.account\n    end\n\n    def remove_plaid_transaction(raw_transaction)\n      account.entries.find_by(plaid_id: raw_transaction[\"transaction_id\"])&.destroy\n    end\n\n    # Since we find_or_create_by transactions, we don't need a distinction between added/modified\n    def modified_transactions\n      modified = plaid_account.raw_transactions_payload[\"modified\"] || []\n      added = plaid_account.raw_transactions_payload[\"added\"] || []\n\n      modified + added\n    end\n\n    def removed_transactions\n      plaid_account.raw_transactions_payload[\"removed\"] || []\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_account/type_mappable.rb",
    "content": "module PlaidAccount::TypeMappable\n  extend ActiveSupport::Concern\n\n  UnknownAccountTypeError = Class.new(StandardError)\n\n  def map_accountable(plaid_type)\n    accountable_class = TYPE_MAPPING.dig(\n      plaid_type.to_sym,\n      :accountable\n    )\n\n    unless accountable_class\n      raise UnknownAccountTypeError, \"Unknown account type: #{plaid_type}\"\n    end\n\n    accountable_class.new\n  end\n\n  def map_subtype(plaid_type, plaid_subtype)\n    TYPE_MAPPING.dig(\n      plaid_type.to_sym,\n      :subtype_mapping,\n      plaid_subtype\n    ) || \"other\"\n  end\n\n  # Plaid Account Types -> Accountable Types\n  # https://plaid.com/docs/api/accounts/#account-type-schema\n  TYPE_MAPPING = {\n    depository: {\n      accountable: Depository,\n      subtype_mapping: {\n        \"checking\" => \"checking\",\n        \"savings\" => \"savings\",\n        \"hsa\" => \"hsa\",\n        \"cd\" => \"cd\",\n        \"money market\" => \"money_market\"\n      }\n    },\n    credit: {\n      accountable: CreditCard,\n      subtype_mapping: {\n        \"credit card\" => \"credit_card\"\n      }\n    },\n    loan: {\n      accountable: Loan,\n      subtype_mapping: {\n        \"mortgage\" => \"mortgage\",\n        \"student\" => \"student\",\n        \"auto\" => \"auto\",\n        \"business\" => \"business\",\n        \"home equity\" => \"home_equity\",\n        \"line of credit\" => \"line_of_credit\"\n      }\n    },\n    investment: {\n      accountable: Investment,\n      subtype_mapping: {\n        \"brokerage\" => \"brokerage\",\n        \"pension\" => \"pension\",\n        \"retirement\" => \"retirement\",\n        \"401k\" => \"401k\",\n        \"roth 401k\" => \"roth_401k\",\n        \"529\" => \"529_plan\",\n        \"hsa\" => \"hsa\",\n        \"mutual fund\" => \"mutual_fund\",\n        \"roth\" => \"roth_ira\",\n        \"ira\" => \"ira\"\n      }\n    },\n    other: {\n      accountable: OtherAsset,\n      subtype_mapping: {}\n    }\n  }\nend\n"
  },
  {
    "path": "app/models/plaid_account.rb",
    "content": "class PlaidAccount < ApplicationRecord\n  belongs_to :plaid_item\n\n  has_one :account, dependent: :destroy\n\n  validates :name, :plaid_type, :currency, presence: true\n  validate :has_balance\n\n  def upsert_plaid_snapshot!(account_snapshot)\n    assign_attributes(\n      current_balance: account_snapshot.balances.current,\n      available_balance: account_snapshot.balances.available,\n      currency: account_snapshot.balances.iso_currency_code,\n      plaid_type: account_snapshot.type,\n      plaid_subtype: account_snapshot.subtype,\n      name: account_snapshot.name,\n      mask: account_snapshot.mask,\n      raw_payload: account_snapshot\n    )\n\n    save!\n  end\n\n  def upsert_plaid_transactions_snapshot!(transactions_snapshot)\n    assign_attributes(\n      raw_transactions_payload: transactions_snapshot\n    )\n\n    save!\n  end\n\n  def upsert_plaid_investments_snapshot!(investments_snapshot)\n    assign_attributes(\n      raw_investments_payload: investments_snapshot\n    )\n\n    save!\n  end\n\n  def upsert_plaid_liabilities_snapshot!(liabilities_snapshot)\n    assign_attributes(\n      raw_liabilities_payload: liabilities_snapshot\n    )\n\n    save!\n  end\n\n  private\n    # Plaid guarantees at least one of these.  This validation is a sanity check for that guarantee.\n    def has_balance\n      return if current_balance.present? || available_balance.present?\n      errors.add(:base, \"Plaid account must have either current or available balance\")\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_entry/processor.rb",
    "content": "class PlaidEntry::Processor\n  # plaid_transaction is the raw hash fetched from Plaid API and converted to JSONB\n  def initialize(plaid_transaction, plaid_account:, category_matcher:)\n    @plaid_transaction = plaid_transaction\n    @plaid_account = plaid_account\n    @category_matcher = category_matcher\n  end\n\n  def process\n    PlaidAccount.transaction do\n      entry = account.entries.find_or_initialize_by(plaid_id: plaid_id) do |e|\n        e.entryable = Transaction.new\n      end\n\n      entry.assign_attributes(\n        amount: amount,\n        currency: currency,\n        date: date\n      )\n\n      entry.enrich_attribute(\n        :name,\n        name,\n        source: \"plaid\"\n      )\n\n      if detailed_category\n        matched_category = category_matcher.match(detailed_category)\n\n        if matched_category\n          entry.transaction.enrich_attribute(\n            :category_id,\n            matched_category.id,\n            source: \"plaid\"\n          )\n        end\n      end\n\n      if merchant\n        entry.transaction.enrich_attribute(\n          :merchant_id,\n          merchant.id,\n          source: \"plaid\"\n        )\n      end\n    end\n  end\n\n  private\n    attr_reader :plaid_transaction, :plaid_account, :category_matcher\n\n    def account\n      plaid_account.account\n    end\n\n    def plaid_id\n      plaid_transaction[\"transaction_id\"]\n    end\n\n    def name\n      plaid_transaction[\"merchant_name\"] || plaid_transaction[\"original_description\"]\n    end\n\n    def amount\n      plaid_transaction[\"amount\"]\n    end\n\n    def currency\n      plaid_transaction[\"iso_currency_code\"]\n    end\n\n    def date\n      plaid_transaction[\"date\"]\n    end\n\n    def detailed_category\n      plaid_transaction.dig(\"personal_finance_category\", \"detailed\")\n    end\n\n    def merchant\n      merchant_id = plaid_transaction[\"merchant_entity_id\"]\n      merchant_name = plaid_transaction[\"merchant_name\"]\n\n      return nil unless merchant_id.present? && merchant_name.present?\n\n      ProviderMerchant.find_or_create_by!(\n        source: \"plaid\",\n        name: merchant_name,\n      ) do |m|\n        m.provider_merchant_id = merchant_id\n        m.website_url = plaid_transaction[\"website\"]\n        m.logo_url = plaid_transaction[\"logo_url\"]\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_item/accounts_snapshot.rb",
    "content": "# All Plaid data is fetched at the item-level.  This class is a simple wrapper that\n# providers a convenience method, get_account_data which scopes the item-level payload\n# to each Plaid Account\nclass PlaidItem::AccountsSnapshot\n  def initialize(plaid_item, plaid_provider:)\n    @plaid_item = plaid_item\n    @plaid_provider = plaid_provider\n  end\n\n  def accounts\n    @accounts ||= plaid_provider.get_item_accounts(plaid_item.access_token).accounts\n  end\n\n  def get_account_data(account_id)\n    AccountData.new(\n      account_data: accounts.find { |a| a.account_id == account_id },\n      transactions_data: account_scoped_transactions_data(account_id),\n      investments_data: account_scoped_investments_data(account_id),\n      liabilities_data: account_scoped_liabilities_data(account_id)\n    )\n  end\n\n  def transactions_cursor\n    return nil unless transactions_data\n    transactions_data.cursor\n  end\n\n  private\n    attr_reader :plaid_item, :plaid_provider\n\n    TransactionsData = Data.define(:added, :modified, :removed)\n    LiabilitiesData = Data.define(:credit, :mortgage, :student)\n    InvestmentsData = Data.define(:transactions, :holdings, :securities)\n    AccountData = Data.define(:account_data, :transactions_data, :investments_data, :liabilities_data)\n\n    def account_scoped_transactions_data(account_id)\n      return nil unless transactions_data\n\n      TransactionsData.new(\n        added: transactions_data.added.select { |t| t.account_id == account_id },\n        modified: transactions_data.modified.select { |t| t.account_id == account_id },\n        removed: transactions_data.removed.select { |t| t.account_id == account_id }\n      )\n    end\n\n    def account_scoped_investments_data(account_id)\n      return nil unless investments_data\n\n      transactions = investments_data.transactions.select { |t| t.account_id == account_id }\n      holdings = investments_data.holdings.select { |h| h.account_id == account_id }\n      securities = transactions.count > 0 && holdings.count > 0 ? investments_data.securities : []\n\n      InvestmentsData.new(\n        transactions: transactions,\n        holdings: holdings,\n        securities: securities\n      )\n    end\n\n    def account_scoped_liabilities_data(account_id)\n      return nil unless liabilities_data\n\n      LiabilitiesData.new(\n        credit: liabilities_data.credit&.find { |c| c.account_id == account_id },\n        mortgage: liabilities_data.mortgage&.find { |m| m.account_id == account_id },\n        student: liabilities_data.student&.find { |s| s.account_id == account_id }\n      )\n    end\n\n    def can_fetch_transactions?\n      plaid_item.supports_product?(\"transactions\") && accounts.any?\n    end\n\n    def transactions_data\n      return nil unless can_fetch_transactions?\n\n      @transactions_data ||= plaid_provider.get_transactions(\n        plaid_item.access_token,\n        next_cursor: plaid_item.next_cursor\n      )\n    end\n\n    def can_fetch_investments?\n      plaid_item.supports_product?(\"investments\") &&\n      accounts.any? { |a| a.type == \"investment\" }\n    end\n\n    def investments_data\n      return nil unless can_fetch_investments?\n      @investments_data ||= plaid_provider.get_item_investments(plaid_item.access_token)\n    end\n\n    def can_fetch_liabilities?\n      plaid_item.supports_product?(\"liabilities\") &&\n      accounts.any? do |a|\n        a.type == \"credit\" && a.subtype == \"credit card\" ||\n        a.type == \"loan\" && (a.subtype == \"mortgage\" || a.subtype == \"student\")\n      end\n    end\n\n    def liabilities_data\n      return nil unless can_fetch_liabilities?\n      @liabilities_data ||= plaid_provider.get_item_liabilities(plaid_item.access_token)\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_item/importer.rb",
    "content": "class PlaidItem::Importer\n  def initialize(plaid_item, plaid_provider:)\n    @plaid_item = plaid_item\n    @plaid_provider = plaid_provider\n  end\n\n  def import\n    fetch_and_import_item_data\n    fetch_and_import_accounts_data\n  rescue Plaid::ApiError => e\n    handle_plaid_error(e)\n  end\n\n  private\n    attr_reader :plaid_item, :plaid_provider\n\n    # All errors that should halt the import should be re-raised after handling\n    # These errors will propagate up to the Sync record and mark it as failed.\n    def handle_plaid_error(error)\n      error_body = JSON.parse(error.response_body)\n\n      case error_body[\"error_code\"]\n      when \"ITEM_LOGIN_REQUIRED\"\n        plaid_item.update!(status: :requires_update)\n      else\n        raise error\n      end\n    end\n\n    def fetch_and_import_item_data\n      item_data = plaid_provider.get_item(plaid_item.access_token).item\n      institution_data = plaid_provider.get_institution(item_data.institution_id).institution\n\n      plaid_item.upsert_plaid_snapshot!(item_data)\n      plaid_item.upsert_plaid_institution_snapshot!(institution_data)\n    end\n\n    def fetch_and_import_accounts_data\n      snapshot = PlaidItem::AccountsSnapshot.new(plaid_item, plaid_provider: plaid_provider)\n\n      PlaidItem.transaction do\n        snapshot.accounts.each do |raw_account|\n          plaid_account = plaid_item.plaid_accounts.find_or_initialize_by(\n            plaid_id: raw_account.account_id\n          )\n\n          PlaidAccount::Importer.new(\n            plaid_account,\n            account_snapshot: snapshot.get_account_data(raw_account.account_id)\n          ).import\n        end\n\n        # Once we know all data has been imported, save the cursor to avoid re-fetching the same data next time\n        plaid_item.update!(next_cursor: snapshot.transactions_cursor)\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_item/provided.rb",
    "content": "module PlaidItem::Provided\n  extend ActiveSupport::Concern\n\n  def plaid_provider\n    @plaid_provider ||= Provider::Registry.plaid_provider_for_region(self.plaid_region)\n  end\nend\n"
  },
  {
    "path": "app/models/plaid_item/sync_complete_event.rb",
    "content": "class PlaidItem::SyncCompleteEvent\n  attr_reader :plaid_item\n\n  def initialize(plaid_item)\n    @plaid_item = plaid_item\n  end\n\n  def broadcast\n    plaid_item.accounts.each do |account|\n      account.broadcast_sync_complete\n    end\n\n    plaid_item.broadcast_replace_to(\n      plaid_item.family,\n      target: \"plaid_item_#{plaid_item.id}\",\n      partial: \"plaid_items/plaid_item\",\n      locals: { plaid_item: plaid_item }\n    )\n\n    plaid_item.family.broadcast_sync_complete\n  end\nend\n"
  },
  {
    "path": "app/models/plaid_item/syncer.rb",
    "content": "class PlaidItem::Syncer\n  attr_reader :plaid_item\n\n  def initialize(plaid_item)\n    @plaid_item = plaid_item\n  end\n\n  def perform_sync(sync)\n    # Loads item metadata, accounts, transactions, and other data to our DB\n    plaid_item.import_latest_plaid_data\n\n    # Processes the raw Plaid data and updates internal domain objects\n    plaid_item.process_accounts\n\n    # All data is synced, so we can now run an account sync to calculate historical balances and more\n    plaid_item.schedule_account_syncs(\n      parent_sync: sync,\n      window_start_date: sync.window_start_date,\n      window_end_date: sync.window_end_date\n    )\n  end\n\n  def perform_post_sync\n    # no-op\n  end\nend\n"
  },
  {
    "path": "app/models/plaid_item/webhook_processor.rb",
    "content": "class PlaidItem::WebhookProcessor\n  MissingItemError = Class.new(StandardError)\n\n  def initialize(webhook_body)\n    parsed = JSON.parse(webhook_body)\n    @webhook_type = parsed[\"webhook_type\"]\n    @webhook_code = parsed[\"webhook_code\"]\n    @item_id = parsed[\"item_id\"]\n    @error = parsed[\"error\"]\n  end\n\n  def process\n    unless plaid_item\n      handle_missing_item\n      return\n    end\n\n    case [ webhook_type, webhook_code ]\n    when [ \"TRANSACTIONS\", \"SYNC_UPDATES_AVAILABLE\" ]\n      plaid_item.sync_later\n    when [ \"INVESTMENTS_TRANSACTIONS\", \"DEFAULT_UPDATE\" ]\n      plaid_item.sync_later\n    when [ \"HOLDINGS\", \"DEFAULT_UPDATE\" ]\n      plaid_item.sync_later\n    when [ \"ITEM\", \"ERROR\" ]\n      if error[\"error_code\"] == \"ITEM_LOGIN_REQUIRED\"\n        plaid_item.update!(status: :requires_update)\n      end\n    else\n      Rails.logger.warn(\"Unhandled Plaid webhook type: #{webhook_type}:#{webhook_code}\")\n    end\n  rescue => e\n    # To always ensure we return a 200 to Plaid (to keep endpoint healthy), silently capture and report all errors\n    Sentry.capture_exception(e)\n  end\n\n  private\n    attr_reader :webhook_type, :webhook_code, :item_id, :error\n\n    def plaid_item\n      @plaid_item ||= PlaidItem.find_by(plaid_id: item_id)\n    end\n\n    def handle_missing_item\n      return if plaid_item.present?\n\n      # If we cannot find an item in our DB, that means we've reached an invalid data state where\n      # the Plaid Item (upstream) still exists (and is being billed), but doesn't exist internally.\n      #\n      # Since we don't have the item which has the access token, there is nothing we can do programmatically\n      # here, so we just need to report it to Sentry and manually handle it.\n      Sentry.capture_exception(MissingItemError.new(\"Received Plaid webhook for item no longer in our DB.  Manual action required to resolve.\")) do |scope|\n        scope.set_tags(plaid_item_id: item_id)\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/plaid_item.rb",
    "content": "class PlaidItem < ApplicationRecord\n  include Syncable, Provided\n\n  enum :plaid_region, { us: \"us\", eu: \"eu\" }\n  enum :status, { good: \"good\", requires_update: \"requires_update\" }, default: :good\n\n  if Rails.application.credentials.active_record_encryption.present?\n    encrypts :access_token, deterministic: true\n  end\n\n  validates :name, :access_token, presence: true\n\n  before_destroy :remove_plaid_item\n\n  belongs_to :family\n  has_one_attached :logo\n\n  has_many :plaid_accounts, dependent: :destroy\n  has_many :accounts, through: :plaid_accounts\n\n  scope :active, -> { where(scheduled_for_deletion: false) }\n  scope :ordered, -> { order(created_at: :desc) }\n  scope :needs_update, -> { where(status: :requires_update) }\n\n  def get_update_link_token(webhooks_url:, redirect_url:)\n    family.get_link_token(\n      webhooks_url: webhooks_url,\n      redirect_url: redirect_url,\n      region: plaid_region,\n      access_token: access_token\n    )\n  rescue Plaid::ApiError => e\n    error_body = JSON.parse(e.response_body)\n\n    if error_body[\"error_code\"] == \"ITEM_NOT_FOUND\"\n      # Mark the connection as invalid but don't auto-delete\n      update!(status: :requires_update)\n    end\n\n    Sentry.capture_exception(e)\n    nil\n  end\n\n  def destroy_later\n    update!(scheduled_for_deletion: true)\n    DestroyJob.perform_later(self)\n  end\n\n  def import_latest_plaid_data\n    PlaidItem::Importer.new(self, plaid_provider: plaid_provider).import\n  end\n\n  # Reads the fetched data and updates internal domain objects\n  # Generally, this should only be called within a \"sync\", but can be called\n  # manually to \"re-sync\" the already fetched data\n  def process_accounts\n    plaid_accounts.each do |plaid_account|\n      PlaidAccount::Processor.new(plaid_account).process\n    end\n  end\n\n  # Once all the data is fetched, we can schedule account syncs to calculate historical balances\n  def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)\n    accounts.each do |account|\n      account.sync_later(\n        parent_sync: parent_sync,\n        window_start_date: window_start_date,\n        window_end_date: window_end_date\n      )\n    end\n  end\n\n  # Saves the raw data fetched from Plaid API for this item\n  def upsert_plaid_snapshot!(item_snapshot)\n    assign_attributes(\n      available_products: item_snapshot.available_products,\n      billed_products: item_snapshot.billed_products,\n      raw_payload: item_snapshot,\n    )\n\n    save!\n  end\n\n  # Saves the raw data fetched from Plaid API for this item's institution\n  def upsert_plaid_institution_snapshot!(institution_snapshot)\n    assign_attributes(\n      institution_id: institution_snapshot.institution_id,\n      institution_url: institution_snapshot.url,\n      institution_color: institution_snapshot.primary_color,\n      raw_institution_payload: institution_snapshot\n    )\n\n    save!\n  end\n\n  def supports_product?(product)\n    supported_products.include?(product)\n  end\n\n  private\n    def remove_plaid_item\n      plaid_provider.remove_item(access_token)\n    rescue Plaid::ApiError => e\n      json_response = JSON.parse(e.response_body)\n\n      # If the item is not found, that means it was already deleted by the user on their\n      # Plaid portal OR by Plaid support.  Either way, we're not being billed, so continue\n      # with the deletion of our internal record.\n      unless json_response[\"error_code\"] == \"ITEM_NOT_FOUND\"\n        raise e\n      end\n    end\n\n    # Plaid returns mutually exclusive arrays here.  If the item has made a request for a product,\n    # it is put in the billed_products array.  If it is supported, but not yet used, it goes in the\n    # available_products array.\n    def supported_products\n      available_products + billed_products\n    end\nend\n"
  },
  {
    "path": "app/models/property.rb",
    "content": "class Property < ApplicationRecord\n  include Accountable\n\n  SUBTYPES = {\n    \"single_family_home\" => { short: \"Single Family Home\", long: \"Single Family Home\" },\n    \"multi_family_home\" => { short: \"Multi-Family Home\", long: \"Multi-Family Home\" },\n    \"condominium\" => { short: \"Condo\", long: \"Condominium\" },\n    \"townhouse\" => { short: \"Townhouse\", long: \"Townhouse\" },\n    \"investment_property\" => { short: \"Investment Property\", long: \"Investment Property\" },\n    \"second_home\" => { short: \"Second Home\", long: \"Second Home\" }\n  }.freeze\n\n  has_one :address, as: :addressable, dependent: :destroy\n\n  accepts_nested_attributes_for :address\n\n  attribute :area_unit, :string, default: \"sqft\"\n\n  class << self\n    def icon\n      \"home\"\n    end\n\n    def color\n      \"#06AED4\"\n    end\n\n    def classification\n      \"asset\"\n    end\n  end\n\n  def area\n    Measurement.new(area_value, area_unit) if area_value.present?\n  end\n\n  def purchase_price\n    first_valuation_amount\n  end\n\n  def trend\n    Trend.new(current: account.balance_money, previous: first_valuation_amount)\n  end\n\n  def balance_display_name\n    \"market value\"\n  end\n\n  def opening_balance_display_name\n    \"original purchase price\"\n  end\n\n  private\n    def first_valuation_amount\n      account.entries.valuations.order(:date).first&.amount_money || account.balance_money\n    end\nend\n"
  },
  {
    "path": "app/models/provider/exchange_rate_concept.rb",
    "content": "module Provider::ExchangeRateConcept\n  extend ActiveSupport::Concern\n\n  Rate = Data.define(:date, :from, :to, :rate)\n\n  def fetch_exchange_rate(from:, to:, date:)\n    raise NotImplementedError, \"Subclasses must implement #fetch_exchange_rate\"\n  end\n\n  def fetch_exchange_rates(from:, to:, start_date:, end_date:)\n    raise NotImplementedError, \"Subclasses must implement #fetch_exchange_rates\"\n  end\nend\n"
  },
  {
    "path": "app/models/provider/github.rb",
    "content": "class Provider::Github\n  attr_reader :name, :owner, :branch\n\n  def initialize\n    @name = \"maybe\"\n    @owner = \"maybe-finance\"\n    @branch = \"main\"\n  end\n\n  def fetch_latest_release_notes\n    begin\n      Rails.cache.fetch(\"latest_github_release_notes\", expires_in: 2.hours) do\n        release = Octokit.releases(repo).first\n        if release\n          {\n            avatar: release.author.avatar_url,\n            # this is the username, it would be nice to get the full name\n            username: release.author.login,\n            name: release.name,\n            published_at: release.published_at,\n            body: Octokit.markdown(release.body, mode: \"gfm\", context: repo)\n          }\n        else\n          nil\n        end\n      end\n    rescue => e\n      Rails.logger.error \"Failed to fetch latest GitHub release notes: #{e.message}\"\n      nil\n    end\n  end\n\n  private\n    def repo\n      \"#{owner}/#{name}\"\n    end\nend\n"
  },
  {
    "path": "app/models/provider/llm_concept.rb",
    "content": "module Provider::LlmConcept\n  extend ActiveSupport::Concern\n\n  AutoCategorization = Data.define(:transaction_id, :category_name)\n\n  def auto_categorize(transactions)\n    raise NotImplementedError, \"Subclasses must implement #auto_categorize\"\n  end\n\n  AutoDetectedMerchant = Data.define(:transaction_id, :business_name, :business_url)\n\n  def auto_detect_merchants(transactions)\n    raise NotImplementedError, \"Subclasses must implement #auto_detect_merchants\"\n  end\n\n  ChatMessage = Data.define(:id, :output_text)\n  ChatStreamChunk = Data.define(:type, :data)\n  ChatResponse = Data.define(:id, :model, :messages, :function_requests)\n  ChatFunctionRequest = Data.define(:id, :call_id, :function_name, :function_args)\n\n  def chat_response(prompt, model:, instructions: nil, functions: [], function_results: [], streamer: nil, previous_response_id: nil)\n    raise NotImplementedError, \"Subclasses must implement #chat_response\"\n  end\nend\n"
  },
  {
    "path": "app/models/provider/openai/auto_categorizer.rb",
    "content": "class Provider::Openai::AutoCategorizer\n  def initialize(client, transactions: [], user_categories: [])\n    @client = client\n    @transactions = transactions\n    @user_categories = user_categories\n  end\n\n  def auto_categorize\n    response = client.responses.create(parameters: {\n      model: \"gpt-4.1-mini\",\n      input: [ { role: \"developer\", content: developer_message } ],\n      text: {\n        format: {\n          type: \"json_schema\",\n          name: \"auto_categorize_personal_finance_transactions\",\n          strict: true,\n          schema: json_schema\n        }\n      },\n      instructions: instructions\n    })\n\n    Rails.logger.info(\"Tokens used to auto-categorize transactions: #{response.dig(\"usage\").dig(\"total_tokens\")}\")\n\n    build_response(extract_categorizations(response))\n  end\n\n  private\n    attr_reader :client, :transactions, :user_categories\n\n    AutoCategorization = Provider::LlmConcept::AutoCategorization\n\n    def build_response(categorizations)\n      categorizations.map do |categorization|\n        AutoCategorization.new(\n          transaction_id: categorization.dig(\"transaction_id\"),\n          category_name: normalize_category_name(categorization.dig(\"category_name\")),\n        )\n      end\n    end\n\n    def normalize_category_name(category_name)\n      return nil if category_name == \"null\"\n\n      category_name\n    end\n\n    def extract_categorizations(response)\n      response_json = JSON.parse(response.dig(\"output\")[0].dig(\"content\")[0].dig(\"text\"))\n      response_json.dig(\"categorizations\")\n    end\n\n    def json_schema\n      {\n        type: \"object\",\n        properties: {\n          categorizations: {\n            type: \"array\",\n            description: \"An array of auto-categorizations for each transaction\",\n            items: {\n              type: \"object\",\n              properties: {\n                transaction_id: {\n                  type: \"string\",\n                  description: \"The internal ID of the original transaction\",\n                  enum: transactions.map { |t| t[:id] }\n                },\n                category_name: {\n                  type: \"string\",\n                  description: \"The matched category name of the transaction, or null if no match\",\n                  enum: [ *user_categories.map { |c| c[:name] }, \"null\" ]\n                }\n              },\n              required: [ \"transaction_id\", \"category_name\" ],\n              additionalProperties: false\n            }\n          }\n        },\n        required: [ \"categorizations\" ],\n        additionalProperties: false\n      }\n    end\n\n    def developer_message\n      <<~MESSAGE.strip_heredoc\n        Here are the user's available categories in JSON format:\n\n        ```json\n        #{user_categories.to_json}\n        ```\n\n        Use the available categories to auto-categorize the following transactions:\n\n        ```json\n        #{transactions.to_json}\n        ```\n      MESSAGE\n    end\n\n    def instructions\n      <<~INSTRUCTIONS.strip_heredoc\n        You are an assistant to a consumer personal finance app.  You will be provided a list\n        of the user's transactions and a list of the user's categories.  Your job is to auto-categorize\n        each transaction.\n\n        Closely follow ALL the rules below while auto-categorizing:\n\n        - Return 1 result per transaction\n        - Correlate each transaction by ID (transaction_id)\n        - Attempt to match the most specific category possible (i.e. subcategory over parent category)\n        - Category and transaction classifications should match (i.e. if transaction is an \"expense\", the category must have classification of \"expense\")\n        - If you don't know the category, return \"null\"\n          - You should always favor \"null\" over false positives\n          - Be slightly pessimistic.  Only match a category if you're 60%+ confident it is the correct one.\n        - Each transaction has varying metadata that can be used to determine the category\n          - Note: \"hint\" comes from 3rd party aggregators and typically represents a category name that\n            may or may not match any of the user-supplied categories\n      INSTRUCTIONS\n    end\nend\n"
  },
  {
    "path": "app/models/provider/openai/auto_merchant_detector.rb",
    "content": "class Provider::Openai::AutoMerchantDetector\n  def initialize(client, transactions:, user_merchants:)\n    @client = client\n    @transactions = transactions\n    @user_merchants = user_merchants\n  end\n\n  def auto_detect_merchants\n    response = client.responses.create(parameters: {\n      model: \"gpt-4.1-mini\",\n      input: [ { role: \"developer\", content: developer_message } ],\n      text: {\n        format: {\n          type: \"json_schema\",\n          name: \"auto_detect_personal_finance_merchants\",\n          strict: true,\n          schema: json_schema\n        }\n      },\n      instructions: instructions\n    })\n\n    Rails.logger.info(\"Tokens used to auto-detect merchants: #{response.dig(\"usage\").dig(\"total_tokens\")}\")\n\n    build_response(extract_categorizations(response))\n  end\n\n  private\n    attr_reader :client, :transactions, :user_merchants\n\n    AutoDetectedMerchant = Provider::LlmConcept::AutoDetectedMerchant\n\n    def build_response(categorizations)\n      categorizations.map do |categorization|\n        AutoDetectedMerchant.new(\n          transaction_id: categorization.dig(\"transaction_id\"),\n          business_name: normalize_ai_value(categorization.dig(\"business_name\")),\n          business_url: normalize_ai_value(categorization.dig(\"business_url\")),\n        )\n      end\n    end\n\n    def normalize_ai_value(ai_value)\n      return nil if ai_value == \"null\"\n\n      ai_value\n    end\n\n    def extract_categorizations(response)\n      response_json = JSON.parse(response.dig(\"output\")[0].dig(\"content\")[0].dig(\"text\"))\n      response_json.dig(\"merchants\")\n    end\n\n    def json_schema\n      {\n        type: \"object\",\n        properties: {\n          merchants: {\n            type: \"array\",\n            description: \"An array of auto-detected merchant businesses for each transaction\",\n            items: {\n              type: \"object\",\n              properties: {\n                transaction_id: {\n                  type: \"string\",\n                  description: \"The internal ID of the original transaction\",\n                  enum: transactions.map { |t| t[:id] }\n                },\n                business_name: {\n                  type: [ \"string\", \"null\" ],\n                  description: \"The detected business name of the transaction, or `null` if uncertain\"\n                },\n                business_url: {\n                  type: [ \"string\", \"null\" ],\n                  description: \"The URL of the detected business, or `null` if uncertain\"\n                }\n              },\n              required: [ \"transaction_id\", \"business_name\", \"business_url\" ],\n              additionalProperties: false\n            }\n          }\n        },\n        required: [ \"merchants\" ],\n        additionalProperties: false\n      }\n    end\n\n    def developer_message\n      <<~MESSAGE.strip_heredoc\n        Here are the user's available merchants in JSON format:\n\n        ```json\n        #{user_merchants.to_json}\n        ```\n\n        Use BOTH your knowledge AND the user-generated merchants to auto-detect the following transactions:\n\n        ```json\n        #{transactions.to_json}\n        ```\n\n        Return \"null\" if you are not 80%+ confident in your answer.\n      MESSAGE\n    end\n\n    def instructions\n      <<~INSTRUCTIONS.strip_heredoc\n        You are an assistant to a consumer personal finance app.\n\n        Closely follow ALL the rules below while auto-detecting business names and website URLs:\n\n        - Return 1 result per transaction\n        - Correlate each transaction by ID (transaction_id)\n        - Do not include the subdomain in the business_url (i.e. \"amazon.com\" not \"www.amazon.com\")\n        - User merchants are considered \"manual\" user-generated merchants and should only be used in 100% clear cases\n        - Be slightly pessimistic.  We favor returning \"null\" over returning a false positive.\n        - NEVER return a name or URL for generic transaction names (e.g. \"Paycheck\", \"Laundromat\", \"Grocery store\", \"Local diner\")\n\n        Determining a value:\n\n        - First attempt to determine the name + URL from your knowledge of global businesses\n        - If no certain match, attempt to match one of the user-provided merchants\n        - If no match, return \"null\"\n\n        Example 1 (known business):\n\n        ```\n        Transaction name: \"Some Amazon purchases\"\n\n        Result:\n        - business_name: \"Amazon\"\n        - business_url: \"amazon.com\"\n        ```\n\n        Example 2 (generic business):\n\n        ```\n        Transaction name: \"local diner\"\n\n        Result:\n        - business_name: null\n        - business_url: null\n        ```\n      INSTRUCTIONS\n    end\nend\n"
  },
  {
    "path": "app/models/provider/openai/chat_config.rb",
    "content": "class Provider::Openai::ChatConfig\n  def initialize(functions: [], function_results: [])\n    @functions = functions\n    @function_results = function_results\n  end\n\n  def tools\n    functions.map do |fn|\n      {\n        type: \"function\",\n        name: fn[:name],\n        description: fn[:description],\n        parameters: fn[:params_schema],\n        strict: fn[:strict]\n      }\n    end\n  end\n\n  def build_input(prompt)\n    results = function_results.map do |fn_result|\n      {\n        type: \"function_call_output\",\n        call_id: fn_result[:call_id],\n        output: fn_result[:output].to_json\n      }\n    end\n\n    [\n      { role: \"user\", content: prompt },\n      *results\n    ]\n  end\n\n  private\n    attr_reader :functions, :function_results\nend\n"
  },
  {
    "path": "app/models/provider/openai/chat_parser.rb",
    "content": "class Provider::Openai::ChatParser\n  Error = Class.new(StandardError)\n\n  def initialize(object)\n    @object = object\n  end\n\n  def parsed\n    ChatResponse.new(\n      id: response_id,\n      model: response_model,\n      messages: messages,\n      function_requests: function_requests\n    )\n  end\n\n  private\n    attr_reader :object\n\n    ChatResponse = Provider::LlmConcept::ChatResponse\n    ChatMessage = Provider::LlmConcept::ChatMessage\n    ChatFunctionRequest = Provider::LlmConcept::ChatFunctionRequest\n\n    def response_id\n      object.dig(\"id\")\n    end\n\n    def response_model\n      object.dig(\"model\")\n    end\n\n    def messages\n      message_items = object.dig(\"output\").filter { |item| item.dig(\"type\") == \"message\" }\n\n      message_items.map do |message_item|\n        ChatMessage.new(\n          id: message_item.dig(\"id\"),\n          output_text: message_item.dig(\"content\").map do |content|\n            text = content.dig(\"text\")\n            refusal = content.dig(\"refusal\")\n            text || refusal\n          end.flatten.join(\"\\n\")\n        )\n      end\n    end\n\n    def function_requests\n      function_items = object.dig(\"output\").filter { |item| item.dig(\"type\") == \"function_call\" }\n\n      function_items.map do |function_item|\n        ChatFunctionRequest.new(\n          id: function_item.dig(\"id\"),\n          call_id: function_item.dig(\"call_id\"),\n          function_name: function_item.dig(\"name\"),\n          function_args: function_item.dig(\"arguments\")\n        )\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/provider/openai/chat_stream_parser.rb",
    "content": "class Provider::Openai::ChatStreamParser\n  Error = Class.new(StandardError)\n\n  def initialize(object)\n    @object = object\n  end\n\n  def parsed\n    type = object.dig(\"type\")\n\n    case type\n    when \"response.output_text.delta\", \"response.refusal.delta\"\n      Chunk.new(type: \"output_text\", data: object.dig(\"delta\"))\n    when \"response.completed\"\n      raw_response = object.dig(\"response\")\n      Chunk.new(type: \"response\", data: parse_response(raw_response))\n    end\n  end\n\n  private\n    attr_reader :object\n\n    Chunk = Provider::LlmConcept::ChatStreamChunk\n\n    def parse_response(response)\n      Provider::Openai::ChatParser.new(response).parsed\n    end\nend\n"
  },
  {
    "path": "app/models/provider/openai.rb",
    "content": "class Provider::Openai < Provider\n  include LlmConcept\n\n  # Subclass so errors caught in this provider are raised as Provider::Openai::Error\n  Error = Class.new(Provider::Error)\n\n  MODELS = %w[gpt-4.1]\n\n  def initialize(access_token)\n    @client = ::OpenAI::Client.new(access_token: access_token)\n  end\n\n  def supports_model?(model)\n    MODELS.include?(model)\n  end\n\n  def auto_categorize(transactions: [], user_categories: [])\n    with_provider_response do\n      raise Error, \"Too many transactions to auto-categorize. Max is 25 per request.\" if transactions.size > 25\n\n      AutoCategorizer.new(\n        client,\n        transactions: transactions,\n        user_categories: user_categories\n      ).auto_categorize\n    end\n  end\n\n  def auto_detect_merchants(transactions: [], user_merchants: [])\n    with_provider_response do\n      raise Error, \"Too many transactions to auto-detect merchants. Max is 25 per request.\" if transactions.size > 25\n\n      AutoMerchantDetector.new(\n        client,\n        transactions: transactions,\n        user_merchants: user_merchants\n      ).auto_detect_merchants\n    end\n  end\n\n  def chat_response(prompt, model:, instructions: nil, functions: [], function_results: [], streamer: nil, previous_response_id: nil)\n    with_provider_response do\n      chat_config = ChatConfig.new(\n        functions: functions,\n        function_results: function_results\n      )\n\n      collected_chunks = []\n\n      # Proxy that converts raw stream to \"LLM Provider concept\" stream\n      stream_proxy = if streamer.present?\n        proc do |chunk|\n          parsed_chunk = ChatStreamParser.new(chunk).parsed\n\n          unless parsed_chunk.nil?\n            streamer.call(parsed_chunk)\n            collected_chunks << parsed_chunk\n          end\n        end\n      else\n        nil\n      end\n\n      raw_response = client.responses.create(parameters: {\n        model: model,\n        input: chat_config.build_input(prompt),\n        instructions: instructions,\n        tools: chat_config.tools,\n        previous_response_id: previous_response_id,\n        stream: stream_proxy\n      })\n\n      # If streaming, Ruby OpenAI does not return anything, so to normalize this method's API, we search\n      # for the \"response chunk\" in the stream and return it (it is already parsed)\n      if stream_proxy.present?\n        response_chunk = collected_chunks.find { |chunk| chunk.type == \"response\" }\n        response_chunk.data\n      else\n        ChatParser.new(raw_response).parsed\n      end\n    end\n  end\n\n  private\n    attr_reader :client\nend\n"
  },
  {
    "path": "app/models/provider/plaid.rb",
    "content": "class Provider::Plaid\n  attr_reader :client, :region\n\n  MAYBE_SUPPORTED_PLAID_PRODUCTS = %w[transactions investments liabilities].freeze\n  MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730\n\n  def initialize(config, region: :us)\n    @client = Plaid::PlaidApi.new(\n      Plaid::ApiClient.new(config)\n    )\n    @region = region\n  end\n\n  def validate_webhook!(verification_header, raw_body)\n    jwks_loader = ->(options) do\n      key_id = options[:kid]\n\n      jwk_response = client.webhook_verification_key_get(\n        Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id)\n      )\n\n      jwks = JWT::JWK::Set.new([ jwk_response.key.to_hash ])\n\n      jwks.filter! { |key| key[:use] == \"sig\" }\n      jwks\n    end\n\n    payload, _header = JWT.decode(\n      verification_header, nil, true,\n      {\n        algorithms: [ \"ES256\" ],\n        jwks: jwks_loader,\n        verify_expiration: false\n      }\n    )\n\n    issued_at = Time.at(payload[\"iat\"])\n    raise JWT::VerificationError, \"Webhook is too old\" if Time.now - issued_at > 5.minutes\n\n    expected_hash = payload[\"request_body_sha256\"]\n    actual_hash = Digest::SHA256.hexdigest(raw_body)\n    raise JWT::VerificationError, \"Invalid webhook body hash\" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash)\n  end\n\n  def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil, access_token: nil)\n    request_params = {\n      user: { client_user_id: user_id },\n      client_name: \"Maybe Finance\",\n      country_codes: country_codes,\n      language: \"en\",\n      webhook: webhooks_url,\n      redirect_uri: redirect_url,\n      transactions: { days_requested: MAX_HISTORY_DAYS }\n    }\n\n    if access_token.present?\n      request_params[:access_token] = access_token\n    else\n      request_params[:products] = [ get_primary_product(accountable_type) ]\n      request_params[:additional_consented_products] = get_additional_consented_products(accountable_type)\n    end\n\n    request = Plaid::LinkTokenCreateRequest.new(request_params)\n\n    client.link_token_create(request)\n  end\n\n  def exchange_public_token(token)\n    request = Plaid::ItemPublicTokenExchangeRequest.new(\n      public_token: token\n    )\n\n    client.item_public_token_exchange(request)\n  end\n\n  def get_item(access_token)\n    request = Plaid::ItemGetRequest.new(access_token: access_token)\n    client.item_get(request)\n  end\n\n  def remove_item(access_token)\n    request = Plaid::ItemRemoveRequest.new(access_token: access_token)\n    client.item_remove(request)\n  end\n\n  def get_item_accounts(access_token)\n    request = Plaid::AccountsGetRequest.new(access_token: access_token)\n    client.accounts_get(request)\n  end\n\n  def get_transactions(access_token, next_cursor: nil)\n    cursor = next_cursor\n    added = []\n    modified = []\n    removed = []\n    has_more = true\n\n    while has_more\n      request = Plaid::TransactionsSyncRequest.new(\n        access_token: access_token,\n        cursor: cursor,\n        options: {\n          include_original_description: true\n        }\n      )\n\n      response = client.transactions_sync(request)\n\n      added += response.added\n      modified += response.modified\n      removed += response.removed\n      has_more = response.has_more\n      cursor = response.next_cursor\n    end\n\n    TransactionSyncResponse.new(added:, modified:, removed:, cursor:)\n  end\n\n  def get_item_investments(access_token, start_date: nil, end_date: Date.current)\n    start_date = start_date || MAX_HISTORY_DAYS.days.ago.to_date\n    holdings, holding_securities = get_item_holdings(access_token: access_token)\n    transactions, transaction_securities = get_item_investment_transactions(access_token: access_token, start_date:, end_date:)\n\n    merged_securities = ((holding_securities || []) + (transaction_securities || [])).uniq { |s| s.security_id }\n\n    InvestmentsResponse.new(holdings:, transactions:, securities: merged_securities)\n  end\n\n  def get_item_liabilities(access_token)\n    request = Plaid::LiabilitiesGetRequest.new({ access_token: access_token })\n    response = client.liabilities_get(request)\n    response.liabilities\n  end\n\n  def get_institution(institution_id)\n    request = Plaid::InstitutionsGetByIdRequest.new({\n      institution_id: institution_id,\n      country_codes: country_codes,\n      options: {\n        include_optional_metadata: true\n      }\n    })\n    client.institutions_get_by_id(request)\n  end\n\n  private\n    TransactionSyncResponse = Struct.new :added, :modified, :removed, :cursor, keyword_init: true\n    InvestmentsResponse = Struct.new :holdings, :transactions, :securities, keyword_init: true\n\n    def get_item_holdings(access_token:)\n      request = Plaid::InvestmentsHoldingsGetRequest.new({ access_token: access_token })\n      response = client.investments_holdings_get(request)\n\n      [ response.holdings, response.securities ]\n    end\n\n    def get_item_investment_transactions(access_token:, start_date:, end_date:)\n      transactions = []\n      securities = []\n      offset = 0\n\n      loop do\n        request = Plaid::InvestmentsTransactionsGetRequest.new(\n          access_token: access_token,\n          start_date: start_date.to_s,\n          end_date: end_date.to_s,\n          options: { offset: offset }\n        )\n\n        response = client.investments_transactions_get(request)\n\n        transactions += response.investment_transactions\n        securities += response.securities\n\n        break if transactions.length >= response.total_investment_transactions\n        offset = transactions.length\n      end\n\n      [ transactions, securities ]\n    end\n\n    def get_primary_product(accountable_type)\n      return \"transactions\" if eu?\n\n      case accountable_type\n      when \"Investment\"\n        \"investments\"\n      when \"CreditCard\", \"Loan\"\n        \"liabilities\"\n      else\n        \"transactions\"\n      end\n    end\n\n    def get_additional_consented_products(accountable_type)\n      return [] if eu?\n\n      MAYBE_SUPPORTED_PLAID_PRODUCTS - [ get_primary_product(accountable_type) ]\n    end\n\n    def eu?\n      region.to_sym == :eu\n    end\n\n    def country_codes\n      if eu?\n        [ \"ES\", \"NL\", \"FR\", \"IE\", \"DE\", \"IT\", \"PL\", \"DK\", \"NO\", \"SE\", \"EE\", \"LT\", \"LV\", \"PT\", \"BE\" ]  # EU supported countries\n      else\n        [ \"US\", \"CA\" ] # US + CA only\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/provider/plaid_sandbox.rb",
    "content": "class Provider::PlaidSandbox < Provider::Plaid\n  attr_reader :client\n\n  def initialize\n    @client = create_client\n    @region = :us\n  end\n\n  def create_public_token(username: nil)\n    client.sandbox_public_token_create(\n      Plaid::SandboxPublicTokenCreateRequest.new(\n        institution_id: \"ins_109508\", # \"First Platypus Bank\" (Plaid's sandbox institution that works with all products)\n        initial_products: [ \"transactions\", \"investments\", \"liabilities\" ],\n        options: {\n          override_username: username || \"custom_test\"\n        }\n      )\n    ).public_token\n  end\n\n  def fire_webhook(item, type: \"TRANSACTIONS\", code: \"SYNC_UPDATES_AVAILABLE\")\n    client.sandbox_item_fire_webhook(\n      Plaid::SandboxItemFireWebhookRequest.new(\n        access_token: item.access_token,\n        webhook_type: type,\n        webhook_code: code,\n      )\n    )\n  end\n\n  def reset_login(item)\n    client.sandbox_item_reset_login(\n      Plaid::SandboxItemResetLoginRequest.new(\n        access_token: item.access_token\n      )\n    )\n  end\n\n  private\n    def create_client\n      raise \"Plaid sandbox is not supported in production\" if Rails.env.production?\n\n      api_client = Plaid::ApiClient.new(\n        Rails.application.config.plaid\n      )\n\n      # Force sandbox environment for PlaidSandbox regardless of Rails config\n      api_client.config.server_index = Plaid::Configuration::Environment[\"sandbox\"]\n\n      Plaid::PlaidApi.new(api_client)\n    end\nend\n"
  },
  {
    "path": "app/models/provider/registry.rb",
    "content": "class Provider::Registry\n  include ActiveModel::Validations\n\n  Error = Class.new(StandardError)\n\n  CONCEPTS = %i[exchange_rates securities llm]\n\n  validates :concept, inclusion: { in: CONCEPTS }\n\n  class << self\n    def for_concept(concept)\n      new(concept.to_sym)\n    end\n\n    def get_provider(name)\n      send(name)\n    rescue NoMethodError\n      raise Error.new(\"Provider '#{name}' not found in registry\")\n    end\n\n    def plaid_provider_for_region(region)\n      region.to_sym == :us ? plaid_us : plaid_eu\n    end\n\n    private\n      def stripe\n        secret_key = ENV[\"STRIPE_SECRET_KEY\"]\n        webhook_secret = ENV[\"STRIPE_WEBHOOK_SECRET\"]\n\n        return nil unless secret_key.present? && webhook_secret.present?\n\n        Provider::Stripe.new(secret_key:, webhook_secret:)\n      end\n\n      def synth\n        api_key = ENV.fetch(\"SYNTH_API_KEY\", Setting.synth_api_key)\n\n        return nil unless api_key.present?\n\n        Provider::Synth.new(api_key)\n      end\n\n      def plaid_us\n        config = Rails.application.config.plaid\n\n        return nil unless config.present?\n\n        Provider::Plaid.new(config, region: :us)\n      end\n\n      def plaid_eu\n        config = Rails.application.config.plaid_eu\n\n        return nil unless config.present?\n\n        Provider::Plaid.new(config, region: :eu)\n      end\n\n      def github\n        Provider::Github.new\n      end\n\n      def openai\n        access_token = ENV.fetch(\"OPENAI_ACCESS_TOKEN\", Setting.openai_access_token)\n\n        return nil unless access_token.present?\n\n        Provider::Openai.new(access_token)\n      end\n  end\n\n  def initialize(concept)\n    @concept = concept\n    validate!\n  end\n\n  def providers\n    available_providers.map { |p| self.class.send(p) }\n  end\n\n  def get_provider(name)\n    provider_method = available_providers.find { |p| p == name.to_sym }\n\n    raise Error.new(\"Provider '#{name}' not found for concept: #{concept}\") unless provider_method.present?\n\n    self.class.send(provider_method)\n  end\n\n  private\n    attr_reader :concept\n\n    def available_providers\n      case concept\n      when :exchange_rates\n        %i[synth]\n      when :securities\n        %i[synth]\n      when :llm\n        %i[openai]\n      else\n        %i[synth plaid_us plaid_eu github openai]\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/provider/security_concept.rb",
    "content": "module Provider::SecurityConcept\n  extend ActiveSupport::Concern\n\n  Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic, :country_code)\n  SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind, :exchange_operating_mic)\n  Price = Data.define(:symbol, :date, :price, :currency, :exchange_operating_mic)\n\n  def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)\n    raise NotImplementedError, \"Subclasses must implement #search_securities\"\n  end\n\n  def fetch_security_info(symbol:, exchange_operating_mic:)\n    raise NotImplementedError, \"Subclasses must implement #fetch_security_info\"\n  end\n\n  def fetch_security_price(symbol:, exchange_operating_mic:, date:)\n    raise NotImplementedError, \"Subclasses must implement #fetch_security_price\"\n  end\n\n  def fetch_security_prices(symbol:, exchange_operating_mic:, start_date:, end_date:)\n    raise NotImplementedError, \"Subclasses must implement #fetch_security_prices\"\n  end\nend\n"
  },
  {
    "path": "app/models/provider/stripe/event_processor.rb",
    "content": "class Provider::Stripe::EventProcessor\n  def initialize(event)\n    @event = event\n  end\n\n  def process\n    raise NotImplementedError, \"Subclasses must implement the process method\"\n  end\n\n  private\n    attr_reader :event\n\n    def event_data\n      event.data.object\n    end\nend\n"
  },
  {
    "path": "app/models/provider/stripe/subscription_event_processor.rb",
    "content": "class Provider::Stripe::SubscriptionEventProcessor < Provider::Stripe::EventProcessor\n  Error = Class.new(StandardError)\n\n  def process\n    raise Error, \"Family not found for Stripe customer ID: #{subscription.customer}\" unless family\n\n    family.subscription.update(\n      stripe_id: subscription.id,\n      status: subscription.status,\n      interval: subscription_details.plan.interval,\n      amount: subscription_details.plan.amount / 100.0, # Stripe returns cents, we report dollars\n      currency: subscription_details.plan.currency.upcase,\n      current_period_ends_at: Time.at(subscription_details.current_period_end)\n    )\n  end\n\n  private\n    def family\n      Family.find_by(stripe_customer_id: subscription.customer)\n    end\n\n    def subscription_details\n      event_data.items.data.first\n    end\n\n    def subscription\n      event_data\n    end\nend\n"
  },
  {
    "path": "app/models/provider/stripe.rb",
    "content": "class Provider::Stripe\n  Error = Class.new(StandardError)\n\n  def initialize(secret_key:, webhook_secret:)\n    @client = Stripe::StripeClient.new(secret_key)\n    @webhook_secret = webhook_secret\n  end\n\n  def process_event(event_id)\n    event = retrieve_event(event_id)\n\n    case event.type\n    when /^customer\\.subscription\\./\n      SubscriptionEventProcessor.new(event).process\n    else\n      Rails.logger.warn \"Unhandled event type: #{event.type}\"\n    end\n  end\n\n  def process_webhook_later(webhook_body, sig_header)\n    thin_event = client.parse_thin_event(webhook_body, sig_header, webhook_secret)\n    StripeEventHandlerJob.perform_later(thin_event.id)\n  end\n\n  def create_checkout_session(plan:, family_id:, family_email:, success_url:, cancel_url:)\n    customer = client.v1.customers.create(\n      email: family_email,\n      metadata: {\n        family_id: family_id\n      }\n    )\n\n    session = client.v1.checkout.sessions.create(\n      customer: customer.id,\n      line_items: [ { price: price_id_for(plan), quantity: 1 } ],\n      mode: \"subscription\",\n      allow_promotion_codes: true,\n      success_url: success_url,\n      cancel_url: cancel_url\n    )\n\n    NewCheckoutSession.new(url: session.url, customer_id: customer.id)\n  end\n\n  def get_checkout_result(session_id)\n    session = client.v1.checkout.sessions.retrieve(session_id)\n\n    unless session.status == \"complete\" && session.payment_status == \"paid\"\n      raise Error, \"Checkout session not complete\"\n    end\n\n    CheckoutSessionResult.new(success?: true, subscription_id: session.subscription)\n  rescue StandardError => e\n    Sentry.capture_exception(e)\n    Rails.logger.error \"Error fetching checkout result for session #{session_id}: #{e.message}\"\n    CheckoutSessionResult.new(success?: false, subscription_id: nil)\n  end\n\n  def create_billing_portal_session_url(customer_id:, return_url:)\n    client.v1.billing_portal.sessions.create(\n      customer: customer_id,\n      return_url: return_url\n    ).url\n  end\n\n  def update_customer_metadata(customer_id:, metadata:)\n    client.v1.customers.update(customer_id, metadata: metadata)\n  end\n\n  private\n    attr_reader :client, :webhook_secret\n\n    NewCheckoutSession = Data.define(:url, :customer_id)\n    CheckoutSessionResult = Data.define(:success?, :subscription_id)\n\n    def price_id_for(plan)\n      prices = {\n        monthly: ENV[\"STRIPE_MONTHLY_PRICE_ID\"],\n        annual: ENV[\"STRIPE_ANNUAL_PRICE_ID\"]\n      }\n\n      prices[plan.to_sym || :monthly]\n    end\n\n    def retrieve_event(event_id)\n      client.v1.events.retrieve(event_id)\n    end\nend\n"
  },
  {
    "path": "app/models/provider/synth.rb",
    "content": "class Provider::Synth < Provider\n  include ExchangeRateConcept, SecurityConcept\n\n  # Subclass so errors caught in this provider are raised as Provider::Synth::Error\n  Error = Class.new(Provider::Error)\n  InvalidExchangeRateError = Class.new(Error)\n  InvalidSecurityPriceError = Class.new(Error)\n\n  def initialize(api_key)\n    @api_key = api_key\n  end\n\n  def healthy?\n    with_provider_response do\n      response = client.get(\"#{base_url}/user\")\n      JSON.parse(response.body).dig(\"id\").present?\n    end\n  end\n\n  def usage\n    with_provider_response do\n      response = client.get(\"#{base_url}/user\")\n\n      parsed = JSON.parse(response.body)\n\n      remaining = parsed.dig(\"api_calls_remaining\")\n      limit = parsed.dig(\"api_limit\")\n      used = limit - remaining\n\n      UsageData.new(\n        used: used,\n        limit: limit,\n        utilization: used.to_f / limit * 100,\n        plan: parsed.dig(\"plan\"),\n      )\n    end\n  end\n\n  # ================================\n  #          Exchange Rates\n  # ================================\n\n  def fetch_exchange_rate(from:, to:, date:)\n    with_provider_response do\n      response = client.get(\"#{base_url}/rates/historical\") do |req|\n        req.params[\"date\"] = date.to_s\n        req.params[\"from\"] = from\n        req.params[\"to\"] = to\n      end\n\n      rates = JSON.parse(response.body).dig(\"data\", \"rates\")\n\n      Rate.new(date: date.to_date, from:, to:, rate: rates.dig(to))\n    end\n  end\n\n  def fetch_exchange_rates(from:, to:, start_date:, end_date:)\n    with_provider_response do\n      data = paginate(\n        \"#{base_url}/rates/historical-range\",\n        from: from,\n        to: to,\n        date_start: start_date.to_s,\n        date_end: end_date.to_s\n      ) do |body|\n        body.dig(\"data\")\n      end\n\n      data.paginated.map do |rate|\n        date = rate.dig(\"date\")\n        rate = rate.dig(\"rates\", to)\n\n        if date.nil? || rate.nil?\n          Rails.logger.warn(\"#{self.class.name} returned invalid rate data for pair from: #{from} to: #{to} on: #{date}.  Rate data: #{rate.inspect}\")\n          Sentry.capture_exception(InvalidExchangeRateError.new(\"#{self.class.name} returned invalid rate data\"), level: :warning) do |scope|\n            scope.set_context(\"rate\", { from: from, to: to, date: date })\n          end\n\n          next\n        end\n\n        Rate.new(date: date.to_date, from:, to:, rate:)\n      end.compact\n    end\n  end\n\n  # ================================\n  #           Securities\n  # ================================\n\n  def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)\n    with_provider_response do\n      response = client.get(\"#{base_url}/tickers/search\") do |req|\n        req.params[\"name\"] = symbol\n        req.params[\"dataset\"] = \"limited\"\n        req.params[\"country_code\"] = country_code if country_code.present?\n        # Synth uses mic_code, which encompasses both exchange_mic AND exchange_operating_mic (union)\n        req.params[\"mic_code\"] = exchange_operating_mic if exchange_operating_mic.present?\n        req.params[\"limit\"] = 25\n      end\n\n      parsed = JSON.parse(response.body)\n\n      parsed.dig(\"data\").map do |security|\n        Security.new(\n          symbol: security.dig(\"symbol\"),\n          name: security.dig(\"name\"),\n          logo_url: security.dig(\"logo_url\"),\n          exchange_operating_mic: security.dig(\"exchange\", \"operating_mic_code\"),\n          country_code: security.dig(\"exchange\", \"country_code\")\n        )\n      end\n    end\n  end\n\n  def fetch_security_info(symbol:, exchange_operating_mic:)\n    with_provider_response do\n      response = client.get(\"#{base_url}/tickers/#{symbol}\") do |req|\n        req.params[\"operating_mic\"] = exchange_operating_mic\n      end\n\n      data = JSON.parse(response.body).dig(\"data\")\n\n      SecurityInfo.new(\n        symbol: symbol,\n        name: data.dig(\"name\"),\n        links: data.dig(\"links\"),\n        logo_url: data.dig(\"logo_url\"),\n        description: data.dig(\"description\"),\n        kind: data.dig(\"kind\"),\n        exchange_operating_mic: exchange_operating_mic\n      )\n    end\n  end\n\n  def fetch_security_price(symbol:, exchange_operating_mic: nil, date:)\n    with_provider_response do\n      historical_data = fetch_security_prices(symbol:, exchange_operating_mic:, start_date: date, end_date: date)\n\n      raise ProviderError, \"No prices found for security #{symbol} on date #{date}\" if historical_data.data.empty?\n\n      historical_data.data.first\n    end\n  end\n\n  def fetch_security_prices(symbol:, exchange_operating_mic: nil, start_date:, end_date:)\n    with_provider_response do\n      params = {\n        start_date: start_date,\n        end_date: end_date,\n        operating_mic_code: exchange_operating_mic\n      }.compact\n\n      data = paginate(\n        \"#{base_url}/tickers/#{symbol}/open-close\",\n        params\n      ) do |body|\n        body.dig(\"prices\")\n      end\n\n      currency = data.first_page.dig(\"currency\")\n      exchange_operating_mic = data.first_page.dig(\"exchange\", \"operating_mic_code\")\n\n      data.paginated.map do |price|\n        date = price.dig(\"date\")\n        price = price.dig(\"close\") || price.dig(\"open\")\n\n        if date.nil? || price.nil?\n          Rails.logger.warn(\"#{self.class.name} returned invalid price data for security #{symbol} on: #{date}.  Price data: #{price.inspect}\")\n          Sentry.capture_exception(InvalidSecurityPriceError.new(\"#{self.class.name} returned invalid security price data\"), level: :warning) do |scope|\n            scope.set_context(\"security\", { symbol: symbol, date: date })\n          end\n\n          next\n        end\n\n        Price.new(\n          symbol: symbol,\n          date: date.to_date,\n          price: price,\n          currency: currency,\n          exchange_operating_mic: exchange_operating_mic\n        )\n      end.compact\n    end\n  end\n\n  private\n    attr_reader :api_key\n\n    def base_url\n      ENV[\"SYNTH_URL\"] || \"https://api.synthfinance.com\"\n    end\n\n    def app_name\n      \"maybe_app\"\n    end\n\n    def app_type\n      Rails.application.config.app_mode\n    end\n\n    def client\n      @client ||= Faraday.new(url: base_url) do |faraday|\n        faraday.request(:retry, {\n          max: 2,\n          interval: 0.05,\n          interval_randomness: 0.5,\n          backoff_factor: 2\n        })\n\n        faraday.response :raise_error\n        faraday.headers[\"Authorization\"] = \"Bearer #{api_key}\"\n        faraday.headers[\"X-Source\"] = app_name\n        faraday.headers[\"X-Source-Type\"] = app_type\n      end\n    end\n\n    def fetch_page(url, page, params = {})\n      client.get(url, params.merge(page: page))\n    end\n\n    def paginate(url, params = {})\n      results = []\n      page = 1\n      current_page = 0\n      total_pages = 1\n      first_page = nil\n\n      while current_page < total_pages\n        response = fetch_page(url, page, params)\n\n        body = JSON.parse(response.body)\n        first_page = body unless first_page\n        page_results = yield(body)\n        results.concat(page_results)\n\n        current_page = body.dig(\"paging\", \"current_page\")\n        total_pages = body.dig(\"paging\", \"total_pages\")\n\n        page += 1\n      end\n\n      PaginatedData.new(\n        paginated: results,\n        first_page: first_page,\n        total_pages: total_pages\n      )\n    end\nend\n"
  },
  {
    "path": "app/models/provider.rb",
    "content": "class Provider\n  Response = Data.define(:success?, :data, :error)\n\n  class Error < StandardError\n    attr_reader :details\n\n    def initialize(message, details: nil)\n      super(message)\n      @details = details\n    end\n\n    def as_json\n      {\n        message: message,\n        details: details\n      }\n    end\n  end\n\n  private\n    PaginatedData = Data.define(:paginated, :first_page, :total_pages)\n    UsageData = Data.define(:used, :limit, :utilization, :plan)\n\n    def with_provider_response(error_transformer: nil, &block)\n      data = yield\n\n      Response.new(\n        success?: true,\n        data: data,\n        error: nil,\n      )\n    rescue => error\n      transformed_error = if error_transformer\n        error_transformer.call(error)\n      else\n        default_error_transformer(error)\n      end\n\n      Response.new(\n        success?: false,\n        data: nil,\n        error: transformed_error\n      )\n    end\n\n    # Override to set class-level error transformation for methods using `with_provider_response`\n    def default_error_transformer(error)\n      if error.is_a?(Faraday::Error)\n        self.class::Error.new(\n          error.message,\n          details: error.response&.dig(:body),\n        )\n      else\n        self.class::Error.new(error.message)\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/provider_merchant.rb",
    "content": "class ProviderMerchant < Merchant\n  enum :source, { plaid: \"plaid\", synth: \"synth\", ai: \"ai\" }\n\n  validates :name, uniqueness: { scope: [ :source ] }\n  validates :source, presence: true\nend\n"
  },
  {
    "path": "app/models/rejected_transfer.rb",
    "content": "class RejectedTransfer < ApplicationRecord\n  belongs_to :inflow_transaction, class_name: \"Transaction\"\n  belongs_to :outflow_transaction, class_name: \"Transaction\"\nend\n"
  },
  {
    "path": "app/models/rule/action.rb",
    "content": "class Rule::Action < ApplicationRecord\n  belongs_to :rule, touch: true\n\n  validates :action_type, presence: true\n\n  def apply(resource_scope, ignore_attribute_locks: false)\n    executor.execute(resource_scope, value: value, ignore_attribute_locks: ignore_attribute_locks)\n  end\n\n  def options\n    executor.options\n  end\n\n  def value_display\n    if value.present?\n      if options\n        options.find { |option| option.last == value }&.first\n      else\n        \"\"\n      end\n    else\n      \"\"\n    end\n  end\n\n  def executor\n    rule.registry.get_executor!(action_type)\n  end\nend\n"
  },
  {
    "path": "app/models/rule/action_executor/auto_categorize.rb",
    "content": "class Rule::ActionExecutor::AutoCategorize < Rule::ActionExecutor\n  def label\n    if rule.family.self_hoster?\n      \"Auto-categorize transactions with AI ($$)\"\n    else\n      \"Auto-categorize transactions\"\n    end\n  end\n\n  def execute(transaction_scope, value: nil, ignore_attribute_locks: false)\n    enrichable_transactions = transaction_scope.enrichable(:category_id)\n\n    if enrichable_transactions.empty?\n      Rails.logger.info(\"No transactions to auto-categorize for #{rule.id}\")\n      return\n    end\n\n    enrichable_transactions.in_batches(of: 20).each_with_index do |transactions, idx|\n      Rails.logger.info(\"Scheduling auto-categorization for batch #{idx + 1} of #{enrichable_transactions.count}\")\n      rule.family.auto_categorize_transactions_later(transactions)\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/rule/action_executor/auto_detect_merchants.rb",
    "content": "class Rule::ActionExecutor::AutoDetectMerchants < Rule::ActionExecutor\n  def label\n    if rule.family.self_hoster?\n      \"Auto-detect merchants with AI ($$)\"\n    else\n      \"Auto-detect merchants\"\n    end\n  end\n\n  def execute(transaction_scope, value: nil, ignore_attribute_locks: false)\n    enrichable_transactions = transaction_scope.enrichable(:merchant_id)\n\n    if enrichable_transactions.empty?\n      Rails.logger.info(\"No transactions to auto-detect merchants for #{rule.id}\")\n      return\n    end\n\n    enrichable_transactions.in_batches(of: 20).each_with_index do |transactions, idx|\n      Rails.logger.info(\"Scheduling auto-merchant-enrichment for batch #{idx + 1} of #{enrichable_transactions.count}\")\n      rule.family.auto_detect_transaction_merchants_later(transactions)\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/rule/action_executor/set_transaction_category.rb",
    "content": "class Rule::ActionExecutor::SetTransactionCategory < Rule::ActionExecutor\n  def type\n    \"select\"\n  end\n\n  def options\n    family.categories.pluck(:name, :id)\n  end\n\n  def execute(transaction_scope, value: nil, ignore_attribute_locks: false)\n    category = family.categories.find_by_id(value)\n\n    scope = transaction_scope\n\n    unless ignore_attribute_locks\n      scope = scope.enrichable(:category_id)\n    end\n\n    scope.each do |txn|\n      txn.enrich_attribute(\n        :category_id,\n        category.id,\n        source: \"rule\"\n      )\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/rule/action_executor/set_transaction_merchant.rb",
    "content": "class Rule::ActionExecutor::SetTransactionMerchant < Rule::ActionExecutor\n  def type\n    \"select\"\n  end\n\n  def options\n    family.merchants.pluck(:name, :id)\n  end\n\n  def execute(transaction_scope, value: nil, ignore_attribute_locks: false)\n    merchant = family.merchants.find_by_id(value)\n    return unless merchant\n\n    scope = transaction_scope\n    unless ignore_attribute_locks\n      scope = scope.enrichable(:merchant_id)\n    end\n\n    scope.each do |txn|\n      txn.enrich_attribute(\n        :merchant_id,\n        merchant.id,\n        source: \"rule\"\n      )\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/rule/action_executor/set_transaction_name.rb",
    "content": "class Rule::ActionExecutor::SetTransactionName < Rule::ActionExecutor\n  def type\n    \"text\"\n  end\n\n  def options\n    nil\n  end\n\n  def execute(transaction_scope, value: nil, ignore_attribute_locks: false)\n    return if value.blank?\n\n    scope = transaction_scope\n    unless ignore_attribute_locks\n      scope = scope.enrichable(:name)\n    end\n\n    scope.each do |txn|\n      txn.entry.enrich_attribute(\n        :name,\n        value,\n        source: \"rule\"\n      )\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/rule/action_executor/set_transaction_tags.rb",
    "content": "class Rule::ActionExecutor::SetTransactionTags < Rule::ActionExecutor\n  def type\n    \"select\"\n  end\n\n  def options\n    family.tags.pluck(:name, :id)\n  end\n\n  def execute(transaction_scope, value: nil, ignore_attribute_locks: false)\n    tag = family.tags.find_by_id(value)\n\n    scope = transaction_scope\n\n    unless ignore_attribute_locks\n      scope = scope.enrichable(:tag_ids)\n    end\n\n    rows = scope.each do |txn|\n      txn.enrich_attribute(\n        :tag_ids,\n        [ tag.id ],\n        source: \"rule\"\n      )\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/rule/action_executor.rb",
    "content": "class Rule::ActionExecutor\n  TYPES = [ \"select\", \"function\", \"text\" ]\n\n  def initialize(rule)\n    @rule = rule\n  end\n\n  def key\n    self.class.name.demodulize.underscore\n  end\n\n  def label\n    key.humanize\n  end\n\n  def type\n    \"function\"\n  end\n\n  def options\n    nil\n  end\n\n  def execute(scope, value: nil, ignore_attribute_locks: false)\n    raise NotImplementedError, \"Action executor #{self.class.name} must implement #execute\"\n  end\n\n  def as_json\n    {\n      type: type,\n      key: key,\n      label: label,\n      options: options\n    }\n  end\n\n  private\n    attr_reader :rule\n\n    def family\n      rule.family\n    end\nend\n"
  },
  {
    "path": "app/models/rule/condition.rb",
    "content": "class Rule::Condition < ApplicationRecord\n  belongs_to :rule, touch: true, optional: -> { where.not(parent_id: nil) }\n  belongs_to :parent, class_name: \"Rule::Condition\", optional: true, inverse_of: :sub_conditions\n\n  has_many :sub_conditions, class_name: \"Rule::Condition\", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent\n\n  validates :condition_type, presence: true\n  validates :operator, presence: true\n  validates :value, presence: true, unless: -> { compound? }\n\n  accepts_nested_attributes_for :sub_conditions, allow_destroy: true\n\n  # We don't store rule_id on sub_conditions, so \"walk up\" to the parent rule\n  def rule\n    parent&.rule || super\n  end\n\n  def compound?\n    condition_type == \"compound\"\n  end\n\n  def apply(scope)\n    if compound?\n      build_compound_scope(scope)\n    else\n      filter.apply(scope, operator, value)\n    end\n  end\n\n  def prepare(scope)\n    if compound?\n      sub_conditions.reduce(scope) { |s, sub| sub.prepare(s) }\n    else\n      filter.prepare(scope)\n    end\n  end\n\n  def value_display\n    if value.present?\n      if options\n        options.find { |option| option.last == value }&.first\n      else\n        value\n      end\n    else\n      \"\"\n    end\n  end\n\n  def options\n    filter.options\n  end\n\n  def operators\n    filter.operators\n  end\n\n  def filter\n    rule.registry.get_filter!(condition_type)\n  end\n\n  private\n    def build_compound_scope(scope)\n      if operator == \"or\"\n        combined_scope = sub_conditions\n          .map { |sub| sub.apply(scope) }\n          .reduce { |acc, s| acc.or(s) }\n\n        combined_scope || scope\n      else\n        sub_conditions.reduce(scope) { |s, sub| sub.apply(s) }\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/rule/condition_filter/transaction_amount.rb",
    "content": "class Rule::ConditionFilter::TransactionAmount < Rule::ConditionFilter\n  def type\n    \"number\"\n  end\n\n  def prepare(scope)\n    scope.with_entry\n  end\n\n  def apply(scope, operator, value)\n    expression = build_sanitized_where_condition(\"ABS(entries.amount)\", operator, value.to_d)\n    scope.where(expression)\n  end\nend\n"
  },
  {
    "path": "app/models/rule/condition_filter/transaction_merchant.rb",
    "content": "class Rule::ConditionFilter::TransactionMerchant < Rule::ConditionFilter\n  def type\n    \"select\"\n  end\n\n  def options\n    family.assigned_merchants.pluck(:name, :id)\n  end\n\n  def prepare(scope)\n    scope.left_joins(:merchant)\n  end\n\n  def apply(scope, operator, value)\n    expression = build_sanitized_where_condition(\"merchants.id\", operator, value)\n    scope.where(expression)\n  end\nend\n"
  },
  {
    "path": "app/models/rule/condition_filter/transaction_name.rb",
    "content": "class Rule::ConditionFilter::TransactionName < Rule::ConditionFilter\n  def prepare(scope)\n    scope.with_entry\n  end\n\n  def apply(scope, operator, value)\n    expression = build_sanitized_where_condition(\"entries.name\", operator, value)\n    scope.where(expression)\n  end\nend\n"
  },
  {
    "path": "app/models/rule/condition_filter.rb",
    "content": "class Rule::ConditionFilter\n  UnsupportedOperatorError = Class.new(StandardError)\n\n  TYPES = [ \"text\", \"number\", \"select\" ]\n\n  OPERATORS_MAP = {\n    \"text\" => [ [ \"Contains\", \"like\" ], [ \"Equal to\", \"=\" ] ],\n    \"number\" => [ [ \"Greater than\", \">\" ], [ \"Greater or equal to\", \">=\" ], [ \"Less than\", \"<\" ], [ \"Less than or equal to\", \"<=\" ], [ \"Is equal to\", \"=\" ] ],\n    \"select\" => [ [ \"Equal to\", \"=\" ] ]\n  }\n\n  def initialize(rule)\n    @rule = rule\n  end\n\n  def type\n    \"text\"\n  end\n\n  def number_step\n    family_currency = Money::Currency.new(family.currency)\n    family_currency.step\n  end\n\n  def key\n    self.class.name.demodulize.underscore\n  end\n\n  def label\n    key.humanize\n  end\n\n  def options\n    nil\n  end\n\n  def operators\n    OPERATORS_MAP.dig(type)\n  end\n\n  # Matchers can prepare the scope with joins by implementing this method\n  def prepare(scope)\n    scope\n  end\n\n  # Applies the condition to the scope\n  def apply(scope, operator, value)\n    raise NotImplementedError, \"Condition #{self.class.name} must implement #apply\"\n  end\n\n  def as_json\n    {\n      type: type,\n      key: key,\n      label: label,\n      operators: operators,\n      options: options,\n      number_step: number_step\n    }\n  end\n\n  private\n    attr_reader :rule\n\n    def family\n      rule.family\n    end\n\n    def build_sanitized_where_condition(field, operator, value)\n      sanitized_value = operator == \"like\" ? \"%#{ActiveRecord::Base.sanitize_sql_like(value)}%\" : value\n\n      ActiveRecord::Base.sanitize_sql_for_conditions([\n        \"#{field} #{sanitize_operator(operator)} ?\",\n        sanitized_value\n      ])\n    end\n\n    def sanitize_operator(operator)\n      raise UnsupportedOperatorError, \"Unsupported operator: #{operator} for type: #{type}\" unless operators.map(&:last).include?(operator)\n\n      if operator == \"like\"\n        \"ILIKE\"\n      else\n        operator\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/rule/registry/transaction_resource.rb",
    "content": "class Rule::Registry::TransactionResource < Rule::Registry\n  def resource_scope\n    family.transactions.visible.with_entry.where(entry: { date: rule.effective_date.. })\n  end\n\n  def condition_filters\n    [\n      Rule::ConditionFilter::TransactionName.new(rule),\n      Rule::ConditionFilter::TransactionAmount.new(rule),\n      Rule::ConditionFilter::TransactionMerchant.new(rule)\n    ]\n  end\n\n  def action_executors\n    enabled_executors = [\n      Rule::ActionExecutor::SetTransactionCategory.new(rule),\n      Rule::ActionExecutor::SetTransactionTags.new(rule),\n      Rule::ActionExecutor::SetTransactionMerchant.new(rule),\n      Rule::ActionExecutor::SetTransactionName.new(rule)\n    ]\n\n    if ai_enabled?\n      enabled_executors << Rule::ActionExecutor::AutoCategorize.new(rule)\n      enabled_executors << Rule::ActionExecutor::AutoDetectMerchants.new(rule)\n    end\n\n    enabled_executors\n  end\n\n  private\n    def ai_enabled?\n      Provider::Registry.get_provider(:openai).present?\n    end\nend\n"
  },
  {
    "path": "app/models/rule/registry.rb",
    "content": "class Rule::Registry\n  UnsupportedActionError = Class.new(StandardError)\n  UnsupportedConditionError = Class.new(StandardError)\n\n  def initialize(rule)\n    @rule = rule\n  end\n\n  def resource_scope\n    raise NotImplementedError, \"#{self.class.name} must implement #resource_scope\"\n  end\n\n  def condition_filters\n    []\n  end\n\n  def action_executors\n    []\n  end\n\n  def get_filter!(key)\n    filter = condition_filters.find { |filter| filter.key == key }\n    raise UnsupportedConditionError, \"Unsupported condition type: #{key}\" unless filter\n    filter\n  end\n\n  def get_executor!(key)\n    executor = action_executors.find { |executor| executor.key == key }\n    raise UnsupportedActionError, \"Unsupported action type: #{key}\" unless executor\n    executor\n  end\n\n  def as_json\n    {\n      filters: condition_filters.map(&:as_json),\n      executors: action_executors.map(&:as_json)\n    }\n  end\n\n  private\n    attr_reader :rule\n\n    def family\n      rule.family\n    end\nend\n"
  },
  {
    "path": "app/models/rule.rb",
    "content": "class Rule < ApplicationRecord\n  UnsupportedResourceTypeError = Class.new(StandardError)\n\n  belongs_to :family\n  has_many :conditions, dependent: :destroy\n  has_many :actions, dependent: :destroy\n\n  accepts_nested_attributes_for :conditions, allow_destroy: true\n  accepts_nested_attributes_for :actions, allow_destroy: true\n\n  before_validation :normalize_name\n\n  validates :resource_type, presence: true\n  validates :name, length: { minimum: 1 }, allow_nil: true\n  validate :no_nested_compound_conditions\n\n  # Every rule must have at least 1 action\n  validate :min_actions\n  validate :no_duplicate_actions\n\n  def action_executors\n    registry.action_executors\n  end\n\n  def condition_filters\n    registry.condition_filters\n  end\n\n  def registry\n    @registry ||= case resource_type\n    when \"transaction\"\n      Rule::Registry::TransactionResource.new(self)\n    else\n      raise UnsupportedResourceTypeError, \"Unsupported resource type: #{resource_type}\"\n    end\n  end\n\n  def affected_resource_count\n    matching_resources_scope.count\n  end\n\n  def apply(ignore_attribute_locks: false)\n    actions.each do |action|\n      action.apply(matching_resources_scope, ignore_attribute_locks: ignore_attribute_locks)\n    end\n  end\n\n  def apply_later(ignore_attribute_locks: false)\n    RuleJob.perform_later(self, ignore_attribute_locks: ignore_attribute_locks)\n  end\n\n  def primary_condition_title\n    return \"No conditions\" if conditions.none?\n\n    first_condition = conditions.first\n    if first_condition.compound? && first_condition.sub_conditions.any?\n      first_sub_condition = first_condition.sub_conditions.first\n      \"If #{first_sub_condition.filter.label.downcase} #{first_sub_condition.operator} #{first_sub_condition.value_display}\"\n    else\n      \"If #{first_condition.filter.label.downcase} #{first_condition.operator} #{first_condition.value_display}\"\n    end\n  end\n\n  private\n    def matching_resources_scope\n      scope = registry.resource_scope\n\n      # 1. Prepare the query with joins required by conditions\n      conditions.each do |condition|\n        scope = condition.prepare(scope)\n      end\n\n      # 2. Apply the conditions to the query\n      conditions.each do |condition|\n        scope = condition.apply(scope)\n      end\n\n      scope\n    end\n\n    def min_actions\n      if actions.reject(&:marked_for_destruction?).empty?\n        errors.add(:base, \"must have at least one action\")\n      end\n    end\n\n    def no_duplicate_actions\n      action_types = actions.reject(&:marked_for_destruction?).map(&:action_type)\n\n      errors.add(:base, \"Rule cannot have duplicate actions #{action_types.inspect}\") if action_types.uniq.count != action_types.count\n    end\n\n    # Validation: To keep rules simple and easy to understand, we don't allow nested compound conditions.\n    def no_nested_compound_conditions\n      return true if conditions.none? { |condition| condition.compound? }\n\n      conditions.each do |condition|\n        if condition.compound?\n          if condition.sub_conditions.any? { |sub_condition| sub_condition.compound? }\n            errors.add(:base, \"Compound conditions cannot be nested\")\n          end\n        end\n      end\n    end\n\n    def normalize_name\n      self.name = nil if name.is_a?(String) && name.strip.empty?\n    end\nend\n"
  },
  {
    "path": "app/models/security/health_checker.rb",
    "content": "# There are hundreds of thousands of market securities that Maybe must handle.\n# Due to the always-changing nature of the market, the health checker is responsible\n# for periodically checking active securities to ensure we can still fetch prices for them.\n#\n# Each security goes through some basic health checks.  If failed, this class is responsible for:\n# - Marking failed attempts and incrementing the failed attempts counter\n# - Marking the security offline if enough consecutive failed checks occur\n# - When we move a security \"offline\", delete all prices for that security as we assume they are bad data\n#\n# The health checker is run daily through SecurityHealthCheckJob (see config/schedule.yml), but not all\n# securities will be checked every day (we run in batches)\nclass Security::HealthChecker\n  MAX_CONSECUTIVE_FAILURES = 5\n  HEALTH_CHECK_INTERVAL = 7.days\n  DAILY_BATCH_SIZE = 1000\n\n  class << self\n    def check_all\n      # No daily limit for unchecked securities (they are prioritized)\n      never_checked_scope.find_each do |security|\n        new(security).run_check\n      end\n\n      # Daily limit for checked securities\n      due_for_check_scope.limit(DAILY_BATCH_SIZE).each do |security|\n        new(security).run_check\n      end\n    end\n\n    private\n      # If a security has never had a health check, we prioritize it, regardless of batch size\n      def never_checked_scope\n        Security.where(last_health_check_at: nil)\n      end\n\n      # Any securities not checked for 30 days are due\n      # We only process the batch size, which means some \"due\" securities will not be checked today\n      # This is by design, to prevent all securities from coming due at the same time\n      def due_for_check_scope\n        Security.where(last_health_check_at: ..HEALTH_CHECK_INTERVAL.ago)\n                .order(last_health_check_at: :asc)\n      end\n  end\n\n  def initialize(security)\n    @security = security\n  end\n\n  def run_check\n    Rails.logger.info(\"Running health check for #{security.ticker}\")\n\n    if latest_provider_price\n      handle_success\n    else\n      handle_failure\n    end\n  rescue => e\n    Sentry.capture_exception(e) do |scope|\n      scope.set_tags(security_id: @security.id)\n    end\n  ensure\n    security.update!(last_health_check_at: Time.current)\n  end\n\n  private\n    attr_reader :security\n\n    def provider\n      Security.provider\n    end\n\n    def latest_provider_price\n      return nil unless provider.present?\n\n      response = provider.fetch_security_price(\n        symbol: security.ticker,\n        exchange_operating_mic: security.exchange_operating_mic,\n        date: Date.current\n      )\n\n      return nil unless response.success?\n\n      response.data.price\n    end\n\n    # On success, reset any failure counters and ensure it is \"online\"\n    def handle_success\n      security.update!(\n        offline: false,\n        failed_fetch_count: 0,\n        failed_fetch_at: nil\n      )\n    end\n\n    def handle_failure\n      new_failure_count = security.failed_fetch_count.to_i + 1\n      new_failure_at = Time.current\n\n      if new_failure_count > MAX_CONSECUTIVE_FAILURES\n        convert_to_offline_security!\n      else\n        security.update!(\n          failed_fetch_count: new_failure_count,\n          failed_fetch_at: new_failure_at\n        )\n      end\n    end\n\n    # The \"offline\" state tells our MarketDataImporter (daily cron) to skip this security when fetching prices\n    def convert_to_offline_security!\n      Security.transaction do\n        security.update!(\n          offline: true,\n          failed_fetch_count: MAX_CONSECUTIVE_FAILURES + 1,\n          failed_fetch_at: Time.current\n        )\n        security.prices.delete_all\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/security/price/importer.rb",
    "content": "class Security::Price::Importer\n  MissingSecurityPriceError = Class.new(StandardError)\n  MissingStartPriceError    = Class.new(StandardError)\n\n  def initialize(security:, security_provider:, start_date:, end_date:, clear_cache: false)\n    @security          = security\n    @security_provider = security_provider\n    @start_date        = start_date\n    @end_date          = normalize_end_date(end_date)\n    @clear_cache       = clear_cache\n  end\n\n  # Constructs a daily series of prices for a single security over the date range.\n  # Returns the number of rows upserted.\n  def import_provider_prices\n    if !clear_cache && all_prices_exist?\n      Rails.logger.info(\"No new prices to sync for #{security.ticker} between #{start_date} and #{end_date}, skipping\")\n      return 0\n    end\n\n    if provider_prices.empty?\n      Rails.logger.warn(\"Could not fetch prices for #{security.ticker} between #{start_date} and #{end_date} because provider returned no prices\")\n      return 0\n    end\n\n    prev_price_value = start_price_value\n\n    unless prev_price_value.present?\n      Rails.logger.error(\"Could not find a start price for #{security.ticker} on or before #{start_date}\")\n\n      Sentry.capture_exception(MissingStartPriceError.new(\"Could not determine start price for ticker\")) do |scope|\n        scope.set_tags(security_id: security.id)\n        scope.set_context(\"security\", {\n          id: security.id,\n          start_date: start_date\n        })\n      end\n\n      return 0\n    end\n\n    gapfilled_prices = effective_start_date.upto(end_date).map do |date|\n      db_price_value       = db_prices[date]&.price\n      provider_price_value = provider_prices[date]&.price\n      provider_currency    = provider_prices[date]&.currency\n\n      chosen_price = if clear_cache\n        provider_price_value || db_price_value   # overwrite when possible\n      else\n        db_price_value || provider_price_value   # fill gaps\n      end\n\n      # Gap-fill using LOCF (last observation carried forward)\n      chosen_price ||= prev_price_value\n      prev_price_value = chosen_price\n\n      {\n        security_id: security.id,\n        date:        date,\n        price:       chosen_price,\n        currency:    provider_currency || prev_price_currency || db_price_currency || \"USD\"\n      }\n    end\n\n    upsert_rows(gapfilled_prices)\n  end\n\n  private\n    attr_reader :security, :security_provider, :start_date, :end_date, :clear_cache\n\n    def provider_prices\n      @provider_prices ||= begin\n        provider_fetch_start_date = effective_start_date - 5.days\n\n        response = security_provider.fetch_security_prices(\n          symbol: security.ticker,\n          exchange_operating_mic: security.exchange_operating_mic,\n          start_date: provider_fetch_start_date,\n          end_date:   end_date\n        )\n\n        if response.success?\n          response.data.index_by(&:date)\n        else\n          Rails.logger.warn(\"#{security_provider.class.name} could not fetch prices for #{security.ticker} between #{provider_fetch_start_date} and #{end_date}. Provider error: #{response.error.message}\")\n          Sentry.capture_exception(MissingSecurityPriceError.new(\"Could not fetch prices for ticker\"), level: :warning) do |scope|\n            scope.set_tags(security_id: security.id)\n            scope.set_context(\"security\", { id: security.id, start_date: start_date, end_date: end_date })\n          end\n\n          {}\n        end\n      end\n    end\n\n    def db_prices\n      @db_prices ||= Security::Price.where(security_id: security.id, date: start_date..end_date)\n                                    .order(:date)\n                                    .to_a\n                                    .index_by(&:date)\n    end\n\n    def all_prices_exist?\n      db_prices.count == expected_count\n    end\n\n    def expected_count\n      (start_date..end_date).count\n    end\n\n    # Skip over ranges that already exist unless clearing cache\n    def effective_start_date\n      return start_date if clear_cache\n\n      (start_date..end_date).detect { |d| !db_prices.key?(d) } || end_date\n    end\n\n    def start_price_value\n      provider_price_value = provider_prices.select { |date, _| date <= start_date }\n                                            .max_by { |date, _| date }\n                                            &.last&.price\n      db_price_value       = db_prices[start_date]&.price\n      provider_price_value || db_price_value\n    end\n\n    def upsert_rows(rows)\n      batch_size         = 200\n      total_upsert_count = 0\n\n      rows.each_slice(batch_size) do |batch|\n        ids = Security::Price.upsert_all(\n          batch,\n          unique_by: %i[security_id date currency],\n          returning: [ \"id\" ]\n        )\n        total_upsert_count += ids.count\n      end\n\n      total_upsert_count\n    end\n\n    def db_price_currency\n      db_prices.values.first&.currency\n    end\n\n    def prev_price_currency\n      @prev_price_currency ||= provider_prices.values.first&.currency\n    end\n\n    # Clamp to today (EST) so we never call our price API for a future date (our API is in EST/EDT timezone)\n    def normalize_end_date(requested_end_date)\n      today_est = Date.current.in_time_zone(\"America/New_York\").to_date\n      [ requested_end_date, today_est ].min\n    end\nend\n"
  },
  {
    "path": "app/models/security/price.rb",
    "content": "class Security::Price < ApplicationRecord\n  belongs_to :security\n\n  validates :date, :price, :currency, presence: true\n  validates :date, uniqueness: { scope: %i[security_id currency] }\nend\n"
  },
  {
    "path": "app/models/security/provided.rb",
    "content": "module Security::Provided\n  extend ActiveSupport::Concern\n\n  SecurityInfoMissingError = Class.new(StandardError)\n\n  class_methods do\n    def provider\n      registry = Provider::Registry.for_concept(:securities)\n      registry.get_provider(:synth)\n    end\n\n    def search_provider(symbol, country_code: nil, exchange_operating_mic: nil)\n      return [] if provider.nil? || symbol.blank?\n\n      params = {\n        country_code: country_code,\n        exchange_operating_mic: exchange_operating_mic\n      }.compact_blank\n\n      response = provider.search_securities(symbol, **params)\n\n      if response.success?\n        response.data.map do |provider_security|\n          # Need to map to domain model so Combobox can display via to_combobox_option\n          Security.new(\n            ticker: provider_security.symbol,\n            name: provider_security.name,\n            logo_url: provider_security.logo_url,\n            exchange_operating_mic: provider_security.exchange_operating_mic,\n            country_code: provider_security.country_code\n          )\n        end\n      else\n        []\n      end\n    end\n  end\n\n  def find_or_fetch_price(date: Date.current, cache: true)\n    price = prices.find_by(date: date)\n\n    return price if price.present?\n\n    # Make sure we have a data provider before fetching\n    return nil unless provider.present?\n    response = provider.fetch_security_price(\n      symbol: ticker,\n      exchange_operating_mic: exchange_operating_mic,\n      date: date\n    )\n\n    return nil unless response.success? # Provider error\n\n    price = response.data\n    Security::Price.find_or_create_by!(\n      security_id: self.id,\n      date: price.date,\n      price: price.price,\n      currency: price.currency\n    ) if cache\n    price\n  end\n\n  def import_provider_details(clear_cache: false)\n    unless provider.present?\n      Rails.logger.warn(\"No provider configured for Security.import_provider_details\")\n      return\n    end\n\n    if self.name.present? && self.logo_url.present? && !clear_cache\n      return\n    end\n\n    response = provider.fetch_security_info(\n      symbol: ticker,\n      exchange_operating_mic: exchange_operating_mic\n    )\n\n    if response.success?\n      update(\n        name: response.data.name,\n        logo_url: response.data.logo_url,\n      )\n    else\n      Rails.logger.warn(\"Failed to fetch security info for #{ticker} from #{provider.class.name}: #{response.error.message}\")\n      Sentry.capture_exception(SecurityInfoMissingError.new(\"Failed to get security info\"), level: :warning) do |scope|\n        scope.set_tags(security_id: self.id)\n        scope.set_context(\"security\", { id: self.id, provider_error: response.error.message })\n      end\n    end\n  end\n\n  def import_provider_prices(start_date:, end_date:, clear_cache: false)\n    unless provider.present?\n      Rails.logger.warn(\"No provider configured for Security.import_provider_prices\")\n      return 0\n    end\n\n    Security::Price::Importer.new(\n      security: self,\n      security_provider: provider,\n      start_date: start_date,\n      end_date: end_date,\n      clear_cache: clear_cache\n    ).import_provider_prices\n  end\n\n  private\n    def provider\n      self.class.provider\n    end\nend\n"
  },
  {
    "path": "app/models/security/resolver.rb",
    "content": "class Security::Resolver\n  def initialize(symbol, exchange_operating_mic: nil, country_code: nil)\n    @symbol = validate_symbol!(symbol)\n    @exchange_operating_mic = exchange_operating_mic\n    @country_code = country_code\n  end\n\n  # Attempts several paths to resolve a security:\n  # 1. Exact match in DB\n  # 2. Search provider for an exact match\n  # 3. Search provider for close match, ranked by relevance\n  # 4. Create offline security if no match is found in either DB or provider\n  def resolve\n    return nil if symbol.blank?\n\n    exact_match_from_db ||\n      exact_match_from_provider ||\n      close_match_from_provider ||\n      offline_security\n  end\n\n  private\n    attr_reader :symbol, :exchange_operating_mic, :country_code\n\n    def validate_symbol!(symbol)\n      raise ArgumentError, \"Symbol is required and cannot be blank\" if symbol.blank?\n      symbol.strip.upcase\n    end\n\n    def offline_security\n      security = Security.find_or_initialize_by(\n        ticker: symbol,\n        exchange_operating_mic: exchange_operating_mic,\n      )\n\n      security.assign_attributes(\n        country_code: country_code,\n        offline: true # This tells us that we shouldn't try to fetch prices later\n      )\n\n      security.save!\n\n      security\n    end\n\n    def exact_match_from_db\n      Security.find_by(\n        {\n          ticker: symbol,\n          exchange_operating_mic: exchange_operating_mic,\n          country_code: country_code.presence\n        }.compact\n      )\n    end\n\n    # If provided a ticker + exchange (and optionally, a country code), we can find exact matches\n    def exact_match_from_provider\n      # Without an exchange, we can never know if we have an exact match\n      return nil unless exchange_operating_mic.present?\n\n      match = provider_search_result.find do |s|\n        ticker_matches = s.ticker.upcase.to_s == symbol.upcase.to_s\n        exchange_matches = s.exchange_operating_mic.upcase.to_s == exchange_operating_mic.upcase.to_s\n\n        if country_code && exchange_operating_mic\n          ticker_matches && exchange_matches && s.country_code.upcase.to_s == country_code.upcase.to_s\n        else\n          ticker_matches && exchange_matches\n        end\n      end\n\n      return nil unless match\n\n      find_or_create_provider_match!(match)\n    end\n\n    def close_match_from_provider\n      filtered_candidates = provider_search_result\n\n      # If a country code is specified, we MUST find a match with the same code\n      if country_code.present?\n        filtered_candidates = filtered_candidates.select { |s| s.country_code.upcase.to_s == country_code.upcase.to_s }\n      end\n\n      # 1. Prefer exact exchange_operating_mic matches (if one was provided)\n      # 2. Rank by country relevance (lower index in the list is more relevant)\n      # 3. Rank by exchange_operating_mic relevance (lower index in the list is more relevant)\n      sorted_candidates = filtered_candidates.sort_by do |s|\n        [\n          exchange_operating_mic.present? && s.exchange_operating_mic.upcase.to_s == exchange_operating_mic.upcase.to_s ? 0 : 1,\n          sorted_country_codes_by_relevance.index(s.country_code&.upcase.to_s) || sorted_country_codes_by_relevance.length,\n          sorted_exchange_operating_mics_by_relevance.index(s.exchange_operating_mic&.upcase.to_s) || sorted_exchange_operating_mics_by_relevance.length\n        ]\n      end\n\n      match = sorted_candidates.first\n\n      return nil unless match\n\n      find_or_create_provider_match!(match)\n    end\n\n    def find_or_create_provider_match!(match)\n      security = Security.find_or_initialize_by(\n        ticker: match.ticker,\n        exchange_operating_mic: match.exchange_operating_mic,\n      )\n\n      security.country_code = match.country_code\n      security.save!\n\n      security\n    end\n\n    def provider_search_result\n      params = {\n        exchange_operating_mic: exchange_operating_mic,\n        country_code: country_code\n      }.compact_blank\n\n      @provider_search_result ||= Security.search_provider(symbol, **params)\n    end\n\n    # Non-exhaustive list of common country codes for help in choosing \"close\" matches\n    # These are generally sorted by market cap.\n    def sorted_country_codes_by_relevance\n      %w[US CN JP IN GB CA FR DE CH SA TW AU NL SE KR IE ES AE IT HK BR DK SG MX RU IL ID BE TH NO]\n    end\n\n    # Non-exhaustive list of common exchange operating MICs for help in choosing \"close\" matches\n    # This is very US-centric since our prices provider and user base is a majority US-based\n    def sorted_exchange_operating_mics_by_relevance\n      [\n        \"XNYS\",  # New York Stock Exchange\n        \"XNAS\",  # NASDAQ Stock Market\n        \"XOTC\",  # OTC Markets Group (OTC Link)\n        \"OTCM\",  # OTC Markets Group\n        \"OTCN\",  # OTC Bulletin Board\n        \"OTCI\",  # OTC International\n        \"OPRA\",  # Options Price Reporting Authority\n        \"MEMX\",  # Members Exchange\n        \"IEXA\",  # IEX All-Market\n        \"IEXG\",  # IEX Growth Market\n        \"EDXM\",  # Cboe EDGX Exchange (Equities)\n        \"XCME\",  # CME Group (Derivatives)\n        \"XCBT\",  # Chicago Board of Trade\n        \"XPUS\",  # Nasdaq PSX (U.S.)\n        \"XPSE\",  # Nasdaq PHLX (U.S.)\n        \"XTRD\",  # Nasdaq TRF (Trade Reporting Facility)\n        \"XTXD\",  # FINRA TRACE (Trade Reporting)\n        \"XARC\",  # NYSE Arca\n        \"XBOX\",  # BOX Options Exchange\n        \"XBXO\"  # BZX Options (Cboe)\n      ]\n    end\nend\n"
  },
  {
    "path": "app/models/security/synth_combobox_option.rb",
    "content": "class Security::SynthComboboxOption\n  include ActiveModel::Model\n\n  attr_accessor :symbol, :name, :logo_url, :exchange_operating_mic, :country_code\n\n  def id\n    \"#{symbol}|#{exchange_operating_mic}\" # submitted by combobox as value\n  end\n\n  def to_combobox_display\n    \"#{symbol} - #{name} (#{exchange_operating_mic})\" # shown in combobox input when selected\n  end\nend\n"
  },
  {
    "path": "app/models/security.rb",
    "content": "class Security < ApplicationRecord\n  include Provided\n\n  before_validation :upcase_symbols\n\n  has_many :trades, dependent: :nullify, class_name: \"Trade\"\n  has_many :prices, dependent: :destroy\n\n  validates :ticker, presence: true\n  validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false }\n\n  scope :online, -> { where(offline: false) }\n\n  def current_price\n    @current_price ||= find_or_fetch_price\n    return nil if @current_price.nil?\n    Money.new(@current_price.price, @current_price.currency)\n  end\n\n  def to_combobox_option\n    SynthComboboxOption.new(\n      symbol: ticker,\n      name: name,\n      logo_url: logo_url,\n      exchange_operating_mic: exchange_operating_mic,\n      country_code: country_code\n    )\n  end\n\n  private\n    def upcase_symbols\n      self.ticker = ticker.upcase\n      self.exchange_operating_mic = exchange_operating_mic.upcase if exchange_operating_mic.present?\n    end\nend\n"
  },
  {
    "path": "app/models/series.rb",
    "content": "class Series\n  # Behave like an Array whose elements are the `Value` structs stored in `values`\n  include Enumerable\n\n  # Forward any undefined method calls (e.g. `first`, `[]`, `map`) to `values`\n  delegate_missing_to :values\n\n  # Enumerable needs `#each`\n  def each(&block)\n    values.each(&block)\n  end\n\n  attr_reader :start_date, :end_date, :interval, :trend, :values, :favorable_direction\n\n  Value = Struct.new(\n    :date,\n    :date_formatted,\n    :value,\n    :trend,\n    keyword_init: true\n  )\n\n  class << self\n    def from_raw_values(values, interval: \"1 day\")\n      raise ArgumentError, \"Must be an array of at least 2 values\" unless values.size >= 2\n      raise ArgumentError, \"Must have date and value properties\" unless values.all? { |value| value.has_key?(:date) && value.has_key?(:value) }\n\n      ordered = values.sort_by { |value| value[:date] }\n      start_date = ordered.first[:date]\n      end_date = ordered.last[:date]\n\n      new(\n        start_date: start_date,\n        end_date: end_date,\n        interval: interval,\n        values: [ nil, *ordered ].each_cons(2).map do |prev_value, curr_value|\n          Value.new(\n            date: curr_value[:date],\n            date_formatted: I18n.l(curr_value[:date], format: :long),\n            value: curr_value[:value],\n            trend: Trend.new(\n              current: curr_value[:value],\n              previous: prev_value&.[](:value)\n            )\n          )\n        end\n      )\n    end\n  end\n\n  def initialize(start_date:, end_date:, interval:, values:, favorable_direction: \"up\")\n    @start_date = start_date\n    @end_date = end_date\n    @interval = interval\n    @values = values\n    @favorable_direction = favorable_direction\n  end\n\n  def trend\n    @trend ||= Trend.new(\n      current: values.last&.value,\n      previous: values.first&.value,\n      favorable_direction: favorable_direction\n    )\n  end\n\n  def as_json\n    {\n      start_date: start_date,\n      end_date: end_date,\n      interval: interval,\n      trend: trend,\n      values: values.map { |v| { date: v.date, date_formatted: v.date_formatted, value: v.value, trend: v.trend } }\n    }\n  end\nend\n"
  },
  {
    "path": "app/models/session.rb",
    "content": "class Session < ApplicationRecord\n  belongs_to :user\n  belongs_to :active_impersonator_session,\n    -> { where(status: :in_progress) },\n    class_name: \"ImpersonationSession\",\n    optional: true\n\n  before_create do\n    self.user_agent = Current.user_agent\n    self.ip_address = Current.ip_address\n  end\n\n  def get_preferred_tab(tab_key)\n    data.dig(\"tab_preferences\", tab_key)\n  end\n\n  def set_preferred_tab(tab_key, tab_value)\n    data[\"tab_preferences\"] ||= {}\n    data[\"tab_preferences\"][tab_key] = tab_value\n    save!\n  end\nend\n"
  },
  {
    "path": "app/models/setting.rb",
    "content": "# Dynamic settings the user can change within the app (helpful for self-hosting)\nclass Setting < RailsSettings::Base\n  cache_prefix { \"v1\" }\n\n  field :synth_api_key, type: :string, default: ENV[\"SYNTH_API_KEY\"]\n  field :openai_access_token, type: :string, default: ENV[\"OPENAI_ACCESS_TOKEN\"]\n\n  field :require_invite_for_signup, type: :boolean, default: false\n  field :require_email_confirmation, type: :boolean, default: ENV.fetch(\"REQUIRE_EMAIL_CONFIRMATION\", \"true\") == \"true\"\nend\n"
  },
  {
    "path": "app/models/subscription.rb",
    "content": "class Subscription < ApplicationRecord\n  TRIAL_DAYS = 14\n\n  belongs_to :family\n\n  # https://docs.stripe.com/api/subscriptions/object\n  enum :status, {\n    incomplete: \"incomplete\",\n    incomplete_expired: \"incomplete_expired\",\n    trialing: \"trialing\", # We use this, but \"offline\" (no through Stripe's interface)\n    active: \"active\",\n    past_due: \"past_due\",\n    canceled: \"canceled\",\n    unpaid: \"unpaid\",\n    paused: \"paused\"\n  }\n\n  validates :stripe_id, presence: true, if: :active?\n  validates :trial_ends_at, presence: true, if: :trialing?\n  validates :family_id, uniqueness: true\n\n  class << self\n    def new_trial_ends_at\n      TRIAL_DAYS.days.from_now\n    end\n  end\n\n  def name\n    case interval\n    when \"month\"\n      \"Monthly Plan\"\n    when \"year\"\n      \"Annual Plan\"\n    else\n      \"Free trial\"\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/sync.rb",
    "content": "class Sync < ApplicationRecord\n  # We run a cron that marks any syncs that have not been resolved in 24 hours as \"stale\"\n  # Syncs often become stale when new code is deployed and the worker restarts\n  STALE_AFTER = 24.hours\n\n  # The max time that a sync will show in the UI (after 5 minutes)\n  VISIBLE_FOR = 5.minutes\n\n  include AASM\n\n  Error = Class.new(StandardError)\n\n  belongs_to :syncable, polymorphic: true\n\n  belongs_to :parent, class_name: \"Sync\", optional: true\n  has_many :children, class_name: \"Sync\", foreign_key: :parent_id, dependent: :destroy\n\n  scope :ordered, -> { order(created_at: :desc) }\n  scope :incomplete, -> { where(\"syncs.status IN (?)\", %w[pending syncing]) }\n  scope :visible, -> { incomplete.where(\"syncs.created_at > ?\", VISIBLE_FOR.ago) }\n\n  after_commit :update_family_sync_timestamp\n\n  validate :window_valid\n\n  # Sync state machine\n  aasm column: :status, timestamps: true do\n    state :pending, initial: true\n    state :syncing\n    state :completed\n    state :failed\n    state :stale\n\n    after_all_transitions :handle_transition\n\n    event :start, after_commit: :handle_start_transition do\n      transitions from: :pending, to: :syncing\n    end\n\n    event :complete, after_commit: :handle_completion_transition do\n      transitions from: :syncing, to: :completed\n    end\n\n    event :fail do\n      transitions from: :syncing, to: :failed\n    end\n\n    # Marks a sync that never completed within the expected time window\n    event :mark_stale do\n      transitions from: %i[pending syncing], to: :stale\n    end\n  end\n\n  class << self\n    def clean\n      incomplete.where(\"syncs.created_at < ?\", STALE_AFTER.ago).find_each(&:mark_stale!)\n    end\n  end\n\n  def perform\n    Rails.logger.tagged(\"Sync\", id, syncable_type, syncable_id) do\n      # This can happen on server restarts or if Sidekiq enqueues a duplicate job\n      unless may_start?\n        Rails.logger.warn(\"Sync #{id} is not in a valid state (#{aasm.from_state}) to start.  Skipping sync.\")\n        return\n      end\n\n      start!\n\n      begin\n        syncable.perform_sync(self)\n      rescue => e\n        fail!\n        update(error: e.message)\n        report_error(e)\n      ensure\n        finalize_if_all_children_finalized\n      end\n    end\n  end\n\n  # Finalizes the current sync AND parent (if it exists)\n  def finalize_if_all_children_finalized\n    Sync.transaction do\n      lock!\n\n      # If this is the \"parent\" and there are still children running, don't finalize.\n      return unless all_children_finalized?\n\n      if syncing?\n        if has_failed_children?\n          fail!\n        else\n          complete!\n        end\n      end\n\n      # If we make it here, the sync is finalized.  Run post-sync, regardless of failure/success.\n      perform_post_sync\n    end\n\n    # If this sync has a parent, try to finalize it so the child status propagates up the chain.\n    parent&.finalize_if_all_children_finalized\n  end\n\n  # If a sync is pending, we can adjust the window if new syncs are created with a wider window.\n  def expand_window_if_needed(new_window_start_date, new_window_end_date)\n    return unless pending?\n    return if self.window_start_date.nil? && self.window_end_date.nil? # already as wide as possible\n\n    earliest_start_date = if self.window_start_date && new_window_start_date\n      [ self.window_start_date, new_window_start_date ].min\n    else\n      nil\n    end\n\n    latest_end_date = if self.window_end_date && new_window_end_date\n      [ self.window_end_date, new_window_end_date ].max\n    else\n      nil\n    end\n\n    update(\n      window_start_date: earliest_start_date,\n      window_end_date: latest_end_date\n    )\n  end\n\n  private\n    def log_status_change\n      Rails.logger.info(\"changing from #{aasm.from_state} to #{aasm.to_state} (event: #{aasm.current_event})\")\n    end\n\n    def has_failed_children?\n      children.failed.any?\n    end\n\n    def all_children_finalized?\n      children.incomplete.empty?\n    end\n\n    def perform_post_sync\n      Rails.logger.info(\"Performing post-sync for #{syncable_type} (#{syncable.id})\")\n      syncable.perform_post_sync\n      syncable.broadcast_sync_complete\n    rescue => e\n      Rails.logger.error(\"Error performing post-sync for #{syncable_type} (#{syncable.id}): #{e.message}\")\n      report_error(e)\n    end\n\n    def report_error(error)\n      Sentry.capture_exception(error) do |scope|\n        scope.set_tags(sync_id: id)\n      end\n    end\n\n    def report_warnings\n      todays_sync_count = syncable.syncs.where(created_at: Date.current.all_day).count\n\n      if todays_sync_count > 10\n        Sentry.capture_exception(\n          Error.new(\"#{syncable_type} (#{syncable.id}) has exceeded 10 syncs today (count: #{todays_sync_count})\"),\n          level: :warning\n        )\n      end\n    end\n\n    def handle_start_transition\n      report_warnings\n    end\n\n    def handle_transition\n      log_status_change\n    end\n\n    def handle_completion_transition\n      family.touch(:latest_sync_completed_at)\n    end\n\n    def window_valid\n      if window_start_date && window_end_date && window_start_date > window_end_date\n        errors.add(:window_end_date, \"must be greater than window_start_date\")\n      end\n    end\n\n    def update_family_sync_timestamp\n      family.touch(:latest_sync_activity_at)\n    end\n\n    def family\n      if syncable.is_a?(Family)\n        syncable\n      else\n        syncable.family\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/tag.rb",
    "content": "class Tag < ApplicationRecord\n  belongs_to :family\n  has_many :taggings, dependent: :destroy\n  has_many :transactions, through: :taggings, source: :taggable, source_type: \"Transaction\"\n  has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: \"Import::Mapping\"\n\n  validates :name, presence: true, uniqueness: { scope: :family }\n\n  scope :alphabetically, -> { order(:name) }\n\n  COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]\n\n  UNCATEGORIZED_COLOR = \"#737373\"\n\n  def replace_and_destroy!(replacement)\n    transaction do\n      raise ActiveRecord::RecordInvalid, \"Replacement tag cannot be the same as the tag being destroyed\" if replacement == self\n\n      if replacement\n        taggings.update_all tag_id: replacement.id\n      end\n\n      destroy!\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/tagging.rb",
    "content": "class Tagging < ApplicationRecord\n  belongs_to :tag\n  belongs_to :taggable, polymorphic: true\nend\n"
  },
  {
    "path": "app/models/tool_call/function.rb",
    "content": "class ToolCall::Function < ToolCall\n  validates :function_name, :function_result, presence: true\n  validates :function_arguments, presence: true, allow_blank: true\n\n  class << self\n    # Translates an \"LLM Concept\" provider's FunctionRequest into a ToolCall::Function\n    def from_function_request(function_request, result)\n      new(\n        provider_id: function_request.id,\n        provider_call_id: function_request.call_id,\n        function_name: function_request.function_name,\n        function_arguments: function_request.function_args,\n        function_result: result\n      )\n    end\n  end\n\n  def to_result\n    {\n      call_id: provider_call_id,\n      output: function_result\n    }\n  end\nend\n"
  },
  {
    "path": "app/models/tool_call.rb",
    "content": "class ToolCall < ApplicationRecord\n  belongs_to :message\nend\n"
  },
  {
    "path": "app/models/trade/create_form.rb",
    "content": "class Trade::CreateForm\n  include ActiveModel::Model\n\n  attr_accessor :account, :date, :amount, :currency, :qty,\n                :price, :ticker, :manual_ticker, :type, :transfer_account_id\n\n  # Either creates a trade, transaction, or transfer based on type\n  # Returns the model, regardless of success or failure\n  def create\n    case type\n    when \"buy\", \"sell\"\n      create_trade\n    when \"interest\"\n      create_interest_income\n    when \"deposit\", \"withdrawal\"\n      create_transfer\n    end\n  end\n\n  private\n    # Users can either look up a ticker from our provider (Synth) or enter a manual, \"offline\" ticker (that we won't fetch prices for)\n    def security\n      ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split(\"|\") : [ manual_ticker, nil ]\n\n      Security::Resolver.new(\n        ticker_symbol,\n        exchange_operating_mic: exchange_operating_mic\n      ).resolve\n    end\n\n    def create_trade\n      signed_qty = type == \"sell\" ? -qty.to_d : qty.to_d\n      signed_amount = signed_qty * price.to_d\n\n      trade_entry = account.entries.new(\n        name: Trade.build_name(type, qty, security.ticker),\n        date: date,\n        amount: signed_amount,\n        currency: currency,\n        entryable: Trade.new(\n          qty: signed_qty,\n          price: price,\n          currency: currency,\n          security: security\n        )\n      )\n\n      if trade_entry.save\n        trade_entry.lock_saved_attributes!\n        account.sync_later\n      end\n\n      trade_entry\n    end\n\n    def create_interest_income\n      signed_amount = amount.to_d * -1\n\n      entry = account.entries.build(\n        name: \"Interest payment\",\n        date: date,\n        amount: signed_amount,\n        currency: currency,\n        entryable: Transaction.new\n      )\n\n      if entry.save\n        entry.lock_saved_attributes!\n        account.sync_later\n      end\n\n      entry\n    end\n\n    def create_transfer\n      if transfer_account_id.present?\n        from_account_id = type == \"withdrawal\" ? account.id : transfer_account_id\n        to_account_id = type == \"withdrawal\" ? transfer_account_id : account.id\n\n        Transfer::Creator.new(\n          family: account.family,\n          source_account_id: from_account_id,\n          destination_account_id: to_account_id,\n          date: date,\n          amount: amount\n        ).create\n      else\n        create_unlinked_transfer\n      end\n    end\n\n    # If user doesn't provide the reciprocal account, it's a regular transaction\n    def create_unlinked_transfer\n      signed_amount = type == \"deposit\" ? amount.to_d * -1 : amount.to_d\n\n      entry = account.entries.build(\n        name: signed_amount < 0 ? \"Deposit to #{account.name}\" : \"Withdrawal from #{account.name}\",\n        date: date,\n        amount: signed_amount,\n        currency: currency,\n        entryable: Transaction.new\n      )\n\n      if entry.save\n        entry.lock_saved_attributes!\n        account.sync_later\n      end\n\n      entry\n    end\nend\n"
  },
  {
    "path": "app/models/trade.rb",
    "content": "class Trade < ApplicationRecord\n  include Entryable, Monetizable\n\n  monetize :price\n\n  belongs_to :security\n\n  validates :qty, presence: true\n  validates :price, :currency, presence: true\n\n  class << self\n    def build_name(type, qty, ticker)\n      prefix = type == \"buy\" ? \"Buy\" : \"Sell\"\n      \"#{prefix} #{qty.to_d.abs} shares of #{ticker}\"\n    end\n  end\n\n  def unrealized_gain_loss\n    return nil if qty.negative?\n    current_price = security.current_price\n    return nil if current_price.nil?\n\n    current_value = current_price * qty.abs\n    cost_basis = price_money * qty.abs\n\n    Trend.new(current: current_value, previous: cost_basis)\n  end\nend\n"
  },
  {
    "path": "app/models/trade_import.rb",
    "content": "class TradeImport < Import\n  def import!\n    transaction do\n      mappings.each(&:create_mappable!)\n\n      trades = rows.map do |row|\n        mapped_account = if account\n          account\n        else\n          mappings.accounts.mappable_for(row.account)\n        end\n\n        # Try to find or create security with ticker only\n        security = find_or_create_security(\n          ticker: row.ticker,\n          exchange_operating_mic: row.exchange_operating_mic\n        )\n\n        Trade.new(\n          security: security,\n          qty: row.qty,\n          currency: row.currency.presence || mapped_account.currency,\n          price: row.price,\n          entry: Entry.new(\n            account: mapped_account,\n            date: row.date_iso,\n            amount: row.signed_amount,\n            name: row.name,\n            currency: row.currency.presence || mapped_account.currency,\n            import: self\n          ),\n        )\n      end\n\n      Trade.import!(trades, recursive: true)\n    end\n  end\n\n  def mapping_steps\n    base = []\n    base << Import::AccountMapping if account.nil?\n    base\n  end\n\n  def required_column_keys\n    %i[date ticker qty price]\n  end\n\n  def column_keys\n    base = %i[date ticker exchange_operating_mic currency qty price name]\n    base.unshift(:account) if account.nil?\n    base\n  end\n\n  def dry_run\n    mappings = { transactions: rows.count }\n\n    mappings.merge(\n      accounts: Import::AccountMapping.for_import(self).creational.count\n    ) if account.nil?\n\n    mappings\n  end\n\n  def csv_template\n    template = <<-CSV\n      date*,ticker*,exchange_operating_mic,currency,qty*,price*,account,name\n      05/15/2024,AAPL,XNAS,USD,10,150.00,Trading Account,Apple Inc. Purchase\n      05/16/2024,GOOGL,XNAS,USD,-5,2500.00,Investment Account,Alphabet Inc. Sale\n      05/17/2024,TSLA,XNAS,USD,2,700.50,Retirement Account,Tesla Inc. Purchase\n    CSV\n\n    csv = CSV.parse(template, headers: true)\n    csv.delete(\"account\") if account.present?\n    csv\n  end\n\n  private\n    def find_or_create_security(ticker: nil, exchange_operating_mic: nil)\n      return nil unless ticker.present?\n\n      # Avoids resolving the same security over and over again (resolver potentially makes network calls)\n      @security_cache ||= {}\n\n      cache_key = [ ticker, exchange_operating_mic ].compact.join(\":\")\n\n      security = @security_cache[cache_key]\n\n      return security if security.present?\n\n      security = Security::Resolver.new(\n        ticker,\n        exchange_operating_mic: exchange_operating_mic.presence\n      ).resolve\n\n      @security_cache[cache_key] = security\n\n      security\n    end\nend\n"
  },
  {
    "path": "app/models/transaction/ruleable.rb",
    "content": "module Transaction::Ruleable\n  extend ActiveSupport::Concern\n\n  def eligible_for_category_rule?\n    rules.joins(:actions).where(\n      actions: {\n        action_type: \"set_transaction_category\",\n        value: category_id\n      }\n    ).empty?\n  end\n\n  private\n    def rules\n      entry.account.family.rules\n    end\nend\n"
  },
  {
    "path": "app/models/transaction/search.rb",
    "content": "class Transaction::Search\n  include ActiveModel::Model\n  include ActiveModel::Attributes\n\n  attribute :search, :string\n  attribute :amount, :string\n  attribute :amount_operator, :string\n  attribute :types, array: true\n  attribute :accounts, array: true\n  attribute :account_ids, array: true\n  attribute :start_date, :string\n  attribute :end_date, :string\n  attribute :categories, array: true\n  attribute :merchants, array: true\n  attribute :tags, array: true\n  attribute :active_accounts_only, :boolean, default: true\n\n  attr_reader :family\n\n  def initialize(family, filters: {})\n    @family = family\n    super(filters)\n  end\n\n  def transactions_scope\n    @transactions_scope ||= begin\n      # This already joins entries + accounts. To avoid expensive double-joins, don't join them again (causes full table scan)\n      query = family.transactions\n\n      query = apply_active_accounts_filter(query, active_accounts_only)\n      query = apply_category_filter(query, categories)\n      query = apply_type_filter(query, types)\n      query = apply_merchant_filter(query, merchants)\n      query = apply_tag_filter(query, tags)\n      query = EntrySearch.apply_search_filter(query, search)\n      query = EntrySearch.apply_date_filters(query, start_date, end_date)\n      query = EntrySearch.apply_amount_filter(query, amount, amount_operator)\n      query = EntrySearch.apply_accounts_filter(query, accounts, account_ids)\n\n      query\n    end\n  end\n\n  # Computes totals for the specific search\n  def totals\n    @totals ||= begin\n      Rails.cache.fetch(\"transaction_search_totals/#{cache_key_base}\") do\n        result = transactions_scope\n                  .select(\n                    \"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total\",\n                    \"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total\",\n                    \"COUNT(entries.id) as transactions_count\"\n                  )\n                  .joins(\n                    ActiveRecord::Base.sanitize_sql_array([\n                      \"LEFT JOIN exchange_rates er ON (er.date = entries.date AND er.from_currency = entries.currency AND er.to_currency = ?)\",\n                      family.currency\n                    ])\n                  )\n                  .take\n\n        Totals.new(\n          count: result.transactions_count.to_i,\n          income_money: Money.new(result.income_total.round, family.currency),\n          expense_money: Money.new(result.expense_total.round, family.currency)\n        )\n      end\n    end\n  end\n\n  def cache_key_base\n    [\n      family.id,\n      Digest::SHA256.hexdigest(attributes.sort.to_h.to_json), # cached by filters\n      family.entries_cache_version\n    ].join(\"/\")\n  end\n\n  private\n    Totals = Data.define(:count, :income_money, :expense_money)\n\n    def apply_active_accounts_filter(query, active_accounts_only_filter)\n      if active_accounts_only_filter\n        query.where(accounts: { status: [ \"draft\", \"active\" ] })\n      else\n        query\n      end\n    end\n\n\n    def apply_category_filter(query, categories)\n      return query unless categories.present?\n\n      query = query.left_joins(:category).where(\n        \"categories.name IN (?) OR (\n        categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))\n      )\",\n        categories\n      )\n\n      if categories.exclude?(\"Uncategorized\")\n        query = query.where.not(category_id: nil)\n      end\n\n      query\n    end\n\n    def apply_type_filter(query, types)\n      return query unless types.present?\n      return query if types.sort == [ \"expense\", \"income\", \"transfer\" ]\n\n      transfer_condition = \"transactions.kind IN ('funds_movement', 'cc_payment', 'loan_payment')\"\n      expense_condition = \"entries.amount >= 0\"\n      income_condition = \"entries.amount <= 0\"\n\n      condition = case types.sort\n      when [ \"transfer\" ]\n        transfer_condition\n      when [ \"expense\" ]\n        Arel.sql(\"#{expense_condition} AND NOT (#{transfer_condition})\")\n      when [ \"income\" ]\n        Arel.sql(\"#{income_condition} AND NOT (#{transfer_condition})\")\n      when [ \"expense\", \"transfer\" ]\n        Arel.sql(\"#{expense_condition} OR #{transfer_condition}\")\n      when [ \"income\", \"transfer\" ]\n        Arel.sql(\"#{income_condition} OR #{transfer_condition}\")\n      when [ \"expense\", \"income\" ]\n        Arel.sql(\"NOT (#{transfer_condition})\")\n      end\n\n      query.where(condition)\n    end\n\n    def apply_merchant_filter(query, merchants)\n      return query unless merchants.present?\n      query.joins(:merchant).where(merchants: { name: merchants })\n    end\n\n    def apply_tag_filter(query, tags)\n      return query unless tags.present?\n      query.joins(:tags).where(tags: { name: tags })\n    end\nend\n"
  },
  {
    "path": "app/models/transaction/transferable.rb",
    "content": "module Transaction::Transferable\n  extend ActiveSupport::Concern\n\n  included do\n    has_one :transfer_as_inflow, class_name: \"Transfer\", foreign_key: \"inflow_transaction_id\", dependent: :destroy\n    has_one :transfer_as_outflow, class_name: \"Transfer\", foreign_key: \"outflow_transaction_id\", dependent: :destroy\n\n    # We keep track of rejected transfers to avoid auto-matching them again\n    has_one :rejected_transfer_as_inflow, class_name: \"RejectedTransfer\", foreign_key: \"inflow_transaction_id\", dependent: :destroy\n    has_one :rejected_transfer_as_outflow, class_name: \"RejectedTransfer\", foreign_key: \"outflow_transaction_id\", dependent: :destroy\n  end\n\n  def transfer\n    transfer_as_inflow || transfer_as_outflow\n  end\n\n  def transfer_match_candidates\n    candidates_scope = if self.entry.amount.negative?\n      family_matches_scope.where(\"inflow_candidates.entryable_id = ?\", self.id)\n    else\n      family_matches_scope.where(\"outflow_candidates.entryable_id = ?\", self.id)\n    end\n\n    candidates_scope.map do |match|\n      Transfer.new(\n        inflow_transaction_id: match.inflow_transaction_id,\n        outflow_transaction_id: match.outflow_transaction_id,\n      )\n    end\n  end\n\n  private\n    def family_matches_scope\n      self.entry.account.family.transfer_match_candidates\n    end\nend\n"
  },
  {
    "path": "app/models/transaction.rb",
    "content": "class Transaction < ApplicationRecord\n  include Entryable, Transferable, Ruleable\n\n  belongs_to :category, optional: true\n  belongs_to :merchant, optional: true\n\n  has_many :taggings, as: :taggable, dependent: :destroy\n  has_many :tags, through: :taggings\n\n  accepts_nested_attributes_for :taggings, allow_destroy: true\n\n  enum :kind, {\n    standard: \"standard\", # A regular transaction, included in budget analytics\n    funds_movement: \"funds_movement\", # Movement of funds between accounts, excluded from budget analytics\n    cc_payment: \"cc_payment\", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)\n    loan_payment: \"loan_payment\", # A payment to a Loan account, treated as an expense in budgets\n    one_time: \"one_time\" # A one-time expense/income, excluded from budget analytics\n  }\n\n  # Overarching grouping method for all transfer-type transactions\n  def transfer?\n    funds_movement? || cc_payment? || loan_payment?\n  end\n\n  def set_category!(category)\n    if category.is_a?(String)\n      category = entry.account.family.categories.find_or_create_by!(\n        name: category\n      )\n    end\n\n    update!(category: category)\n  end\nend\n"
  },
  {
    "path": "app/models/transaction_import.rb",
    "content": "class TransactionImport < Import\n  def import!\n    transaction do\n      mappings.each(&:create_mappable!)\n\n      transactions = rows.map do |row|\n        mapped_account = if account\n          account\n        else\n          mappings.accounts.mappable_for(row.account)\n        end\n\n        category = mappings.categories.mappable_for(row.category)\n        tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact\n\n        Transaction.new(\n          category: category,\n          tags: tags,\n          entry: Entry.new(\n            account: mapped_account,\n            date: row.date_iso,\n            amount: row.signed_amount,\n            name: row.name,\n            currency: row.currency,\n            notes: row.notes,\n            import: self\n          )\n        )\n      end\n\n      Transaction.import!(transactions, recursive: true)\n    end\n  end\n\n  def required_column_keys\n    %i[date amount]\n  end\n\n  def column_keys\n    base = %i[date amount name currency category tags notes]\n    base.unshift(:account) if account.nil?\n    base\n  end\n\n  def mapping_steps\n    base = [ Import::CategoryMapping, Import::TagMapping ]\n    base << Import::AccountMapping if account.nil?\n    base\n  end\n\n  def selectable_amount_type_values\n    return [] if entity_type_col_label.nil?\n\n    csv_rows.map { |row| row[entity_type_col_label] }.uniq\n  end\n\n  def csv_template\n    template = <<-CSV\n      date*,amount*,name,currency,category,tags,account,notes\n      05/15/2024,-45.99,Grocery Store,USD,Food,groceries|essentials,Checking Account,Monthly grocery run\n      05/16/2024,1500.00,Salary,,Income,,Main Account,\n      05/17/2024,-12.50,Coffee Shop,,,coffee,,\n    CSV\n\n    csv = CSV.parse(template, headers: true)\n    csv.delete(\"account\") if account.present?\n    csv\n  end\nend\n"
  },
  {
    "path": "app/models/transfer/creator.rb",
    "content": "class Transfer::Creator\n  def initialize(family:, source_account_id:, destination_account_id:, date:, amount:)\n    @family = family\n    @source_account = family.accounts.find(source_account_id) # early throw if not found\n    @destination_account = family.accounts.find(destination_account_id) # early throw if not found\n    @date = date\n    @amount = amount.to_d\n  end\n\n  def create\n    transfer = Transfer.new(\n      inflow_transaction: inflow_transaction,\n      outflow_transaction: outflow_transaction,\n      status: \"confirmed\"\n    )\n\n    if transfer.save\n      source_account.sync_later\n      destination_account.sync_later\n    end\n\n    transfer\n  end\n\n  private\n    attr_reader :family, :source_account, :destination_account, :date, :amount\n\n    def outflow_transaction\n      name = \"#{name_prefix} to #{destination_account.name}\"\n\n      Transaction.new(\n        kind: outflow_transaction_kind,\n        entry: source_account.entries.build(\n          amount: amount.abs,\n          currency: source_account.currency,\n          date: date,\n          name: name,\n        )\n      )\n    end\n\n    def inflow_transaction\n      name = \"#{name_prefix} from #{source_account.name}\"\n\n      Transaction.new(\n        kind: \"funds_movement\",\n        entry: destination_account.entries.build(\n          amount: inflow_converted_money.amount.abs * -1,\n          currency: destination_account.currency,\n          date: date,\n          name: name,\n        )\n      )\n    end\n\n    # If destination account has different currency, its transaction should show up as converted\n    # Future improvement: instead of a 1:1 conversion fallback, add a UI/UX flow for missing rates\n    def inflow_converted_money\n      Money.new(amount.abs, source_account.currency)\n           .exchange_to(\n             destination_account.currency,\n             date: date,\n             fallback_rate: 1.0\n           )\n    end\n\n    # The \"expense\" side of a transfer is treated different in analytics based on where it goes.\n    def outflow_transaction_kind\n      if destination_account.loan?\n        \"loan_payment\"\n      elsif destination_account.liability?\n        \"cc_payment\"\n      else\n        \"funds_movement\"\n      end\n    end\n\n    def name_prefix\n      if destination_account.liability?\n        \"Payment\"\n      else\n        \"Transfer\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/transfer.rb",
    "content": "class Transfer < ApplicationRecord\n  belongs_to :inflow_transaction, class_name: \"Transaction\"\n  belongs_to :outflow_transaction, class_name: \"Transaction\"\n\n  enum :status, { pending: \"pending\", confirmed: \"confirmed\" }\n\n  validates :inflow_transaction_id, uniqueness: true\n  validates :outflow_transaction_id, uniqueness: true\n\n  validate :transfer_has_different_accounts\n  validate :transfer_has_opposite_amounts\n  validate :transfer_within_date_range\n  validate :transfer_has_same_family\n\n  class << self\n    def kind_for_account(account)\n      if account.loan?\n        \"loan_payment\"\n      elsif account.liability?\n        \"cc_payment\"\n      else\n        \"funds_movement\"\n      end\n    end\n  end\n\n  def reject!\n    Transfer.transaction do\n      RejectedTransfer.find_or_create_by!(inflow_transaction_id: inflow_transaction_id, outflow_transaction_id: outflow_transaction_id)\n      destroy!\n    end\n  end\n\n  # Once transfer is destroyed, we need to mark the denormalized kind fields on the transactions\n  def destroy!\n    Transfer.transaction do\n      inflow_transaction.update!(kind: \"standard\")\n      outflow_transaction.update!(kind: \"standard\")\n      super\n    end\n  end\n\n  def confirm!\n    update!(status: \"confirmed\")\n  end\n\n  def date\n    inflow_transaction.entry.date\n  end\n\n  def sync_account_later\n    inflow_transaction&.entry&.sync_account_later\n    outflow_transaction&.entry&.sync_account_later\n  end\n\n  def to_account\n    inflow_transaction&.entry&.account\n  end\n\n  def from_account\n    outflow_transaction&.entry&.account\n  end\n\n  def amount_abs\n    inflow_transaction&.entry&.amount_money&.abs\n  end\n\n  def name\n    acc = to_account\n    if payment?\n      acc ? \"Payment to #{acc.name}\" : \"Payment\"\n    else\n      acc ? \"Transfer to #{acc.name}\" : \"Transfer\"\n    end\n  end\n\n  def payment?\n    to_account&.liability?\n  end\n\n  def loan_payment?\n    outflow_transaction&.kind == \"loan_payment\"\n  end\n\n  def liability_payment?\n    outflow_transaction&.kind == \"cc_payment\"\n  end\n\n  def regular_transfer?\n    outflow_transaction&.kind == \"funds_movement\"\n  end\n\n  def transfer_type\n    return \"loan_payment\" if loan_payment?\n    return \"liability_payment\" if liability_payment?\n    \"transfer\"\n  end\n\n  def categorizable?\n    to_account&.accountable_type == \"Loan\"\n  end\n\n  private\n    def transfer_has_different_accounts\n      return unless inflow_transaction&.entry && outflow_transaction&.entry\n      errors.add(:base, \"Must be from different accounts\") if to_account == from_account\n    end\n\n    def transfer_has_same_family\n      return unless inflow_transaction&.entry && outflow_transaction&.entry\n      errors.add(:base, \"Must be from same family\") unless to_account&.family == from_account&.family\n    end\n\n    def transfer_has_opposite_amounts\n      return unless inflow_transaction&.entry && outflow_transaction&.entry\n\n      inflow_entry = inflow_transaction.entry\n      outflow_entry = outflow_transaction.entry\n\n      inflow_amount = inflow_entry.amount\n      outflow_amount = outflow_entry.amount\n\n      if inflow_entry.currency == outflow_entry.currency\n        # For same currency, amounts must be exactly opposite\n        errors.add(:base, \"Must have opposite amounts\") if inflow_amount + outflow_amount != 0\n      else\n        # For different currencies, just check the signs are opposite\n        errors.add(:base, \"Must have opposite amounts\") unless inflow_amount.negative? && outflow_amount.positive?\n      end\n    end\n\n    def transfer_within_date_range\n      return unless inflow_transaction&.entry && outflow_transaction&.entry\n\n      date_diff = (inflow_transaction.entry.date - outflow_transaction.entry.date).abs\n      errors.add(:base, \"Must be within 4 days\") if date_diff > 4\n    end\nend\n"
  },
  {
    "path": "app/models/trend.rb",
    "content": "class Trend\n  include ActiveModel::Validations\n\n  DIRECTIONS = %w[up down].freeze\n\n  attr_reader :current, :previous, :favorable_direction\n\n  validates :current, presence: true\n\n  def initialize(current:, previous:, favorable_direction: nil)\n    @current = current\n    @previous = previous || 0\n    @favorable_direction = (favorable_direction.presence_in(DIRECTIONS) || \"up\").inquiry\n\n    validate!\n  end\n\n  def direction\n    if current == previous\n      \"flat\"\n    elsif current > previous\n      \"up\"\n    else\n      \"down\"\n    end.inquiry\n  end\n\n  def color\n    case direction\n    when \"up\"\n      favorable_direction.down? ? red_hex : green_hex\n    when \"down\"\n      favorable_direction.down? ? green_hex : red_hex\n    else\n      gray_hex\n    end\n  end\n\n  def icon\n    if direction.flat?\n      \"minus\"\n    elsif direction.up?\n      \"arrow-up\"\n    else\n      \"arrow-down\"\n    end\n  end\n\n  def value\n    current - previous\n  end\n\n  def percent\n    return 0.0 if previous.zero? && current.zero?\n    return Float::INFINITY if previous.zero?\n\n    change = (current - previous).to_f\n\n    (change / previous.to_f * 100).round(1)\n  end\n\n  def percent_formatted\n    if percent.finite?\n      \"#{percent.round(1)}%\"\n    else\n      percent > 0 ? \"＋∞\" : \"-∞\"\n    end\n  end\n\n  def as_json\n    {\n      value: value,\n      percent: percent,\n      percent_formatted: percent_formatted,\n      current: current,\n      previous: previous,\n      color: color,\n      icon: icon\n    }\n  end\n\n  private\n    def red_hex\n      \"var(--color-destructive)\"\n    end\n\n    def green_hex\n      \"var(--color-success)\"\n    end\n\n    def gray_hex\n      \"var(--color-gray)\"\n    end\nend\n"
  },
  {
    "path": "app/models/user.rb",
    "content": "class User < ApplicationRecord\n  has_secure_password\n\n  belongs_to :family\n  belongs_to :last_viewed_chat, class_name: \"Chat\", optional: true\n  has_many :sessions, dependent: :destroy\n  has_many :chats, dependent: :destroy\n  has_many :api_keys, dependent: :destroy\n  has_many :mobile_devices, dependent: :destroy\n  has_many :invitations, foreign_key: :inviter_id, dependent: :destroy\n  has_many :impersonator_support_sessions, class_name: \"ImpersonationSession\", foreign_key: :impersonator_id, dependent: :destroy\n  has_many :impersonated_support_sessions, class_name: \"ImpersonationSession\", foreign_key: :impersonated_id, dependent: :destroy\n  accepts_nested_attributes_for :family, update_only: true\n\n  validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }\n  validate :ensure_valid_profile_image\n  validates :default_period, inclusion: { in: Period::PERIODS.keys }\n  normalizes :email, with: ->(email) { email.strip.downcase }\n  normalizes :unconfirmed_email, with: ->(email) { email&.strip&.downcase }\n\n  normalizes :first_name, :last_name, with: ->(value) { value.strip.presence }\n\n  enum :role, { member: \"member\", admin: \"admin\", super_admin: \"super_admin\" }, validate: true\n\n  has_one_attached :profile_image do |attachable|\n    attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ], convert: :webp, saver: { quality: 80 }\n    attachable.variant :small, resize_to_fill: [ 72, 72 ], convert: :webp, saver: { quality: 80 }, preprocessed: true\n  end\n\n  validate :profile_image_size\n\n  generates_token_for :password_reset, expires_in: 15.minutes do\n    password_salt&.last(10)\n  end\n\n  generates_token_for :email_confirmation, expires_in: 1.day do\n    unconfirmed_email\n  end\n\n  def pending_email_change?\n    unconfirmed_email.present?\n  end\n\n  def initiate_email_change(new_email)\n    return false if new_email == email\n    return false if new_email == unconfirmed_email\n\n    if Rails.application.config.app_mode.self_hosted? && !Setting.require_email_confirmation\n      update(email: new_email)\n    else\n      if update(unconfirmed_email: new_email)\n        EmailConfirmationMailer.with(user: self).confirmation_email.deliver_later\n        true\n      else\n        false\n      end\n    end\n  end\n\n  def request_impersonation_for(user_id)\n    impersonated = User.find(user_id)\n    impersonator_support_sessions.create!(impersonated: impersonated)\n  end\n\n  def admin?\n    super_admin? || role == \"admin\"\n  end\n\n  def display_name\n    [ first_name, last_name ].compact.join(\" \").presence || email\n  end\n\n  def initial\n    (display_name&.first || email.first).upcase\n  end\n\n  def initials\n    if first_name.present? && last_name.present?\n      \"#{first_name.first}#{last_name.first}\".upcase\n    else\n      initial\n    end\n  end\n\n  def show_ai_sidebar?\n    show_ai_sidebar\n  end\n\n  def ai_available?\n    !Rails.application.config.app_mode.self_hosted? || ENV[\"OPENAI_ACCESS_TOKEN\"].present?\n  end\n\n  def ai_enabled?\n    ai_enabled && ai_available?\n  end\n\n  # Deactivation\n  validate :can_deactivate, if: -> { active_changed? && !active }\n  after_update_commit :purge_later, if: -> { saved_change_to_active?(from: true, to: false) }\n\n  def deactivate\n    update active: false, email: deactivated_email\n  end\n\n  def can_deactivate\n    if admin? && family.users.count > 1\n      errors.add(:base, :cannot_deactivate_admin_with_other_users)\n    end\n  end\n\n  def purge_later\n    UserPurgeJob.perform_later(self)\n  end\n\n  def purge\n    if last_user_in_family?\n      family.destroy\n    else\n      destroy\n    end\n  end\n\n  # MFA\n  def setup_mfa!\n    update!(\n      otp_secret: ROTP::Base32.random(32),\n      otp_required: false,\n      otp_backup_codes: []\n    )\n  end\n\n  def enable_mfa!\n    update!(\n      otp_required: true,\n      otp_backup_codes: generate_backup_codes\n    )\n  end\n\n  def disable_mfa!\n    update!(\n      otp_secret: nil,\n      otp_required: false,\n      otp_backup_codes: []\n    )\n  end\n\n  def verify_otp?(code)\n    return false if otp_secret.blank?\n    return true if verify_backup_code?(code)\n    totp.verify(code, drift_behind: 15)\n  end\n\n  def provisioning_uri\n    return nil unless otp_secret.present?\n    totp.provisioning_uri(email)\n  end\n\n  def onboarded?\n    onboarded_at.present?\n  end\n\n  def needs_onboarding?\n    !onboarded?\n  end\n\n  private\n    def ensure_valid_profile_image\n      return unless profile_image.attached?\n\n      unless profile_image.content_type.in?(%w[image/jpeg image/png])\n        errors.add(:profile_image, \"must be a JPEG or PNG\")\n        profile_image.purge\n      end\n    end\n\n    def last_user_in_family?\n      family.users.count == 1\n    end\n\n    def deactivated_email\n      email.gsub(/@/, \"-deactivated-#{SecureRandom.uuid}@\")\n    end\n\n    def profile_image_size\n      if profile_image.attached? && profile_image.byte_size > 10.megabytes\n        errors.add(:profile_image, :invalid_file_size, max_megabytes: 10)\n      end\n    end\n\n    def totp\n      ROTP::TOTP.new(otp_secret, issuer: \"Maybe Finance\")\n    end\n\n    def verify_backup_code?(code)\n      return false if otp_backup_codes.blank?\n\n      # Find and remove the used backup code\n      if (index = otp_backup_codes.index(code))\n        remaining_codes = otp_backup_codes.dup\n        remaining_codes.delete_at(index)\n        update_column(:otp_backup_codes, remaining_codes)\n        true\n      else\n        false\n      end\n    end\n\n    def generate_backup_codes\n      8.times.map { SecureRandom.hex(4) }\n    end\nend\n"
  },
  {
    "path": "app/models/user_message.rb",
    "content": "class UserMessage < Message\n  validates :ai_model, presence: true\n\n  after_create_commit :request_response_later\n\n  def role\n    \"user\"\n  end\n\n  def request_response_later\n    chat.ask_assistant_later(self)\n  end\n\n  def request_response\n    chat.ask_assistant(self)\n  end\nend\n"
  },
  {
    "path": "app/models/valuation/name.rb",
    "content": "class Valuation::Name\n  def initialize(valuation_kind, accountable_type)\n    @valuation_kind = valuation_kind\n    @accountable_type = accountable_type\n  end\n\n  def to_s\n    case valuation_kind\n    when \"opening_anchor\"\n      opening_anchor_name\n    when \"current_anchor\"\n      current_anchor_name\n    else\n      recon_name\n    end\n  end\n\n  private\n    attr_reader :valuation_kind, :accountable_type\n\n    def opening_anchor_name\n      case accountable_type\n      when \"Property\", \"Vehicle\"\n        \"Original purchase price\"\n      when \"Loan\"\n        \"Original principal\"\n      when \"Investment\", \"Crypto\", \"OtherAsset\"\n        \"Opening account value\"\n      else\n        \"Opening balance\"\n      end\n    end\n\n    def current_anchor_name\n      case accountable_type\n      when \"Property\", \"Vehicle\"\n        \"Current market value\"\n      when \"Loan\"\n        \"Current loan balance\"\n      when \"Investment\", \"Crypto\", \"OtherAsset\"\n        \"Current account value\"\n      else\n        \"Current balance\"\n      end\n    end\n\n    def recon_name\n      case accountable_type\n      when \"Property\", \"Investment\", \"Vehicle\", \"Crypto\", \"OtherAsset\"\n        \"Manual value update\"\n      when \"Loan\"\n        \"Manual principal update\"\n      else\n        \"Manual balance update\"\n      end\n    end\nend\n"
  },
  {
    "path": "app/models/valuation.rb",
    "content": "class Valuation < ApplicationRecord\n  include Entryable\n\n  enum :kind, {\n    reconciliation: \"reconciliation\",\n    opening_anchor: \"opening_anchor\",\n    current_anchor: \"current_anchor\"\n  }, validate: true, default: \"reconciliation\"\n\n  class << self\n    def build_reconciliation_name(accountable_type)\n      Valuation::Name.new(\"reconciliation\", accountable_type).to_s\n    end\n\n    def build_opening_anchor_name(accountable_type)\n      Valuation::Name.new(\"opening_anchor\", accountable_type).to_s\n    end\n\n    def build_current_anchor_name(accountable_type)\n      Valuation::Name.new(\"current_anchor\", accountable_type).to_s\n    end\n  end\nend\n"
  },
  {
    "path": "app/models/vehicle.rb",
    "content": "class Vehicle < ApplicationRecord\n  include Accountable\n\n  attribute :mileage_unit, :string, default: \"mi\"\n\n  def mileage\n    Measurement.new(mileage_value, mileage_unit) if mileage_value.present?\n  end\n\n  def purchase_price\n    first_valuation_amount\n  end\n\n  def trend\n    Trend.new(current: account.balance_money, previous: first_valuation_amount)\n  end\n\n  class << self\n    def color\n      \"#F23E94\"\n    end\n\n    def icon\n      \"car-front\"\n    end\n\n    def classification\n      \"asset\"\n    end\n  end\n\n  private\n    def first_valuation_amount\n      account.entries.valuations.order(:date).first&.amount_money || account.balance_money\n    end\nend\n"
  },
  {
    "path": "app/services/api_rate_limiter.rb",
    "content": "class ApiRateLimiter\n  # Rate limit tiers (requests per hour)\n  RATE_LIMITS = {\n    standard: 100,\n    premium: 1000,\n    enterprise: 10000\n  }.freeze\n\n  DEFAULT_TIER = :standard\n\n  def initialize(api_key)\n    @api_key = api_key\n    @redis = Redis.new\n  end\n\n  # Check if the API key has exceeded its rate limit\n  def rate_limit_exceeded?\n    current_count >= rate_limit\n  end\n\n  # Increment the request count for this API key\n  def increment_request_count!\n    key = redis_key\n    current_time = Time.current.to_i\n    window_start = (current_time / 3600) * 3600 # Hourly window\n\n    @redis.multi do |transaction|\n      # Use a sliding window with hourly buckets\n      transaction.hincrby(key, window_start.to_s, 1)\n      transaction.expire(key, 7200) # Keep data for 2 hours to handle sliding window\n    end\n  end\n\n  # Get current request count within the current hour\n  def current_count\n    key = redis_key\n    current_time = Time.current.to_i\n    window_start = (current_time / 3600) * 3600\n\n    count = @redis.hget(key, window_start.to_s)\n    count.to_i\n  end\n\n  # Get the rate limit for this API key's tier\n  def rate_limit\n    tier = determine_tier\n    RATE_LIMITS[tier]\n  end\n\n  # Calculate seconds until the rate limit resets\n  def reset_time\n    current_time = Time.current.to_i\n    next_window = ((current_time / 3600) + 1) * 3600\n    next_window - current_time\n  end\n\n  # Get detailed usage information\n  def usage_info\n    {\n      current_count: current_count,\n      rate_limit: rate_limit,\n      remaining: [ rate_limit - current_count, 0 ].max,\n      reset_time: reset_time,\n      tier: determine_tier\n    }\n  end\n\n  # Class method to get usage for an API key without incrementing\n  def self.usage_for(api_key)\n    limit(api_key).usage_info\n  end\n\n  def self.limit(api_key)\n    if Rails.application.config.app_mode.self_hosted?\n      # Use NoopApiRateLimiter for self-hosted mode\n      # This means no rate limiting is applied\n      NoopApiRateLimiter.new(api_key)\n    else\n      new(api_key)\n    end\n  end\n\n  private\n\n    def redis_key\n      \"api_rate_limit:#{@api_key.id}\"\n    end\n\n    def determine_tier\n      # For now, all API keys are standard tier\n      # This can be extended later to support different tiers based on user subscription\n      # or API key configuration\n      DEFAULT_TIER\n    end\nend\n"
  },
  {
    "path": "app/services/noop_api_rate_limiter.rb",
    "content": "class NoopApiRateLimiter\n  def initialize(api_key)\n    @api_key = api_key\n  end\n\n  def rate_limit_exceeded?\n    false\n  end\n\n  def increment_request_count!\n    # No operation\n  end\n\n  def current_count\n    0\n  end\n\n  def rate_limit\n    Float::INFINITY\n  end\n\n  def reset_time\n    0\n  end\n\n  def usage_info\n    {\n      current_count: 0,\n      rate_limit: Float::INFINITY,\n      remaining: Float::INFINITY,\n      reset_time: 0,\n      tier: :noop\n    }\n  end\n\n  def self.usage_for(api_key)\n    new(api_key).usage_info\n  end\nend\n"
  },
  {
    "path": "app/views/accountable_sparklines/show.html.erb",
    "content": "<%= turbo_frame_tag \"#{@accountable.model_name.param_key}_sparkline\" do %>\n  <div class=\"flex items-center justify-end gap-1\">\n    <div class=\"w-8 h-3\">\n      <%= render \"shared/sparkline\", id: dom_id(@accountable, :sparkline_chart), series: @series %>\n    </div>\n\n    <%= tag.p @series.trend.percent_formatted,\n                style: \"color: #{@series.trend.color}\",\n                class: \"font-mono text-right text-xs font-medium text-primary\" %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/_account.html.erb",
    "content": "<%# locals: (account:, return_to: nil) %>\n\n<%= turbo_frame_tag dom_id(account) do %>\n  <div class=\"p-4 flex items-center justify-between gap-3 group/account\">\n    <div class=\"flex items-center gap-3\">\n      <%= render \"accounts/logo\", account: account, size: \"md\" %>\n\n      <div>\n        <% if account.pending_deletion? %>\n          <p class=\"text-sm font-medium text-primary\">\n            <span>\n              <%= account.name %>\n            </span>\n            <span class=\"text-red-500 animate-pulse\">\n              (deletion in progress...)\n            </span>\n          </p>\n        <% else %>\n          <%= link_to account.name, account, class: [(account.active? ? \"text-primary\" : \"text-subdued\"), \"text-sm font-medium hover:underline\"], data: { turbo_frame: \"_top\" } %>\n          <% if account.long_subtype_label %>\n            <p class=\"text-sm text-secondary truncate\"><%= account.long_subtype_label %></p>\n          <% end %>\n        <% end %>\n      </div>\n\n      <% unless account.pending_deletion? %>\n        <%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: \"group-hover/account:flex hidden hover:opacity-80 items-center justify-center\" do %>\n          <%= icon(\"pencil-line\", size: \"sm\") %>\n        <% end %>\n      <% end %>\n    </div>\n    <div class=\"flex items-center gap-8\">\n      <% if account.draft? %>\n        <!-- Balance hidden for draft accounts -->\n      <% elsif account.syncing? %>\n        <div class=\"w-16 h-6 bg-loader rounded-full animate-pulse\"></div>\n      <% else %>\n        <p class=\"text-sm font-medium <%= account.active? ? \"text-primary\" : \"text-subdued\" %>\">\n          <%= format_money account.balance_money %>\n        </p>\n      <% end %>\n\n      <% if account.draft? %>\n        <%= render DS::Link.new(\n          text: \"Complete setup\",\n          href: edit_account_path(account, return_to: return_to),\n          variant: :outline,\n          frame: :modal\n        ) %>\n      <% elsif account.active? || account.disabled? %>\n        <%= form_with model: account, url: toggle_active_account_path(account), method: :patch, data: { turbo_frame: \"_top\", controller: \"auto-submit-form\" } do |f| %>\n          <%= render DS::Toggle.new(\n            id: \"account_#{account.id}_active\",\n            name: \"active\",\n            checked: account.active?,\n            data: { auto_submit_form_target: \"auto\" }\n          ) %>\n        <% end %>\n      <% end %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/_account_sidebar_tabs.html.erb",
    "content": "<%# locals: (family:, active_tab:, mobile: false) %>\n\n<div id=\"account-sidebar-tabs\">\n  <% if family.missing_data_provider? %>\n    <details class=\"group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs\">\n      <summary class=\"flex items-center justify-between gap-2\">\n        <div class=\"flex items-center gap-2\">\n          <%= icon \"triangle-alert\", size: \"sm\", color: \"warning\" %>\n          <p class=\"font-medium\">Missing historical data</p>\n        </div>\n\n        <%= icon(\"chevron-down\", color: \"warning\", class: \"group-open:transform group-open:rotate-180\") %>\n      </summary>\n      <div class=\"text-xs py-2 space-y-2\">\n        <p>Maybe uses Synth API to fetch historical exchange rates, security prices, and more.  This data is required to calculate accurate historical account balances.</p>\n\n        <p>\n          <%= link_to \"Add your Synth API key here.\", settings_hosting_path, class: \"text-yellow-600 underline\" %>\n        </p>\n      </div>\n    </details>\n  <% end %>\n\n  <%= render DS::Tabs.new(active_tab: active_tab, session_key: \"account_sidebar_tab\", testid: \"account-sidebar-tabs\") do |tabs| %>\n    <% tabs.with_nav do |nav| %>\n      <% nav.with_btn(id: \"asset\", label: \"Assets\") %>\n      <% nav.with_btn(id: \"liability\", label: \"Debts\") %>\n      <% nav.with_btn(id: \"all\", label: \"All\") %>\n    <% end %>\n\n    <% tabs.with_panel(tab_id: \"asset\") do %>\n      <div class=\"space-y-2\">\n        <%= render DS::Link.new(\n          text: \"New asset\",\n          variant: \"ghost\",\n          href: new_account_path(step: \"method_select\", classification: \"asset\"),\n          icon: \"plus\",\n          frame: :modal,\n          full_width: true,\n          class: \"justify-start\"\n        ) %>\n\n        <div>\n          <% family.balance_sheet.assets.account_groups.each do |group| %>\n            <%= render \"accounts/accountable_group\", account_group: group, mobile: mobile %>\n          <% end %>\n        </div>\n      </div>\n    <% end %>\n\n    <% tabs.with_panel(tab_id: \"liability\") do %>\n      <div class=\"space-y-2\">\n        <%= render DS::Link.new(\n            text: \"New debt\",\n            variant: \"ghost\",\n            href: new_account_path(step: \"method_select\", classification: \"liability\"),\n            icon: \"plus\",\n            frame: :modal,\n            full_width: true,\n            class: \"justify-start\"\n          ) %>\n\n        <div>\n          <% family.balance_sheet.liabilities.account_groups.each do |group| %>\n            <%= render \"accounts/accountable_group\", account_group: group, mobile: mobile %>\n          <% end %>\n        </div>\n      </div>\n    <% end %>\n\n    <% tabs.with_panel(tab_id: \"all\") do %>\n      <div class=\"space-y-2\">\n        <%= render DS::Link.new(\n            text: \"New account\",\n            variant: \"ghost\",\n            full_width: true,\n            href: new_account_path(step: \"method_select\"),\n            icon: \"plus\",\n            frame: :modal,\n            class: \"justify-start\"\n          ) %>\n\n        <div>\n          <% family.balance_sheet.account_groups.each do |group| %>\n            <%= render \"accounts/accountable_group\", account_group: group, mobile: mobile, all_tab: true %>\n          <% end %>\n        </div>\n      </div>\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/accounts/_account_type.html.erb",
    "content": "<%# locals: (accountable:) %>\n\n<%= link_to new_polymorphic_path(accountable, step: \"method_select\", return_to: params[:return_to]),\n            class: \"flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2\" do %>\n  <%= render DS::FilledIcon.new(\n    icon: accountable.icon,\n    hex_color: accountable.color,\n  ) %>\n\n  <%= accountable.display_name.singularize %>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/_accountable_group.html.erb",
    "content": "<%# locals: (account_group:, mobile: false, all_tab: false, open: nil, **args) %>\n\n<div id=\"<%= account_group.dom_id(tab: all_tab ? :all : nil, mobile: mobile) %>\">\n  <% is_open = open.nil? ? account_group.accounts.any? { |account| page_active?(account_path(account)) } : open %>\n  <%= render DS::Disclosure.new(align: :left, open: is_open) do |disclosure| %>\n    <% disclosure.with_summary_content do %>\n      <div class=\"flex items-center gap-3\">\n        <%= icon \"chevron-right\", class: \"group-open:transform group-open:rotate-90\" %>\n        <%= tag.span class: class_names(\"text-sm text-primary font-medium\", \"animate-pulse\" => account_group.syncing?) do %>\n          <%= account_group.name %>\n        <% end %>\n      </div>\n\n      <div class=\"ml-auto text-right grow\">\n        <%= tag.p format_money(account_group.total_money), class: \"text-sm font-medium text-primary\" %>\n        <%= turbo_frame_tag \"#{account_group.key}_sparkline\", src: accountable_sparkline_path(account_group.key), loading: \"lazy\", data: { controller: \"turbo-frame-timeout\", turbo_frame_timeout_timeout_value: 10000 } do %>\n          <div class=\"flex items-center w-8 h-4 ml-auto\">\n            <div class=\"w-6 h-px bg-loader\"></div>\n          </div>\n        <% end %>\n      </div>\n    <% end %>\n\n    <div class=\"space-y-1\">\n      <% account_group.accounts.each do |account| %>\n        <%= link_to account_path(account),\n                  class: class_names(\n                    \"block flex items-center gap-2 px-3 py-2 rounded-lg\",\n                    page_active?(account_path(account)) ? \"bg-container\" : \"hover:bg-surface-hover\"\n                  ),\n                  title: account.name do %>\n          <%= render \"accounts/logo\", account: account, size: \"sm\", color: account_group.color %>\n\n          <div class=\"min-w-0 grow\">\n            <div class=\"flex items-center gap-2 mb-0.5\">\n              <%= tag.p account.name, class: class_names(\"text-sm text-primary font-medium truncate\", \"animate-pulse\" => account.syncing?) %>\n            </div>\n            <%= tag.p account.short_subtype_label, class: \"text-sm text-secondary truncate\" %>\n          </div>\n\n          <div class=\"ml-auto text-right grow h-10\">\n            <%= tag.p format_money(account.balance_money), class: \"text-sm font-medium text-primary whitespace-nowrap\" %>\n            <%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: \"lazy\", data: { controller: \"turbo-frame-timeout\", turbo_frame_timeout_timeout_value: 10000 } do %>\n              <div class=\"flex items-center w-8 h-4 ml-auto\">\n                <div class=\"w-6 h-px bg-loader\"></div>\n              </div>\n            <% end %>\n          </div>\n        <% end %>\n      <% end %>\n    </div>\n\n    <div class=\"my-2\">\n      <%= render DS::Link.new(\n      href: new_polymorphic_path(account_group.key, step: \"method_select\"),\n      text: \"New #{account_group.name.downcase.singularize}\",\n      icon: \"plus\",\n      full_width: true,\n      variant: \"ghost\",\n      frame: :modal,\n      class: \"justify-start\"\n    ) %>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/accounts/_empty.html.erb",
    "content": "<div class=\"flex justify-center items-center h-[800px] text-sm\">\n  <div class=\"text-center flex flex-col items-center max-w-[300px]\">\n    <%= tag.p t(\".no_accounts\"), class: \"text-primary mb-1 font-medium\" %>\n    <%= tag.p t(\".empty_message\"), class: \"text-secondary mb-4\" %>\n\n    <%= render DS::Link.new(\n      text: t(\".new_account\"),\n      href: new_account_path,\n      frame: :modal\n    ) %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/accounts/_form.html.erb",
    "content": "<%# locals: (account:, url:) %>\n\n<% if @error_message.present? %>\n  <%= render DS::Alert.new(message: @error_message, variant: :error) %>\n<% end %>\n\n<%= styled_form_with model: account, url: url, scope: :account, data: { turbo: false }, class: \"flex flex-col gap-4 justify-between grow text-primary\" do |form| %>\n  <div class=\"grow space-y-2\">\n    <%= form.hidden_field :accountable_type %>\n    <%= form.hidden_field :return_to, value: params[:return_to] %>\n\n    <%= form.text_field :name, placeholder: t(\".name_placeholder\"), required: \"required\", label: t(\".name_label\") %>\n\n    <% unless account.linked? %>\n      <%= form.money_field :balance, label: t(\".balance\"), required: true, default_currency: Current.family.currency %>\n    <% end %>\n\n    <%= yield form %>\n  </div>\n\n  <%= form.submit %>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/_logo.html.erb",
    "content": "<%# locals: (account:, size: \"md\", color: nil) %>\n\n<% size_classes = {\n  \"sm\" => \"w-6 h-6\",\n  \"md\" => \"w-9 h-9\",\n  \"lg\" => \"w-10 h-10\",\n  \"full\" => \"w-full h-full\"\n} %>\n\n<% if account.plaid_account_id? && account.institution_domain.present? %>\n  <%= image_tag \"https://logo.synthfinance.com/#{account.institution_domain}\", class: \"shrink-0 rounded-full #{size_classes[size]}\" %>\n<% elsif account.logo.attached? %>\n  <%= image_tag account.logo, class: \"shrink-0 rounded-full #{size_classes[size]}\" %>\n<% else %>\n  <%= render DS::FilledIcon.new(variant: :text, hex_color: color || account.accountable.color, text: account.name, size: size, rounded: true) %>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/_summary_card.html.erb",
    "content": "<%# locals: (title:, content:) %>\n\n<div class=\"rounded-xl bg-container shadow-xs border border-alpha-black-25 p-4\">\n  <h4 class=\"text-secondary text-sm\"><%= title %></h4>\n  <p class=\"text-xl font-medium text-primary\">\n    <%= content %>\n  </p>\n</div>\n"
  },
  {
    "path": "app/views/accounts/index/_account_groups.erb",
    "content": "<%# locals: (accounts:) %>\n\n<% accounts.group_by(&:accountable_type).sort_by { |group, _| group }.each do |group, accounts| %>\n  <div class=\"bg-container-inset p-1 rounded-xl\">\n    <div class=\"flex items-center px-4 py-2 text-xs font-medium text-secondary\">\n      <p><%= Accountable.from_type(group).display_name %></p>\n      <span class=\"text-subdued mx-2\">&middot;</span>\n      <p><%= accounts.count %></p>\n\n      <% unless accounts.any?(&:syncing?) %>\n        <p class=\"ml-auto\"><%= totals_by_currency(collection: accounts, money_method: :balance_money) %></p>\n      <% end %>\n    </div>\n    <div class=\"bg-container rounded-lg shadow-border-xs\">\n      <% accounts.each_with_index do |account, index| %>\n        <%= render account %>\n        <% unless index == accounts.count - 1 %>\n          <%= render \"shared/ruler\" %>\n        <% end %>\n      <% end %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/index/_manual_accounts.html.erb",
    "content": "<%# locals: (accounts:) %>\n\n<details open class=\"group bg-container p-4 shadow-border-xs rounded-xl\">\n  <summary class=\"flex items-center gap-2 focus-visible:outline-hidden\">\n    <%= icon(\"chevron-right\", class: \"group-open:transform group-open:rotate-90\") %>\n\n    <div class=\"flex items-center justify-center h-8 w-8 rounded-full bg-black/5\">\n      <%= icon(\"folder-pen\") %>\n    </div>\n\n    <span class=\"mr-auto text-sm font-medium text-primary\"><%= t(\".other_accounts\") %></span>\n  </summary>\n\n  <div class=\"space-y-4 mt-4\">\n    <%= render \"accounts/index/account_groups\", accounts: accounts %>\n  </div>\n</details>\n"
  },
  {
    "path": "app/views/accounts/index.html.erb",
    "content": "<header class=\"flex justify-between items-center text-primary font-medium\">\n  <h1 class=\"text-xl\"><%= t(\".accounts\") %></h1>\n  <div class=\"flex items-center gap-5\">\n    <div class=\"flex items-center gap-2\">\n      <%= icon(\n            \"refresh-cw\",\n            as_button: true,\n            size: \"sm\",\n            href: sync_all_accounts_path,\n            disabled: Current.family.syncing?,\n            frame: :_top\n          ) %>\n      <%= render DS::Link.new(\n        text: \"New account\",\n        href: new_account_path(return_to: accounts_path),\n        variant: \"primary\",\n        icon: \"plus\",\n        frame: :modal\n      ) %>\n    </div>\n  </div>\n</header>\n\n<% if @manual_accounts.empty? && @plaid_items.empty? %>\n  <%= render \"empty\" %>\n<% else %>\n  <div class=\"space-y-2\">\n    <% if @plaid_items.any? %>\n      <%= render @plaid_items.sort_by(&:created_at) %>\n    <% end %>\n\n    <% if @manual_accounts.any? %>\n      <%= render \"accounts/index/manual_accounts\", accounts: @manual_accounts %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/new/_container.html.erb",
    "content": "<%# locals: (title:, back_path: nil) %>\n\n<%= render DS::Dialog.new do |dialog| %>\n  <div class=\"flex flex-col relative\" data-controller=\"list-keyboard-navigation\">\n    <div class=\"border-b border-tertiary md:border-alpha-black-25 px-4 pb-4 text-gray-800 flex items-center justify-between gap-2\">\n      <div class=\"flex items-center gap-2\">\n        <% if back_path %>\n          <%= render DS::Link.new(\n          variant: \"icon\",\n          icon: \"arrow-left\",\n          href: back_path,\n          size: \"lg\"\n        ) %>\n        <% end %>\n\n        <span class=\"text-primary\"><%= title %></span>\n      </div>\n\n      <%= icon(\"x\", as_button: true, size: \"lg\", data: { action: \"dialog#close\" }) %>\n    </div>\n\n    <div class=\"p-2 text-subdued\">\n      <button hidden data-controller=\"hotkey\" data-hotkey=\"k,K,ArrowUp,ArrowLeft\" data-action=\"list-keyboard-navigation#focusPrevious\">Previous</button>\n      <button hidden data-controller=\"hotkey\" data-hotkey=\"j,J,ArrowDown,ArrowRight\" data-action=\"list-keyboard-navigation#focusNext\">Next</button>\n\n      <%= yield %>\n    </div>\n\n    <div class=\"border-t border-alpha-black-25 px-4 pt-4 text-secondary text-sm justify-between hidden md:flex\">\n      <div class=\"flex space-x-5\">\n        <div class=\"flex items-center space-x-2\">\n          <span>Select</span>\n          <kbd class=\"bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center\">\n            <%= icon(\"corner-down-left\", size: \"xs\") %>\n          </kbd>\n        </div>\n        <div class=\"flex items-center space-x-2\">\n          <span>Navigate</span>\n          <kbd class=\"bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center\">\n            <%= icon(\"arrow-up\", size: \"xs\") %>\n          </kbd>\n          <kbd class=\"bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center\">\n            <%= icon(\"arrow-down\", size: \"xs\") %>\n          </kbd>\n        </div>\n      </div>\n      <div class=\"flex items-center space-x-2\">\n        <button data-action=\"dialog#close\">Close</button>\n        <kbd class=\"bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-8 h-5 shrink-0 grow-0 items-center justify-center text-xs\">ESC</kbd>\n      </div>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/new/_method_selector.html.erb",
    "content": "<%# locals: (path:, accountable_type:, show_us_link: true, show_eu_link: true) %>\n\n<%= render layout: \"accounts/new/container\", locals: { title: t(\".title\"), back_path: new_account_path } do %>\n  <div class=\"text-sm\">\n    <%= link_to path, class: \"flex items-center gap-4 w-full text-center text-primary focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-surface rounded-lg p-2\" do %>\n      <span class=\"flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]\">\n        <%= icon(\"keyboard\") %>\n      </span>\n      <%= t(\"accounts.new.method_selector.manual_entry\") %>\n    <% end %>\n\n    <% if show_us_link %>\n      <%# Default US-only Link %>\n      <%= link_to new_plaid_item_path(region: \"us\", accountable_type: accountable_type),\n                  class: \"text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2\",\n                  data: { turbo_frame: \"modal\" } do %>\n        <span class=\"flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]\">\n          <%= icon(\"link-2\") %>\n        </span>\n        <%= t(\"accounts.new.method_selector.connected_entry\") %>\n      <% end %>\n    <% end %>\n\n    <%# EU Link %>\n    <% if show_eu_link %>\n      <%= link_to new_plaid_item_path(region: \"eu\", accountable_type: accountable_type),\n                  class: \"text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2\",\n                  data: { turbo_frame: \"modal\" } do %>\n        <span class=\"flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]\">\n          <%= icon(\"link-2\") %>\n        </span>\n        <%= t(\"accounts.new.method_selector.connected_entry_eu\") %>\n      <% end %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/new.html.erb",
    "content": "<%= render layout: \"accounts/new/container\", locals: { title: t(\".title\") } do %>\n  <div class=\"text-sm\">\n    <% unless params[:classification] == \"liability\" %>\n      <%= render \"account_type\", accountable: Depository.new %>\n      <%= render \"account_type\", accountable: Investment.new %>\n      <%= render \"account_type\", accountable: Crypto.new %>\n      <%= render \"account_type\", accountable: Property.new %>\n      <%= render \"account_type\", accountable: Vehicle.new %>\n    <% end %>\n\n    <% unless params[:classification] == \"asset\" %>\n      <%= render \"account_type\", accountable: CreditCard.new %>\n      <%= render \"account_type\", accountable: Loan.new %>\n    <% end %>\n\n    <% unless params[:classification] == \"liability\" %>\n      <%= render \"account_type\", accountable: OtherAsset.new %>\n    <% end %>\n\n    <% unless params[:classification] == \"asset\" %>\n      <%= render \"account_type\", accountable: OtherLiability.new %>\n    <% end %>\n\n    <% unless params[:return_to].present? %>\n      <%= button_to imports_path(import: { type: \"AccountImport\" }),\n              data: { turbo_frame: :_top },\n            class: \"flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2\" do %>\n        <%= render DS::FilledIcon.new(\n          icon: \"download\",\n          hex_color: \"#F79009\",\n        ) %>\n\n        <%= t(\"accounts.new.import_accounts\") %>\n      <% end %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/show/_activity.html.erb",
    "content": "<%# locals: (account:) %>\n\n<%= turbo_frame_tag dom_id(account, \"entries\") do %>\n  <div class=\"bg-container p-5 shadow-border-xs rounded-xl\">\n    <div class=\"flex items-center justify-between mb-4\" data-testid=\"activity-menu\">\n      <%= tag.h2 t(\".title\"), class: \"font-medium text-lg\" %>\n      <% unless @account.plaid_account_id.present? %>\n        <%= render DS::Menu.new(variant: \"button\") do |menu| %>\n          <% menu.with_button(text: \"New\", variant: \"secondary\", icon: \"plus\") %>\n\n          <% menu.with_item(\n              variant: \"link\",\n              text: \"New balance\",\n              icon: \"circle-dollar-sign\",\n              href: new_valuation_path(account_id: @account.id),\n              data: { turbo_frame: :modal }) %>\n\n          <% unless @account.crypto? %>\n            <% href = @account.investment? ? new_trade_path(account_id: @account.id) : new_transaction_path(account_id: @account.id) %>\n            <% menu.with_item(\n                variant: \"link\",\n                text: \"New transaction\",\n                icon: \"credit-card\",\n                href: href,\n                data: { turbo_frame: :modal }) %>\n          <% end %>\n        <% end %>\n      <% end %>\n    </div>\n\n    <div>\n      <%= form_with url: account_path(account),\n              id: \"entries-search\",\n              scope: :q,\n              method: :get,\n              data: { controller: \"auto-submit-form\" } do |form| %>\n        <div class=\"flex gap-2 mb-4\">\n          <div class=\"grow\">\n            <div class=\"flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900\">\n              <%= icon(\"search\") %>\n              <%= hidden_field_tag :account_id, @account.id %>\n              <%= form.search_field :search,\n                            placeholder: \"Search entries by name\",\n                            value: @q[:search],\n                            class: \"form-field__input placeholder:text-sm placeholder:text-secondary\",\n                            \"data-auto-submit-form-target\": \"auto\" %>\n            </div>\n          </div>\n        </div>\n      <% end %>\n    </div>\n\n    <% if @entries.empty? %>\n      <p class=\"text-secondary text-sm p-4\"><%= t(\".no_entries\") %></p>\n    <% else %>\n      <%= tag.div id: dom_id(@account, \"entries_bulk_select\"),\n                data: {\n                  controller: \"bulk-select\",\n                  bulk_select_singular_label_value: t(\".entry\"),\n                  bulk_select_plural_label_value: t(\".entries\")\n                } do %>\n        <div id=\"entry-selection-bar\" data-bulk-select-target=\"selectionBar\" class=\"flex justify-center hidden\">\n          <%= render \"entries/selection_bar\" %>\n        </div>\n\n        <div class=\"grid bg-container-inset rounded-xl grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-5 py-3 mb-4\">\n          <div class=\"pl-0.5 col-span-8 flex items-center gap-4\">\n            <%= check_box_tag \"selection_entry\",\n                              class: \"checkbox checkbox--light\",\n                              data: { action: \"bulk-select#togglePageSelection\" } %>\n            <p><%= t(\".date\") %></p>\n          </div>\n          <%= tag.p t(\".amount\"), class: \"col-span-2 justify-self-end\" %>\n          <%= tag.p t(\".balance\"), class: \"col-span-2 justify-self-end\" %>\n        </div>\n\n        <div>\n          <div class=\"space-y-4\">\n            <%= entries_by_date(@entries) do |entries| %>\n              <% entries.each_with_index do |entry, index| %>\n                <%= render entry, view_ctx: \"account\" %>\n              <% end %>\n            <% end %>\n          </div>\n\n          <div class=\"p-4 bg-container rounded-bl-lg rounded-br-lg\">\n            <%= render \"shared/pagination\", pagy: @pagy %>\n          </div>\n        </div>\n      <% end %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/show/_header.html.erb",
    "content": "<%# locals: (account:, title:, subtitle: nil) %>\n\n<header class=\"space-y-4\">\n  <div class=\"flex items-center gap-4\">\n    <div class=\"flex items-center gap-3 overflow-hidden\">\n      <%= render \"accounts/logo\", account: account %>\n\n      <div class=\"flex items-center gap-2\">\n        <div class=\"truncate\">\n          <div class=\"flex items-center gap-3\">\n            <h2 class=\"font-medium text-xl truncate <%= \"animate-pulse\" if account.syncing? %>\"><%= title %></h2>\n            <% if account.draft? %>\n              <%= render DS::Link.new(\n                  text: \"Complete setup\",\n                  href: edit_account_path(account),\n                  variant: :outline,\n                  size: :sm,\n                  frame: :modal\n                ) %>\n            <% end %>\n          </div>\n          <% if subtitle.present? %>\n            <p class=\"text-sm text-secondary\"><%= subtitle %></p>\n          <% end %>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"flex items-center gap-1 ml-auto\">\n      <% if Rails.env.development? || self_hosted? %>\n        <%= icon(\n            \"refresh-cw\",\n            as_button: true,\n            size: \"sm\",\n            href: account.linked? ? sync_plaid_item_path(account.plaid_account.plaid_item) : sync_account_path(account),\n            disabled: account.syncing?,\n            frame: :_top\n          ) %>\n      <% end %>\n\n      <%= render \"accounts/show/menu\", account: account %>\n    </div>\n  </div>\n</header>\n"
  },
  {
    "path": "app/views/accounts/show/_menu.html.erb",
    "content": "<%# locals: (account:) %>\n\n<%= render DS::Menu.new(testid: \"account-menu\") do |menu| %>\n  <% menu.with_item(variant: \"link\", text: \"Edit\", href: edit_account_path(account), icon: \"pencil-line\", data: { turbo_frame: :modal }) %>\n\n  <% unless account.crypto? %>\n    <% menu.with_item(\n      variant: \"link\",\n      text: \"Import transactions\",\n      href: imports_path({ import: { type: account.investment? ? \"TradeImport\" : \"TransactionImport\", account_id: account.id } }),\n      icon: \"download\",\n      data: { turbo_frame: :_top }\n    ) %>\n  <% end %>\n\n  <% unless account.linked? %>\n    <% menu.with_item(\n      variant: \"button\",\n      text: \"Delete account\",\n      href: account_path(account),\n      method: :delete,\n      icon: \"trash-2\",\n      confirm: CustomConfirm.for_resource_deletion(\"account\", high_severity: true),\n      data: { turbo_frame: :_top }\n    ) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/show.html.erb",
    "content": "<%= render UI::AccountPage.new(\n      account: @account,\n      chart_view: @chart_view,\n      chart_period: @period,\n      active_tab: @tab\n    ) do |account_page| %>\n  <%= account_page.with_activity_feed(feed_data: @activity_feed_data, pagy: @pagy, search: @q[:search]) %>\n<% end %>\n"
  },
  {
    "path": "app/views/accounts/sparkline.html.erb",
    "content": "<%= turbo_frame_tag dom_id(@account, :sparkline) do %>\n  <div class=\"flex items-center justify-end gap-1\">\n    <div class=\"w-8 h-5\">\n      <%= render \"shared/sparkline\", id: dom_id(@account, :sparkline_chart), series: @sparkline_series %>\n    </div>\n\n    <%= tag.p @sparkline_series.trend.percent_formatted,\n                style: \"color: #{@sparkline_series.trend.color}\",\n                class: \"font-mono text-right text-xs font-medium text-primary\" %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/api/v1/accounts/index.json.jbuilder",
    "content": "# frozen_string_literal: true\n\njson.accounts @accounts do |account|\n  json.id account.id\n  json.name account.name\n  json.balance account.balance_money.format\n  json.currency account.currency\n  json.classification account.classification\n  json.account_type account.accountable_type.underscore\nend\n\njson.pagination do\n  json.page @pagy.page\n  json.per_page @per_page\n  json.total_count @pagy.count\n  json.total_pages @pagy.pages\nend\n"
  },
  {
    "path": "app/views/api/v1/chats/_chat.json.jbuilder",
    "content": "# frozen_string_literal: true\n\njson.id chat.id\njson.title chat.title\njson.error chat.error.present? ? chat.error : nil\njson.created_at chat.created_at.iso8601\njson.updated_at chat.updated_at.iso8601\n"
  },
  {
    "path": "app/views/api/v1/chats/index.json.jbuilder",
    "content": "# frozen_string_literal: true\n\njson.chats @chats do |chat|\n  json.id chat.id\n  json.title chat.title\n  json.last_message_at chat.messages.ordered.first&.created_at&.iso8601\n  json.message_count chat.messages.count\n  json.error chat.error.present? ? chat.error : nil\n  json.created_at chat.created_at.iso8601\n  json.updated_at chat.updated_at.iso8601\nend\n\njson.pagination do\n  json.page @pagy.page\n  json.per_page @pagy.vars[:items]\n  json.total_count @pagy.count\n  json.total_pages @pagy.pages\nend\n"
  },
  {
    "path": "app/views/api/v1/chats/show.json.jbuilder",
    "content": "# frozen_string_literal: true\n\njson.partial! \"chat\", chat: @chat\n\njson.messages @messages do |message|\n  json.id message.id\n  json.type message.type.underscore\n  json.role message.role\n  json.content message.content\n  json.model message.ai_model if message.type == \"AssistantMessage\"\n  json.created_at message.created_at.iso8601\n  json.updated_at message.updated_at.iso8601\n\n  # Include tool calls for assistant messages\n  if message.type == \"AssistantMessage\" && message.tool_calls.any?\n    json.tool_calls message.tool_calls do |tool_call|\n      json.id tool_call.id\n      json.function_name tool_call.function_name\n      json.function_arguments tool_call.function_arguments\n      json.function_result tool_call.function_result\n      json.created_at tool_call.created_at.iso8601\n    end\n  end\nend\n\nif @pagy\n  json.pagination do\n    json.page @pagy.page\n    json.per_page @pagy.vars[:items]\n    json.total_count @pagy.count\n    json.total_pages @pagy.pages\n  end\nend\n"
  },
  {
    "path": "app/views/api/v1/messages/show.json.jbuilder",
    "content": "# frozen_string_literal: true\n\njson.id @message.id\njson.chat_id @message.chat_id\njson.type @message.type.underscore\njson.role @message.role\njson.content @message.content\njson.model @message.ai_model if @message.type == \"AssistantMessage\"\njson.created_at @message.created_at.iso8601\njson.updated_at @message.updated_at.iso8601\n\n# Note: AI response will be processed asynchronously\nif @message.type == \"UserMessage\"\n  json.ai_response_status \"pending\"\n  json.ai_response_message \"AI response is being generated\"\nend\n"
  },
  {
    "path": "app/views/api/v1/transactions/_transaction.json.jbuilder",
    "content": "# frozen_string_literal: true\n\njson.id transaction.id\njson.date transaction.entry.date\njson.amount transaction.entry.amount_money.format\njson.currency transaction.entry.currency\njson.name transaction.entry.name\njson.notes transaction.entry.notes\njson.classification transaction.entry.classification\n\n# Account information\njson.account do\n  json.id transaction.entry.account.id\n  json.name transaction.entry.account.name\n  json.account_type transaction.entry.account.accountable_type.underscore\nend\n\n# Category information\nif transaction.category.present?\n  json.category do\n    json.id transaction.category.id\n    json.name transaction.category.name\n    json.classification transaction.category.classification\n    json.color transaction.category.color\n    json.icon transaction.category.lucide_icon\n  end\nelse\n  json.category nil\nend\n\n# Merchant information\nif transaction.merchant.present?\n  json.merchant do\n    json.id transaction.merchant.id\n    json.name transaction.merchant.name\n  end\nelse\n  json.merchant nil\nend\n\n# Tags\njson.tags transaction.tags do |tag|\n  json.id tag.id\n  json.name tag.name\n  json.color tag.color\nend\n\n# Transfer information (if this transaction is part of a transfer)\nif transaction.transfer.present?\n  json.transfer do\n    json.id transaction.transfer.id\n    json.amount transaction.transfer.amount_abs.format\n    json.currency transaction.transfer.inflow_transaction.entry.currency\n\n    # Other transaction in the transfer\n    if transaction.transfer.inflow_transaction == transaction\n      other_transaction = transaction.transfer.outflow_transaction\n    else\n      other_transaction = transaction.transfer.inflow_transaction\n    end\n\n    if other_transaction.present?\n      json.other_account do\n        json.id other_transaction.entry.account.id\n        json.name other_transaction.entry.account.name\n        json.account_type other_transaction.entry.account.accountable_type.underscore\n      end\n    end\n  end\nelse\n  json.transfer nil\nend\n\n# Additional metadata\njson.created_at transaction.created_at.iso8601\njson.updated_at transaction.updated_at.iso8601\n"
  },
  {
    "path": "app/views/api/v1/transactions/index.json.jbuilder",
    "content": "# frozen_string_literal: true\n\njson.transactions @transactions do |transaction|\n  json.partial! \"transaction\", transaction: transaction\nend\n\njson.pagination do\n  json.page @pagy.page\n  json.per_page @per_page\n  json.total_count @pagy.count\n  json.total_pages @pagy.pages\nend\n"
  },
  {
    "path": "app/views/api/v1/transactions/show.json.jbuilder",
    "content": "# frozen_string_literal: true\n\njson.partial! \"transaction\", transaction: @transaction\n"
  },
  {
    "path": "app/views/assistant_messages/_assistant_message.html.erb",
    "content": "<%# locals: (assistant_message:) %>\n\n<div id=\"<%= dom_id(assistant_message) %>\">\n  <% if assistant_message.reasoning? %>\n    <details class=\"group mb-1\">\n      <summary class=\"flex items-center gap-2\">\n        <p class=\"text-secondary text-sm\">Assistant reasoning</p>\n        <%= icon(\"chevron-down\", class: \"group-open:transform group-open:rotate-180\") %>\n      </summary>\n\n      <div class=\"prose prose--ai-chat\"><%= markdown(assistant_message.content) %></div>\n    </details>\n  <% else %>\n    <% if assistant_message.chat.debug_mode? && assistant_message.tool_calls.any? %>\n      <%= render \"assistant_messages/tool_calls\", message: assistant_message %>\n    <% end %>\n\n    <div class=\"flex items-start gap-3 mb-6\">\n      <%= render \"chats/ai_avatar\" %>\n\n      <div class=\"prose prose--ai-chat\"><%= markdown(assistant_message.content) %></div>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/assistant_messages/_tool_calls.html.erb",
    "content": "<%# locals: (message:) %>\n\n<details class=\"my-2 group mb-4\">\n  <summary class=\"text-secondary text-xs cursor-pointer flex items-center gap-2\">\n    <%= icon(\"chevron-right\", class: \"group-open:transform group-open:rotate-90\") %>\n    <p>Tool Calls</p>\n  </summary>\n\n  <div class=\"mt-2\">\n    <% message.tool_calls.each do |tool_call| %>\n      <div class=\"bg-blue-50 border-blue-200 px-3 py-2 rounded-lg border mb-2\">\n        <p class=\"text-secondary text-xs\">Function:</p>\n        <p class=\"text-primary text-sm font-mono\"><%= tool_call.function_name %></p>\n        <p class=\"text-secondary text-xs mt-2\">Arguments:</p>\n        <pre class=\"text-primary text-sm font-mono whitespace-pre-wrap\"><%= tool_call.function_arguments %></pre>\n      </div>\n    <% end %>\n  </div>\n</details>\n"
  },
  {
    "path": "app/views/budget_categories/_allocation_progress.erb",
    "content": "<%# locals: (budget:) %>\n\n<div id=\"<%= dom_id(budget, :allocation_progress) %>\" class=\"space-y-2 mb-6\">\n  <div class=\"flex items-center gap-2\">\n    <% if budget.available_to_allocate.negative? %>\n      <div class=\"rounded-full w-1.5 h-1.5 bg-red-500\"></div>\n    <% else %>\n      <div class=\"rounded-full w-1.5 h-1.5 <%= budget.allocated_spending > 0 ? \"bg-gray-900\" : \"bg-gray-100\" %>\"></div>\n    <% end %>\n\n    <% if budget.available_to_allocate.negative? %>\n      <p class=\"text-primary text-sm\">&gt; 100% set</p>\n    <% else %>\n      <p class=\"text-secondary text-sm\">\n        <%= number_to_percentage(budget.allocated_percent, precision: 0) %> set\n      </p>\n    <% end %>\n\n    <p class=\"ml-auto text-sm space-x-1\">\n      <span class=\"<%= budget.available_to_allocate.negative? ? \"text-red-500\" : \"text-primary\" %>\"><%= format_money(budget.allocated_spending_money) %></span>\n      <span class=\"text-secondary\"> / </span>\n      <span class=\"text-secondary\"><%= format_money(budget.budgeted_spending_money) %></span>\n    </p>\n  </div>\n\n  <div class=\"relative h-1.5 rounded-2xl bg-gray-100\">\n    <% if budget.available_to_allocate.negative? %>\n      <div class=\"absolute inset-0 bg-red-500 rounded-2xl\" style=\"width: 100%;\"></div>\n    <% else %>\n      <div class=\"absolute inset-0 bg-gray-900 rounded-2xl\" style=\"width: <%= budget.allocated_percent %>%;\"></div>\n    <% end %>\n  </div>\n\n  <div class=\"text-sm\">\n    <% if budget.available_to_allocate.negative? %>\n      <p class=\"text-secondary\">\n        Budget exceeded by <span class=\"text-red-500\"><%= format_money(budget.available_to_allocate_money.abs) %></span>\n      </p>\n    <% else %>\n      <span class=\"text-primary\"><%= format_money(budget.available_to_allocate_money) %></span>\n      <span class=\"text-secondary\">left to allocate</span>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/budget_categories/_budget_category.html.erb",
    "content": "<%# locals: (budget_category:) %>\n\n<%= turbo_frame_tag dom_id(budget_category), class: \"w-full\" do %>\n  <%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: \"group w-full p-4 flex items-center gap-3 bg-container\", data: { turbo_frame: \"drawer\" } do %>\n\n    <% if budget_category.initialized? %>\n      <div class=\"w-10 h-10 group-hover:scale-105 transition-all duration-300\">\n        <%= render \"budget_categories/budget_category_donut\", budget_category: budget_category %>\n      </div>\n    <% else %>\n      <div class=\"w-8 h-8 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center\" style=\"color: <%= budget_category.category.color %>\">\n        <% if budget_category.category.lucide_icon %>\n          <%= icon(budget_category.category.lucide_icon, color: \"current\") %>\n        <% else %>\n          <%= render DS::FilledIcon.new(\n            variant: :text,\n            hex_color: budget_category.category.color,\n            text: budget_category.category.name,\n            size: \"sm\",\n            rounded: true\n          ) %>\n        <% end %>\n      </div>\n    <% end %>\n\n    <div>\n      <p class=\"text-sm font-medium text-primary\"><%= budget_category.category.name %></p>\n\n      <% if budget_category.initialized? %>\n        <% if budget_category.available_to_spend.negative? %>\n          <p class=\"text-sm font-medium text-red-500\"><%= format_money(budget_category.available_to_spend_money.abs) %> over</p>\n        <% elsif budget_category.available_to_spend.zero? %>\n          <p class=\"text-sm font-medium <%= budget_category.budgeted_spending.positive? ? \"text-orange-500\" : \"text-secondary\" %>\">\n            <%= format_money(budget_category.available_to_spend_money) %> left\n          </p>\n        <% else %>\n          <p class=\"text-sm text-secondary font-medium\"><%= format_money(budget_category.available_to_spend_money) %> left</p>\n        <% end %>\n      <% else %>\n        <p class=\"text-sm text-secondary font-medium\">\n          <%= budget_category.median_monthly_expense_money.format %> avg\n        </p>\n      <% end %>\n    </div>\n\n    <div class=\"ml-auto text-right\">\n      <p class=\"text-sm font-medium text-primary\"><%= format_money(budget_category.actual_spending_money) %></p>\n\n      <% if budget_category.initialized? %>\n        <p class=\"text-sm text-secondary\">from <%= format_money(budget_category.budgeted_spending_money) %></p>\n      <% end %>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/budget_categories/_budget_category_donut.html.erb",
    "content": "<%# locals: (budget_category:) %>\n\n<%= tag.div data: {\n  controller: \"donut-chart\",\n  donut_chart_segments_value: budget_category.to_donut_segments_json,\n  donut_chart_segment_height_value: 5,\n  donut_chart_segment_opacity_value: 0.2\n}, class: \"relative h-full\" do %>\n  <div data-donut-chart-target=\"chartContainer\" class=\"absolute inset-0 pointer-events-none\"></div>\n\n  <div data-donut-chart-target=\"contentContainer\" class=\"flex justify-center items-center h-full p-1\">\n    <div data-donut-chart-target=\"defaultContent\" class=\"h-full w-full rounded-full flex flex-col items-center justify-center\" style=\"background-color: <%= hex_with_alpha(budget_category.category.color, 0.05) %>\">\n      <% if budget_category.category.lucide_icon %>\n        <span style=\"color: <%= budget_category.category.color %>\">\n          <%= icon(budget_category.category.lucide_icon, size: \"sm\", color: \"current\") %>\n        </span>\n      <% else %>\n        <span class=\"text-sm uppercase\" style=\"color: <%= budget_category.category.color %>\">\n          <%= budget_category.category.name.first.upcase %>\n        </span>\n      <% end %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/budget_categories/_budget_category_form.html.erb",
    "content": "<%# locals: (budget_category:) %>\n\n<% currency = Money::Currency.new(budget_category.budget.currency) %>\n\n<div id=\"<%= dom_id(budget_category, :form) %>\" class=\"w-full flex gap-3\">\n  <div class=\"w-1 h-3 rounded-xl mt-1\" style=\"background-color: <%= budget_category.category.color %>\"></div>\n\n  <div class=\"text-sm mr-3\">\n    <p class=\"text-primary font-medium mb-0.5\"><%= budget_category.category.name %></p>\n\n    <p class=\"text-secondary\"><%= budget_category.median_monthly_expense_money.format(precision: 0) %>/m avg</p>\n  </div>\n\n  <div class=\"ml-auto\">\n    <%= form_with model: [budget_category.budget, budget_category], data: { controller: \"auto-submit-form preserve-focus\" } do |f| %>\n      <div class=\"form-field w-[120px]\">\n        <div class=\"flex items-center\">\n          <span class=\"text-secondary text-sm mr-2\"><%= currency.symbol %></span>\n          <%= f.number_field :budgeted_spending,\n                            class: \"form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\",\n                            placeholder: \"0\",\n                            step: currency.step,\n                            id: dom_id(budget_category, :budgeted_spending),\n                            min: 0,\n                            max: budget_category.max_allocation,\n                            data: { auto_submit_form_target: \"auto\" } %>\n        </div>\n      </div>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/budget_categories/_confirm_button.html.erb",
    "content": "<div id=\"<%= dom_id(budget, :confirm_button) %>\">\n  <%= render DS::Button.new(\n    text: \"Confirm\",\n    variant: \"primary\",\n    full_width: true,\n    href: budget_path(budget),\n    method: :get,\n    disabled: !budget.allocations_valid?\n  ) %>\n</div>\n"
  },
  {
    "path": "app/views/budget_categories/_no_categories.html.erb",
    "content": "<div class=\"flex justify-center items-center\">\n  <div class=\"text-center flex flex-col items-center max-w-[500px]\">\n    <h2 class=\"text-lg text-primary font-medium\">Oops!</h2>\n    <p class=\"text-secondary text-sm max-w-sm mx-auto mb-4\">\n      You have not created or assigned any expense categories to your transactions yet.\n    </p>\n\n    <div class=\"flex items-center gap-2\">\n      <%= render DS::Button.new(\n        text: \"Use defaults (recommended)\",\n        href: bootstrap_categories_path,\n      ) %>\n\n      <%= render DS::Link.new(\n        text: \"New category\",\n        variant: \"outline\",\n        icon: \"plus\",\n        href: new_category_path,\n        frame: :modal,\n      ) %>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/budget_categories/_uncategorized_budget_category_form.html.erb",
    "content": "<%# locals: (budget:) %>\n\n<% budget_category = budget.uncategorized_budget_category %>\n\n<div id=\"<%= dom_id(budget, :uncategorized_budget_category_form) %>\" class=\"flex gap-3\">\n  <div class=\"w-1 h-3 rounded-xl mt-1\" style=\"background-color: <%= budget_category.category.color %>\"></div>\n\n  <div class=\"text-sm mr-3\">\n    <p class=\"text-primary font-medium mb-0.5\"><%= budget_category.category.name %></p>\n    <p class=\"text-secondary\"><%= budget_category.avg_monthly_expense_money.format(precision: 0) %>/m avg</p>\n  </div>\n\n  <div class=\"ml-auto\">\n    <div class=\"form-field w-[120px]\">\n      <div class=\"flex items-center\">\n        <span class=\"text-subdued text-sm mr-2\"><%= budget_category.budgeted_spending_money.currency.symbol %></span>\n        <%= text_field_tag :uncategorized, budget_category.budgeted_spending_money.amount, autocomplete: \"off\", class: \"form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none\", disabled: true %>\n      </div>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/budget_categories/index.html.erb",
    "content": "<%= content_for :header_nav do %>\n  <%= render \"budgets/budget_nav\", budget: @budget %>\n<% end %>\n\n<%= content_for :previous_path, edit_budget_path(@budget) %>\n<%= content_for :cancel_path, budget_path(@budget) %>\n\n<div>\n  <div class=\"space-y-6\">\n    <div class=\"text-center space-y-2\">\n      <h1 class=\"text-3xl text-primary font-medium\">Edit your category budgets</h1>\n      <p class=\"text-secondary text-sm max-w-md mx-auto\">\n        Adjust category budgets to set spending limits. Unallocated funds will be automatically assigned as uncategorized.\n      </p>\n    </div>\n\n    <div class=\"mx-auto max-w-lg\">\n      <% if @budget.family.categories.empty? %>\n        <div class=\"bg-container shadow-border-xs rounded-lg p-4\">\n          <%= render \"budget_categories/no_categories\" %>\n        </div>\n      <% else %>\n        <div class=\"max-w-md mx-auto\">\n          <%= render \"budget_categories/allocation_progress\", budget: @budget %>\n\n          <div class=\"space-y-4 mb-4\">\n            <% BudgetCategory::Group.for(@budget_categories).sort_by(&:name).each do |group| %>\n              <div class=\"space-y-4\">\n                <%= render \"budget_categories/budget_category_form\", budget_category: group.budget_category %>\n\n                <div class=\"space-y-4\">\n                  <% group.budget_subcategories.each do |budget_subcategory| %>\n                    <div class=\"w-full flex items-center gap-4\">\n                      <div class=\"ml-4 flex items-center justify-center text-subdued\">\n                        <%= icon(\"corner-down-right\") %>\n                      </div>\n\n                      <%= render \"budget_categories/budget_category_form\", budget_category: budget_subcategory %>\n                    </div>\n                  <% end %>\n                </div>\n              </div>\n            <% end %>\n\n            <%= render \"budget_categories/uncategorized_budget_category_form\", budget: @budget %>\n          </div>\n\n          <%= render \"budget_categories/confirm_button\", budget: @budget %>\n        </div>\n      <% end %>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/budget_categories/show.html.erb",
    "content": "<%= render DS::Dialog.new(variant: :drawer) do |dialog| %>\n  <% dialog.with_header do %>\n    <div>\n      <p class=\"text-sm text-secondary\">Category</p>\n      <h3 class=\"text-2xl font-medium text-primary\">\n        <%= @budget_category.name %>\n      </h3>\n\n      <% if @budget_category.budget.initialized? %>\n        <p class=\"text-sm text-secondary\">\n          <span class=\"text-primary\">\n            <%= format_money(@budget_category.actual_spending_money) %>\n          </span>\n          <span>/</span>\n          <span><%= format_money(@budget_category.budgeted_spending_money) %></span>\n        </p>\n      <% end %>\n    </div>\n\n    <% if @budget_category.budget.initialized? %>\n      <div class=\"ml-auto w-10 h-10\">\n        <%= render \"budget_categories/budget_category_donut\",\n                    budget_category: @budget_category %>\n      </div>\n    <% end %>\n  <% end %>\n\n  <% dialog.with_body do %>\n    <% dialog.with_section(title: \"Overview\", open: true) do %>\n      <div class=\"pb-4\">\n        <dl class=\"space-y-3 px-3 py-2\">\n          <div class=\"flex items-center justify-between text-sm\">\n            <dt class=\"text-secondary\">\n              <%= @budget_category.budget.start_date.strftime(\"%b %Y\") %> spending\n            </dt>\n            <dd class=\"text-primary font-medium\">\n              <%= format_money @budget_category.actual_spending_money %>\n            </dd>\n          </div>\n\n          <% if @budget_category.budget.initialized? %>\n            <div class=\"flex items-center justify-between text-sm\">\n              <dt class=\"text-secondary\">Status</dt>\n              <% if @budget_category.available_to_spend.negative? %>\n                <dd class=\"flex items-center gap-1 text-red-500 font-medium\">\n                  <%= icon \"alert-circle\", size: \"sm\", color: \"destructive\" %>\n                  <%= format_money @budget_category.available_to_spend_money.abs %>\n                  <span>overspent</span>\n                </dd>\n              <% elsif @budget_category.available_to_spend.zero? %>\n                <dd class=\"flex items-center gap-1 text-orange-500 font-medium\">\n                  <%= icon \"x-circle\", size: \"sm\", color: \"warning\" %>\n                  <%= format_money @budget_category.available_to_spend_money %>\n                  <span>left</span>\n                </dd>\n              <% else %>\n                <dd class=\"text-primary flex items-center gap-1 text-green-500 font-medium\">\n                  <%= icon \"check-circle\", size: \"sm\", color: \"success\" %>\n                  <%= format_money @budget_category.available_to_spend_money %>\n                  <span>left</span>\n                </dd>\n              <% end %>\n            </div>\n\n            <div class=\"flex items-center justify-between text-sm\">\n              <dt class=\"text-secondary\">Budgeted</dt>\n              <dd class=\"text-primary font-medium\">\n                <%= format_money @budget_category.budgeted_spending_money %>\n              </dd>\n            </div>\n          <% end %>\n\n          <div class=\"flex items-center justify-between text-sm\">\n            <dt class=\"text-secondary\">Monthly average spending</dt>\n            <dd class=\"text-primary font-medium\">\n              <%= @budget_category.avg_monthly_expense_money.format %>\n            </dd>\n          </div>\n\n          <div class=\"flex items-center justify-between text-sm\">\n            <dt class=\"text-secondary\">Monthly median spending</dt>\n            <dd class=\"text-primary font-medium\">\n              <%= @budget_category.median_monthly_expense_money.format %>\n            </dd>\n          </div>\n        </dl>\n      </div>\n    <% end %>\n\n    <% dialog.with_section(title: \"Recent Transactions\", open: true) do %>\n      <div class=\"space-y-2\">\n        <div class=\"px-3 py-4 space-y-2\">\n          <% if @recent_transactions.any? %>\n            <ul class=\"space-y-2 mb-4\">\n              <% @recent_transactions.each_with_index do |transaction, index| %>\n                <li class=\"flex gap-4 text-sm space-y-1\">\n                  <div class=\"flex flex-col items-center gap-1.5 pt-2\">\n                    <div class=\"rounded-full h-1.5 w-1.5 bg-gray-300\"></div>\n                    <% unless index == @recent_transactions.length - 1 %>\n                      <div class=\"h-12 w-px bg-alpha-black-200\"></div>\n                    <% end %>\n                  </div>\n\n                  <div class=\"flex justify-between w-full\">\n                    <div>\n                      <p class=\"text-secondary text-xs uppercase\">\n                        <%= transaction.entry.date.strftime(\"%b %d\") %>\n                      </p>\n                      <%= link_to transaction.entry.name,\n                                  transactions_path,\n                                  class: \"text-primary hover:underline\",\n                                  data: { turbo_frame: :_top } %>\n                    </div>\n                    <p class=\"text-primary font-medium\">\n                      <%= format_money transaction.entry.amount_money %>\n                    </p>\n                  </div>\n                </li>\n              <% end %>\n            </ul>\n\n            <%= render DS::Link.new(\n              text: \"View all category transactions\",\n              variant: \"outline\",\n              full_width: true,\n              href: transactions_path(q: {\n                categories: [@budget_category.name],\n                start_date: @budget.start_date,\n                end_date: @budget.end_date\n              }),\n              frame: :_top\n            ) %>\n          <% else %>\n            <p class=\"text-secondary text-sm mb-4\">\n              No transactions found for this budget period.\n            </p>\n          <% end %>\n        </div>\n      </div>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/budget_categories/update.turbo_stream.erb",
    "content": "<%= turbo_stream.replace dom_id(@budget, :allocation_progress), partial: \"budget_categories/allocation_progress\", locals: { budget: @budget } %>\n\n<%= turbo_stream.replace dom_id(@budget, :uncategorized_budget_category_form), partial: \"budget_categories/uncategorized_budget_category_form\", locals: { budget: @budget } %>\n\n<%= turbo_stream.replace dom_id(@budget, :confirm_button), partial: \"budget_categories/confirm_button\", locals: { budget: @budget } %>\n\n<% if @budget_category.subcategory? %>\n  <%# Update sibling subcategories when a subcategory changes %>\n  <% @budget_category.siblings.each do |sibling| %>\n    <%= turbo_stream.update dom_id(sibling, :form), partial: \"budget_categories/budget_category_form\", locals: { budget_category: sibling } %>\n  <% end %>\n\n<% else %>\n  <%# Update all subcategories when a parent category changes %>\n  <% @budget_category.subcategories.each do |subcategory| %>\n    <%= turbo_stream.update dom_id(subcategory, :form), partial: \"budget_categories/budget_category_form\", locals: { budget_category: subcategory } %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/budgets/_actuals_summary.html.erb",
    "content": "<%# locals: (budget:) %>\n\n<div>\n  <div class=\"p-4 border-b border-secondary\">\n    <h3 class=\"text-sm text-secondary mb-2\">Income</h3>\n\n    <span class=\"inline-block mb-2 text-xl font-medium text-primary\">\n      <%= budget.actual_income_money.format %>\n    </span>\n\n    <% if budget.income_category_totals.any? %>\n      <div>\n        <div class=\"flex h-1.5 mb-3 gap-1\">\n          <% budget.income_category_totals.each do |category_total| %>\n            <div class=\"h-full rounded-full\" style=\"background-color: <%= category_total.category.color %>; width: <%= category_total.weight %>%\"></div>\n          <% end %>\n        </div>\n\n        <div class=\"flex flex-wrap gap-x-2.5 gap-y-1 text-xs\">\n          <% budget.income_category_totals.each do |category_total| %>\n            <div class=\"flex items-center gap-1.5\">\n              <div class=\"w-2.5 h-2.5 rounded-full shrink-0\" style=\"background-color: <%= category_total.category.color %>\"></div>\n              <span class=\"text-secondary\"><%= category_total.category.name %></span>\n              <span class=\"text-primary\"><%= number_to_percentage(category_total.weight, precision: 0) %></span>\n            </div>\n          <% end %>\n        </div>\n      </div>\n    <% end %>\n  </div>\n\n  <div class=\"p-4\">\n    <h3 class=\"text-sm text-secondary mb-2\">Expenses</h3>\n\n    <span class=\"inline-block mb-2 text-xl font-medium text-primary\"><%= budget.actual_spending_money.format %></span>\n\n    <% if budget.expense_category_totals.any? %>\n      <div>\n        <div class=\"flex h-1.5 mb-3 gap-1\">\n          <% budget.expense_category_totals.each do |category_total| %>\n            <div class=\"h-full rounded-full\" style=\"background-color: <%= category_total.category.color %>; width: <%= category_total.weight %>%\"></div>\n          <% end %>\n        </div>\n\n        <div class=\"flex flex-wrap gap-x-2.5 gap-y-1 text-xs\">\n          <% budget.expense_category_totals.each do |category_total| %>\n            <div class=\"flex items-center gap-1.5\">\n              <div class=\"w-2.5 h-2.5 rounded-full shrink-0\" style=\"background-color: <%= category_total.category.color %>\"></div>\n              <span class=\"text-secondary\"><%= category_total.category.name %></span>\n              <span class=\"text-primary\"><%= number_to_percentage(category_total.weight, precision: 0) %></span>\n            </div>\n          <% end %>\n        </div>\n      </div>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/budgets/_budget_categories.html.erb",
    "content": "<%# locals: (budget:) %>\n\n<div>\n  <div class=\"flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase\">\n    <p>Categories</p>\n    <span class=\"text-subdued\">&middot;</span>\n    <p><%= budget.budget_categories.count %></p>\n\n    <p class=\"ml-auto\">Amount</p>\n  </div>\n\n  <div class=\"bg-container py-1 shadow-border-xs rounded-md\">\n    <% if budget.family.categories.expenses.empty? %>\n      <div class=\"py-8\">\n        <%= render \"budget_categories/no_categories\" %>\n      </div>\n    <% else %>\n      <% category_groups = BudgetCategory::Group.for(budget.budget_categories) %>\n\n      <% category_groups.each_with_index do |group, index| %>\n        <div>\n          <%= render \"budget_categories/budget_category\", budget_category: group.budget_category %>\n\n          <div>\n            <% group.budget_subcategories.each do |budget_subcategory| %>\n              <div class=\"w-full flex items-center -mt-4\">\n                <div class=\"ml-8 flex items-center justify-center text-subdued\">\n                  <%= icon \"corner-down-right\" %>\n                </div>\n\n                <%= render \"budget_categories/budget_category\", budget_category: budget_subcategory %>\n              </div>\n            <% end %>\n          </div>\n        </div>\n\n        <%= render \"shared/ruler\" %>\n      <% end %>\n\n      <%= render \"budget_categories/budget_category\", budget_category: budget.uncategorized_budget_category %>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/budgets/_budget_donut.html.erb",
    "content": "<%= tag.div data: { controller: \"donut-chart\", donut_chart_segments_value: budget.to_donut_segments_json }, class: \"relative h-full\" do %>\n  <div data-donut-chart-target=\"chartContainer\" class=\"absolute inset-0 pointer-events-none\"></div>\n\n  <div data-donut-chart-target=\"contentContainer\" class=\"flex justify-center items-center h-full\">\n    <div data-donut-chart-target=\"defaultContent\" class=\"flex flex-col items-center\">\n      <% if budget.initialized? %>\n        <div class=\"text-gray-600 text-sm mb-2\">\n          <span>Spent</span>\n        </div>\n\n        <div class=\"mb-2 text-3xl font-medium <%= budget.available_to_spend.negative? ? \"text-red-500\" : \"text-primary\" %>\">\n          <%= format_money(budget.actual_spending_money) %>\n        </div>\n\n        <%= render DS::Link.new(\n          text: \"of #{budget.budgeted_spending_money.format}\",\n          variant: \"secondary\",\n          icon: \"pencil\",\n          icon_position: \"right\",\n          size: \"sm\",\n          href: edit_budget_path(budget)\n        ) %>\n      <% else %>\n        <div class=\"text-subdued text-3xl mb-2\">\n          <span><%= format_money Money.new(0, budget.currency || budget.family.currency) %></span>\n        </div>\n\n        <%= render DS::Link.new(\n          text: \"New budget\",\n          size: \"sm\",\n          icon: \"plus\",\n          href: edit_budget_path(budget)\n        ) %>\n      <% end %>\n    </div>\n\n    <% budget.budget_categories.each do |bc| %>\n      <div id=\"segment_<%= bc.id %>\" class=\"hidden\">\n        <div class=\"flex flex-col gap-2 items-center\">\n          <div class=\"flex items-center gap-3\">\n            <div class=\"w-1 h-3 rounded-xl\" style=\"background-color: <%= bc.category.color %>\"></div>\n            <p class=\"text-sm text-secondary\"><%= bc.category.name %></p>\n          </div>\n\n          <p class=\"text-3xl font-medium <%= bc.available_to_spend.negative? ? \"text-red-500\" : \"text-primary\" %>\">\n            <%= format_money(bc.actual_spending_money) %>\n          </p>\n\n          <%= render DS::Link.new(\n            text: \"of #{bc.budgeted_spending_money.format(precision: 0)}\",\n            variant: \"secondary\",\n            icon: \"pencil\",\n            icon_position: \"right\",\n            size: \"sm\",\n            href: budget_budget_categories_path(budget)\n          ) %>\n        </div>\n      </div>\n    <% end %>\n\n    <div id=\"segment_unused\" class=\"hidden\">\n      <p class=\"text-sm text-secondary text-center mb-2\">Unused</p>\n\n      <p class=\"text-3xl font-medium text-primary\">\n        <%= format_money(budget.available_to_spend_money) %>\n      </p>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/budgets/_budget_header.html.erb",
    "content": "<%# locals: (budget:, previous_budget:, next_budget:, latest_budget:) %>\n\n<div class=\"flex items-center gap-1 mb-4\">\n  <div class=\"flex items-center gap-2\">\n    <% if budget.previous_budget_param %>\n      <%= render DS::Link.new(\n        variant: \"icon\",\n        icon: \"chevron-left\",\n        href: budget_path(budget.previous_budget_param),\n      ) %>\n    <% else %>\n      <span class=\"text-subdued\">\n        <%= icon \"chevron-left\", color: \"current\" %>\n      </span>\n    <% end %>\n\n    <% if budget.next_budget_param %>\n      <%= render DS::Link.new(\n        variant: \"icon\",\n        icon: \"chevron-right\",\n        href: budget_path(budget.next_budget_param),\n      ) %>\n    <% else %>\n      <span class=\"text-subdued\">\n        <%= icon \"chevron-right\", color: \"current\" %>\n      </span>\n    <% end %>\n  </div>\n\n  <%= render DS::Menu.new(variant: \"button\") do |menu| %>\n    <% menu.with_button class: \"flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2\"  do %>\n      <span class=\"text-primary font-medium text-lg lg:text-base\"><%= @budget.name %></span>\n      <%= icon(\"chevron-down\") %>\n    <% end %>\n\n    <% menu.with_custom_content do %>\n      <%= render \"budgets/picker\", family: Current.family, year: budget.start_date.year %>\n    <% end %>\n  <% end %>\n\n  <div class=\"ml-auto\">\n    <%= render DS::Link.new(\n        text: \"Today\",\n        variant: \"outline\",\n        href: budget_path(Budget.date_to_param(Date.current)),\n    ) %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/budgets/_budget_nav.html.erb",
    "content": "<%# locals: (budget:) %>\n\n<% steps = [\n  { name: \"Setup\", path: edit_budget_path(budget), is_complete: budget.initialized?, step_number: 1 },\n  { name: \"Categories\", path: budget_budget_categories_path(budget), is_complete: budget.allocations_valid?, step_number: 2 },\n] %>\n\n<ul class=\"flex items-center gap-2\">\n  <% steps.each_with_index do |step, idx| %>\n    <li class=\"flex items-center gap-2 group\">\n      <% is_current = request.path == step[:path] %>\n\n      <% text_class = if is_current\n                  \"text-primary\"\n                else\n                  step[:is_complete] ? \"text-green-600\" : \"text-secondary\"\n                end %>\n      <% step_class = if is_current\n                  \"bg-primary text-primary\"\n                else\n                  step[:is_complete] ? \"bg-green-600/10 border-alpha-black-25\" : \"bg-container-inset\"\n                end %>\n\n      <%= link_to step[:path], class: \"flex items-center gap-3\" do %>\n        <div class=\"flex items-center gap-2 text-sm font-medium <%= text_class %>\">\n          <span class=\"<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent\">\n            <%= step[:is_complete] && !is_current ? icon(\"check\", size: \"sm\") : idx + 1 %>\n          </span>\n\n          <span><%= step[:name] %></span>\n        </div>\n      <% end %>\n\n      <hr class=\"border border-secondary w-12 group-last:hidden\">\n    </li>\n  <% end %>\n</ul>\n"
  },
  {
    "path": "app/views/budgets/_budgeted_summary.html.erb",
    "content": "<%# locals: (budget:) %>\n\n<div>\n  <div class=\"p-4 border-b border-secondary\">\n    <h3 class=\"text-sm text-secondary mb-2\">Expected income</h3>\n\n    <span class=\"inline-block mb-2 text-xl font-medium text-primary\">\n      <%= format_money(budget.expected_income_money) %>\n    </span>\n\n    <div>\n      <div class=\"flex h-1.5 mb-3 gap-1\">\n        <% if budget.remaining_expected_income.negative? %>\n          <div class=\"rounded-md h-1.5 bg-green-500\" style=\"width: <%= 100 - budget.surplus_percent %>%\"></div>\n          <div class=\"rounded-md h-1.5 bg-green-500\" style=\"width: <%= budget.surplus_percent %>%\"></div>\n        <% else %>\n          <div class=\"rounded-md h-1.5 bg-green-500\" style=\"width: <%= budget.actual_income_percent %>%\"></div>\n          <div class=\"rounded-md h-1.5 bg-surface-inset\" style=\"width: <%= 100 - budget.actual_income_percent %>%\"></div>\n        <% end %>\n      </div>\n      <div class=\"flex justify-between text-sm\">\n        <p class=\"text-secondary\"><%= format_money(budget.actual_income_money) %> earned</p>\n        <p class=\"font-medium\">\n          <% if budget.remaining_expected_income.negative? %>\n            <span class=\"text-green-500\"><%= format_money(budget.remaining_expected_income_money.abs) %> over</span>\n          <% else %>\n            <span class=\"text-primary\"><%= format_money(budget.remaining_expected_income_money) %> left</span>\n          <% end %>\n        </p>\n      </div>\n    </div>\n  </div>\n\n  <div class=\"p-4\">\n    <h3 class=\"text-sm text-secondary mb-2\">Budgeted</h3>\n\n    <span class=\"inline-block mb-2 text-xl font-medium text-primary\">\n      <%= format_money(budget.budgeted_spending_money) %>\n    </span>\n\n    <div>\n      <div class=\"flex h-1.5 mb-3 gap-1\">\n        <% if budget.available_to_spend.negative? %>\n          <div class=\"rounded-md h-1.5 bg-inverse\" style=\"width: <%= 100 - budget.overage_percent %>%\"></div>\n          <div class=\"rounded-md h-1.5 bg-destructive\" style=\"width: <%= budget.overage_percent %>%\"></div>\n        <% else %>\n          <div class=\"rounded-md h-1.5 bg-inverse\" style=\"width: <%= budget.percent_of_budget_spent %>%\"></div>\n          <div class=\"rounded-md h-1.5 bg-surface-inset\" style=\"width: <%= 100 - budget.percent_of_budget_spent %>%\"></div>\n        <% end %>\n      </div>\n      <div class=\"flex justify-between text-sm\">\n        <p class=\"text-secondary\"><%= format_money(budget.actual_spending_money) %> spent</p>\n        <p class=\"font-medium\">\n          <% if budget.available_to_spend.negative? %>\n            <span class=\"text-destructive\"><%= format_money(budget.available_to_spend_money.abs) %> over</span>\n          <% else %>\n            <span class=\"text-primary\"><%= format_money(budget.available_to_spend_money) %> left</span>\n          <% end %>\n        </p>\n      </div>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/budgets/_over_allocation_warning.html.erb",
    "content": "<%# locals: (budget:) %>\n\n<div class=\"flex flex-col gap-4 items-center justify-center h-full\">\n  <%= icon \"alert-triangle\", size: \"lg\", color: \"destructive\" %>\n  <p class=\"text-secondary text-sm text-center\">You have over-allocated your budget.  Please fix your allocations.</p>\n\n  <%= render DS::Link.new(\n    text: \"Fix allocations\",\n    variant: \"secondary\",\n    size: \"sm\",\n    icon: \"pencil\",\n    icon_position: \"right\",\n    href: budget_budget_categories_path(budget)\n  ) %>\n</div>\n"
  },
  {
    "path": "app/views/budgets/_picker.html.erb",
    "content": "<%# locals: (family:, year:) %>\n\n<%= turbo_frame_tag \"budget_picker\" do %>\n  <div class=\"p-3 space-y-4\">\n    <div class=\"flex items-center gap-2 justify-between\">\n      <% last_month_of_previous_year = Date.new(year - 1, 12, 1) %>\n\n      <% if Budget.budget_date_valid?(last_month_of_previous_year, family: family) %>\n        <%= link_to picker_budgets_path(year: year - 1), data: { turbo_frame: \"budget_picker\" }, class: \"p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md\" do %>\n          <%= icon \"chevron-left\" %>\n        <% end %>\n      <% else %>\n        <span class=\"p-2 flex items-center justify-center text-subdued rounded-md\">\n          <%= icon \"chevron-left\", color: \"current\" %>\n        </span>\n      <% end %>\n\n      <span class=\"w-40 text-center px-3 py-2 border border-tertiary rounded-md\" data-budget-picker-target=\"year\">\n        <%= year %>\n      </span>\n\n      <% first_month_of_next_year = Date.new(year + 1, 1, 1) %>\n\n      <% if Budget.budget_date_valid?(first_month_of_next_year, family: family) %>\n        <%= link_to picker_budgets_path(year: year + 1), data: { turbo_frame: \"budget_picker\" }, class: \"p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md\" do %>\n          <%= icon \"chevron-right\" %>\n        <% end %>\n      <% else %>\n        <span class=\"p-2 flex items-center justify-center text-subdued rounded-md\">\n          <%= icon \"chevron-right\", color: \"current\" %>\n        </span>\n      <% end %>\n    </div>\n\n    <div class=\"grid grid-cols-3 gap-2 text-sm text-center font-medium\">\n      <% Date::ABBR_MONTHNAMES.compact.each do |month_name| %>\n        <% date = Date.strptime(\"#{month_name}-#{year}\", \"%b-%Y\") %>\n        <% param_key = Budget.date_to_param(date) %>\n\n        <% if Budget.budget_date_valid?(date, family: family) %>\n          <%= render DS::Link.new(\n            variant: \"ghost\",\n            text: month_name,\n            href: budget_path(param_key),\n            full_width: true,\n            frame: :_top\n          ) %>\n        <% else %>\n          <span class=\"px-3 py-2 text-subdued rounded-md\"><%= month_name %></span>\n        <% end %>\n      <% end %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/budgets/edit.html.erb",
    "content": "<%= content_for :header_nav do %>\n  <%= render \"budgets/budget_nav\", budget: @budget %>\n<% end %>\n\n<%= content_for :previous_path, budget_path(@budget) %>\n<%= content_for :cancel_path, budget_path(@budget) %>\n\n<div>\n  <div class=\"space-y-4\">\n    <div class=\"text-center space-y-2\">\n      <h1 class=\"text-3xl text-primary font-medium\">Setup your budget</h1>\n      <p class=\"text-secondary text-sm max-w-sm mx-auto\">\n        Enter your monthly earnings and planned spending below to setup your budget.\n      </p>\n    </div>\n\n    <div class=\"mx-auto max-w-lg\">\n      <%= styled_form_with model: @budget, class: \"space-y-3\", data: { controller: \"budget-form\" } do |f| %>\n        <%= f.money_field :budgeted_spending, label: \"Budgeted spending\", required: true, disable_currency: true %>\n        <%= f.money_field :expected_income, label: \"Expected income\", required: true, disable_currency: true %>\n\n        <% if @budget.estimated_income && @budget.estimated_spending %>\n          <div class=\"border border-tertiary rounded-lg p-3 flex\">\n            <%= icon \"sparkles\" %>\n            <div class=\"ml-2 space-y-1 text-sm\">\n              <h4 class=\"text-primary\">Autosuggest income & spending budget</h4>\n              <p class=\"text-secondary\">\n                This will be based on transaction history. AI can make mistakes, verify before continuing.\n              </p>\n            </div>\n\n            <%= render DS::Toggle.new(\n              id: \"auto_fill\",\n              data: {\n                action: \"change->budget-form#toggleAutoFill\",\n                budget_form_income_param: { key: \"budget_expected_income\", value: sprintf(\"%.2f\", @budget.estimated_income) },\n                budget_form_spending_param: { key: \"budget_budgeted_spending\", value: sprintf(\"%.2f\", @budget.estimated_spending) }\n              }\n            ) %>\n          </div>\n        <% end %>\n\n        <%= f.submit \"Continue\" %>\n      <% end %>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/budgets/show.html.erb",
    "content": "<div class=\"pb-12\">\n  <%= render \"budgets/budget_header\",\n            budget: @budget,\n            previous_budget: @previous_budget,\n            next_budget: @next_budget,\n            latest_budget: @latest_budget %>\n\n  <div class=\"flex flex-col items-start gap-4 md:flex-row\">\n    <div class=\"w-full md:max-w-[300px] space-y-4\">\n      <div class=\"h-[300px] bg-container rounded-xl shadow-border-xs p-8\">\n        <% if @budget.available_to_allocate.negative? %>\n          <%= render \"budgets/over_allocation_warning\", budget: @budget %>\n        <% else %>\n          <%= render \"budgets/budget_donut\", budget: @budget %>\n        <% end %>\n      </div>\n\n      <div>\n\n        <% if @budget.initialized? && @budget.available_to_allocate.positive? %>\n          <%= render DS::Tabs.new(active_tab: params[:tab].presence || \"budgeted\") do |tabs| %>\n            <% tabs.with_nav do |nav| %>\n              <% nav.with_btn(id: \"budgeted\", label: \"Budgeted\") %>\n              <% nav.with_btn(id: \"actuals\", label: \"Actual\") %>\n            <% end %>\n\n            <% tabs.with_panel(tab_id: \"budgeted\") do %>\n              <div class=\"bg-container rounded-xl shadow-border-xs\">\n                <%= render \"budgets/budgeted_summary\", budget: @budget %>\n              </div>\n            <% end %>\n\n            <% tabs.with_panel(tab_id: \"actuals\") do %>\n              <div class=\"bg-container rounded-xl shadow-border-xs\">\n                <%= render \"budgets/actuals_summary\", budget: @budget %>\n              </div>\n            <% end %>\n          <% end %>\n        <% else %>\n          <div class=\"bg-container rounded-xl shadow-border-xs\">\n            <%= render \"budgets/actuals_summary\", budget: @budget %>\n          </div>\n        <% end %>\n      </div>\n    </div>\n\n    <div class=\"w-full grow bg-container rounded-xl shadow-border-xs p-4\">\n      <div class=\"flex items-center justify-between mb-4\">\n        <h2 class=\"text-lg font-medium\">Categories</h2>\n\n        <% if @budget.initialized? %>\n          <%= render DS::Link.new(\n            text: \"Edit\",\n            variant: \"secondary\",\n            icon: \"settings-2\",\n            href: budget_budget_categories_path(@budget)\n          ) %>\n        <% end %>\n      </div>\n\n      <div class=\"bg-container-inset rounded-xl p-1\">\n        <%= render \"budgets/budget_categories\", budget: @budget %>\n      </div>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/categories/_badge.html.erb",
    "content": "<%# locals: (category:) %>\n<% category ||= Category.uncategorized %>\n\n<div>\n  <span class=\"flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border truncate focus-visible:outline-none focus-visible:ring-0\"\n        style=\"\n          background-color: color-mix(in oklab, <%= category.color %> 10%, transparent);\n          border-color: color-mix(in oklab, <%= category.color %> 10%, transparent);\n          color: <%= category.color %>;\">\n    <% if category.lucide_icon.present? %>\n      <%= icon category.lucide_icon, size: \"sm\", color: \"current\" %>\n    <% end %>\n    <%= category.name %>\n  </span>\n</div>\n"
  },
  {
    "path": "app/views/categories/_category.html.erb",
    "content": "<%# locals: (category:) %>\n\n<div id=\"<%= dom_id(category) %>\" class=\"flex justify-between items-center px-4 pb-4 <%= \"pt-4\" unless category.subcategory? %> <%= \"pb-4\" unless category.subcategories.any? %> bg-container\">\n  <div class=\"flex w-full items-center gap-2.5\">\n    <% if category.subcategory? %>\n      <span style=\"color: <%= category.color %>\">\n        <%= icon \"corner-down-right\", size: \"sm\", color: \"current\", class: \"ml-2\" %>\n      </span>\n    <% end %>\n\n    <%= render partial: \"categories/badge\", locals: { category: category } %>\n  </div>\n\n  <div class=\"justify-self-end\">\n    <%= render DS::Menu.new do |menu| %>\n      <% menu.with_item(variant: \"link\", text: t(\".edit\"), icon: \"pencil\", href: edit_category_path(category), data: { turbo_frame: :modal }) %>\n\n      <% if category.transactions.any? %>\n        <% menu.with_item(variant: \"link\", text: t(\".delete\"), icon: \"trash-2\", href: new_category_deletion_path(category), destructive: true, data: { turbo_frame: :modal }) %>\n      <% else %>\n        <% menu.with_item(variant: \"button\", text: t(\".delete\"), icon: \"trash-2\", href: category_path(category), method: :delete) %>\n      <% end %>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/categories/_category_list_group.html.erb",
    "content": "<%# locals: (title:, categories:) %>\n\n<div class=\"rounded-xl bg-container-inset space-y-1 p-1\">\n  <div class=\"flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase\">\n    <p><%= title %></p>\n    <span class=\"text-subdued\">&middot;</span>\n    <p><%= categories.count %></p>\n  </div>\n\n  <div class=\"shadow-border-xs rounded-lg bg-container\">\n    <div class=\"overflow-hidden rounded-lg\">\n      <% Category::Group.for(categories).each_with_index do |group, idx| %>\n        <%= render group.category %>\n\n        <% group.subcategories.each do |subcategory| %>\n          <%= render subcategory %>\n        <% end %>\n\n        <% unless idx == Category::Group.for(categories).count - 1 %>\n          <%= render \"shared/ruler\" %>\n        <% end %>\n      <% end %>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/categories/_color_avatar.html.erb",
    "content": "<%# locals: (category:) %>\n\n<span\n      data-category-target=\"avatar\"\n      class=\"w-14 h-14 flex items-center justify-center rounded-full\"\n      style=\"background-color: color-mix(in oklab, <%= category.color %> 10%, transparent); color: <%= category.color %>\">\n  <%= icon(category.lucide_icon, size: \"2xl\", color: \"current\") %>\n</span>\n"
  },
  {
    "path": "app/views/categories/_form.html.erb",
    "content": "<%# locals: (category:, categories:) %>\n\n<div data-controller=\"category\" data-category-preset-colors-value=\"<%= Category::COLORS %>\">\n  <%= styled_form_with model: category, class: \"space-y-4\" do |f| %>\n    <section class=\"space-y-4\">\n      <div class=\"w-fit mx-auto relative\">\n        <%= render partial: \"color_avatar\", locals: { category: category } %>\n\n        <details data-category-target=\"details\" data-action=\"mousedown->category#handleOutsideClick\">\n          <summary class=\"cursor-pointer absolute -bottom-2 -right-2 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-7 h-7 border-subdued rounded-full text-secondary\">\n            <%= icon(\"pen\", size: \"sm\") %>\n          </summary>\n\n          <div class=\"fixed right-0 sm:right-auto mx-2 sm:ml-8 sm:mr-0 mt-2 z-50 bg-container p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit\" data-category-target=\"popup\">\n            <div class=\"flex gap-2 flex-col mb-4\" data-category-target=\"selection\" style=\"<%= \"display:none;\" if @category.subcategory? %>\">\n              <div data-category-target=\"pickerSection\"></div>\n              <h4 class=\"text-gray-500 text-sm\">Color</h4>\n              <div class=\"flex flex-wrap md:flex-nowrap gap-2 items-center\" data-category-target=\"colorsSection\">\n                <% Category::COLORS.each do |color| %>\n                  <label class=\"relative\">\n                    <%= f.radio_button :color, color, class: \"sr-only peer\", data: { action: \"change->category#handleColorChange\" } %>\n                    <div class=\"w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-500\" style=\"background-color: <%= color %>\"></div>\n                  </label>\n                <% end %>\n                <label class=\"relative\">\n                  <%= f.radio_button :color, \"custom-color\", class: \"sr-only peer\", data: { category_target: \"colorPickerRadioBtn\"} %>\n                  <div class=\"w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500\" data-category-target=\"pickerBtn\" style=\"background: conic-gradient(red,orange,yellow,lime,green,teal,cyan,blue,indigo,purple,magenta,pink,red)\"></div>\n                </label>\n              </div>\n              <div class=\"flex gap-2 items-center hidden flex-col\" data-category-target=\"paletteSection\">\n                <div class=\"flex gap-2 items-center w-full\">\n                  <div class=\"w-6 h-6 p-4 rounded-full cursor-pointer\" style=\"background-color: <%= category.color %>\" data-category-target=\"colorPreview\"></div>\n                  <%= f.text_field :color , data: { category_target: \"colorInput\"}, inline: true %>\n                  <%= icon \"palette\", size: \"2xl\", data: { action: \"click->category#toggleSections\" } %>\n                </div>\n                <div data-category-target=\"validationMessage\" class=\"hidden self-start flex gap-1 items-center text-xs text-destructive \">\n                  <span>Poor contrast, choose darker color or</span>\n                  <button type=\"button\" class=\"underline cursor-pointer\" data-action=\"category#autoAdjust\">auto-adjust.</button>\n                </div>\n              </div>\n            </div>\n\n            <div class=\"flex flex-wrap gap-2 justify-center flex-col w-auto md:w-87\">\n              <h4 class=\"text-secondary text-sm\">Icon</h4>\n              <div class=\"flex flex-wrap gap-0.5\">\n                <% Category.icon_codes.each do |icon| %>\n                  <label class=\"relative\">\n                    <%= f.radio_button :lucide_icon, icon, class: \"sr-only peer\", data: { action: \"change->category#handleIconChange change->category#handleIconColorChange\", category_target:\"icon\" } %>\n                    <div class=\"text-secondary w-7 h-7 flex m-0.5 items-center justify-center rounded-full cursor-pointer hover:bg-container-inset-hover peer-checked:bg-container-inset border-1 border-transparent\">\n                      <%= icon(icon, size: \"sm\", color: \"current\") %>\n                    </div>\n                  </label>\n                <% end %>\n              </div>\n            </div>\n          </div>\n        </details>\n      </div>\n\n      <% if category.errors.any? %>\n        <%= render \"shared/form_errors\", model: category %>\n      <% end %>\n\n      <div class=\"space-y-2\">\n        <%= f.select :classification, [[\"Income\", \"income\"], [\"Expense\", \"expense\"]], { label: \"Classification\" }, required: true %>\n        <%= f.text_field :name, placeholder: t(\".placeholder\"), required: true, autofocus: true, label: \"Name\", data: { color_avatar_target: \"name\" } %>\n        <% unless category.parent? %>\n          <%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: \"(unassigned)\", label: \"Parent category (optional)\" }, disabled: category.parent?, data: { action: \"change->category#handleParentChange\" } %>\n        <% end %>\n      </div>\n    </section>\n\n    <section>\n      <%= hidden_field_tag :transaction_id, params[:transaction_id] %>\n      <%= f.submit %>\n    </section>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/categories/_menu.html.erb",
    "content": "<%# locals: (transaction:) %>\n\n<%= render DS::Menu.new(variant: \"button\") do |menu| %>\n  <% menu.with_button do %>\n    <% render partial: \"categories/badge\", locals: { category: transaction.category } %>\n  <% end %>\n\n  <% menu.with_custom_content do %>\n    <%= turbo_frame_tag \"category_dropdown\", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>\n      <div class=\"p-6 flex items-center justify-center\">\n        <p class=\"text-sm text-secondary animate-pulse\"><%= t(\".loading\") %></p>\n      </div>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/categories/edit.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".edit\")) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", category: @category, categories: @categories %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/categories/index.html.erb",
    "content": "<header class=\"flex items-center justify-between\">\n  <h1 class=\"text-primary text-xl font-medium\"><%= t(\".categories\") %></h1>\n\n  <div class=\"flex items-center gap-2\">\n    <%= render DS::Menu.new do |menu| %>\n      <% menu.with_item(\n          variant: \"button\",\n          text: \"Delete all\",\n          href: destroy_all_categories_path,\n          method: :delete,\n          icon: \"trash-2\",\n          confirm: CustomConfirm.for_resource_deletion(\"all categories\", high_severity: true)) %>\n    <% end %>\n\n    <%= render DS::Link.new(\n      text: t(\".new\"),\n      variant: \"primary\",\n      icon: \"plus\",\n      href: new_category_path,\n      frame: :modal\n    ) %>\n  </div>\n</header>\n\n<div class=\"bg-container rounded-xl shadow-border-xs p-4\">\n  <% if @categories.any? %>\n    <div class=\"space-y-4\">\n      <% if @categories.incomes.any? %>\n        <%= render \"categories/category_list_group\", title: t(\".categories_incomes\"), categories: @categories.incomes %>\n      <% end %>\n\n      <% if @categories.expenses.any? %>\n        <%= render \"categories/category_list_group\", title: t(\".categories_expenses\"), categories: @categories.expenses %>\n      <% end %>\n    </div>\n  <% else %>\n    <div class=\"flex justify-center items-center py-20\">\n      <div class=\"text-center flex flex-col items-center max-w-[500px]\">\n        <p class=\"text-sm text-secondary mb-4\"><%= t(\".empty\") %></p>\n        <div class=\"flex items-center gap-2\">\n          <%= render DS::Button.new(\n            text: t(\".bootstrap\"),\n            href: bootstrap_categories_path,\n          ) %>\n\n          <%= render DS::Link.new(\n            text: t(\".new\"),\n            variant: \"outline\",\n            icon: \"plus\",\n            href: new_category_path,\n            frame: :modal\n          ) %>\n        </div>\n      </div>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/categories/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".new_category\")) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", category: @category, categories: @categories %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/category/deletions/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".delete_category\"), subtitle: t(\".explanation\", category_name: @category.name)) %>\n\n  <% dialog.with_body do %>\n    <%= styled_form_with url: category_deletions_path(@category),\n                       data: {\n                         turbo: false,\n                         controller: \"deletion\",\n                         deletion_submit_text_when_not_replacing_value: t(\".delete_and_leave_uncategorized\", category_name: @category.name),\n                         deletion_submit_text_when_replacing_value: t(\".delete_and_recategorize\", category_name: @category.name) } do |f| %>\n      <%= f.collection_select :replacement_category_id,\n                            Current.family.categories.alphabetically.without(@category),\n                            :id, :name,\n                            { prompt: t(\".replacement_category_prompt\"), label: t(\".category\"), container_class: \"mb-4\" },\n                            data: { deletion_target: \"replacementField\", action: \"deletion#chooseSubmitButton\" } %>\n\n      <%= render DS::Button.new(\n        variant: \"destructive\",\n        text: t(\".delete_and_leave_uncategorized\", category_name: @category.name),\n        full_width: true,\n        data: { deletion_target: \"destructiveSubmitButton\" }\n      ) %>\n\n      <%= render DS::Button.new(\n        text: \"Delete and reassign\",\n        data: { deletion_target: \"safeSubmitButton\" },\n        hidden: true,\n        full_width: true\n      ) %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/category/dropdowns/_row.html.erb",
    "content": "<%# locals: (category:) %>\n<% is_selected = category.id === @selected_category&.id %>\n\n<%= content_tag :div,\n      class: [\"filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full hover:bg-container-inset-hover\",\n              { \"bg-container-inset\": is_selected }],\n      data: { filter_name: category.name } do %>\n  <%= button_to transaction_category_path(\n        @transaction.entry,\n        entry: {\n          entryable_type: \"Transaction\",\n          entryable_attributes: { id: @transaction.id, category_id: category.id }\n        }\n      ),\n      method: :patch,\n      class: \"flex w-full items-center gap-1.5 cursor-pointer focus:outline-none\" do %>\n\n    <%= icon(\"check\") if is_selected %>\n\n    <% if category.subcategory? %>\n      <%= icon(\"corner-down-right\", size: \"sm\") %>\n    <% end %>\n\n    <%= render partial: \"categories/badge\", locals: { category: category } %>\n  <% end %>\n\n  <%= render DS::Menu.new do |menu| %>\n    <% menu.with_item(variant: \"link\", text: t(\".edit\"), icon: \"pencil-line\", href: edit_category_path(category), data: { turbo_frame: :modal }) %>\n    <% menu.with_item(variant: \"link\", text: t(\".delete\"), icon: \"trash-2\", href: new_category_deletion_path(category), data: { turbo_frame: :modal }, destructive: true) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/category/dropdowns/show.html.erb",
    "content": "<%= turbo_frame_tag \"category_dropdown\" do %>\n  <div class=\"flex flex-col relative\" data-controller=\"list-filter\">\n    <div class=\"grow p-1.5\">\n      <div class=\"relative flex items-center bg-container border border-secondary rounded-lg\">\n        <input\n          placeholder=\"<%= t(\".search_placeholder\") %>\"\n          autocomplete=\"nope\"\n          type=\"search\"\n          class=\"bg-container placeholder:text-sm placeholder:text-secondary font-normal h-10 relative pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0\"\n          data-list-filter-target=\"input\"\n          data-action=\"list-filter#filter\">\n          <%= icon(\"search\", class: \"absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2\") %>\n        </div>\n      </div>\n      <div data-list-filter-target=\"list\" class=\"flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar\">\n        <div class=\"pb-2 pl-4 mr-2 text-secondary hidden\" data-list-filter-target=\"emptyMessage\">\n          <%= t(\".no_categories\") %>\n        </div>\n        <% if @categories.any? %>\n          <% Category::Group.for(@categories).each do |group| %>\n            <%= render \"category/dropdowns/row\", category: group.category %>\n\n            <% group.subcategories.each do |category| %>\n              <%= render \"category/dropdowns/row\", category: category %>\n            <% end %>\n          <% end %>\n        <% else %>\n          <div class=\"flex justify-center items-center py-12\">\n            <div class=\"text-center flex flex-col items-center max-w-[500px]\">\n              <p class=\"text-sm text-secondary font-normal mb-4\"><%= t(\".empty\") %></p>\n\n              <%= render DS::Button.new(\n              text: t(\".bootstrap\"),\n              variant: \"outline\",\n              href: bootstrap_categories_path,\n              method: :post,\n              data: { turbo_frame: :_top }) %>\n            </div>\n          </div>\n        <% end %>\n      </div>\n\n      <%= render \"shared/ruler\", classes: \"my-2\" %>\n\n      <div class=\"relative p-1.5 w-full\">\n        <% if @transaction.category %>\n          <%= button_to transaction_path(@transaction.entry),\n                      method: :patch,\n                      data: { turbo_frame: dom_id(@transaction.entry) },\n                      params: { entry: { entryable_type: \"Transaction\", entryable_attributes: { id: @transaction.id, category_id: nil } } },\n                      class: \"flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover\" do %>\n            <%= icon(\"minus\") %>\n\n            <%= t(\".clear\") %>\n          <% end %>\n        <% end %>\n\n        <% unless @transaction.transfer? %>\n          <%= link_to new_transaction_transfer_match_path(@transaction.entry),\n                  class: \"flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover\",\n                  data: { turbo_frame: \"modal\" } do %>\n            <%= icon(\"refresh-cw\") %>\n\n            <p>Match transfer/payment</p>\n          <% end %>\n        <% end %>\n\n        <div class=\"flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2\">\n          <div class=\"flex items-center gap-2\">\n            <%= form_with url: transaction_path(@transaction.entry),\n                        method: :patch,\n                        data: { controller: \"auto-submit-form\" } do |f| %>\n              <%= f.hidden_field \"entry[excluded]\", value: !@transaction.entry.excluded %>\n              <%= f.check_box \"entry[excluded]\",\n                          checked: @transaction.entry.excluded,\n                          class: \"checkbox checkbox--light\",\n                          data: { auto_submit_form_target: \"auto\", autosubmit_trigger_event: \"change\" } %>\n            <% end %>\n          </div>\n\n          <p>One-time <%= @transaction.entry.amount.negative? ? \"income\" : \"expense\" %></p>\n\n          <span class=\"text-orange-500 ml-auto\">\n            <%= icon(\"asterisk\", color: \"current\") %>\n          </span>\n        </div>\n      </div>\n    </div>\n  <% end %>\n"
  },
  {
    "path": "app/views/chats/_ai_avatar.html.erb",
    "content": "<%# locals: (theme: \"light\") %>\n\n<div class=\"shrink-0 w-8 h-8 antialiased\" style=\"filter: drop-shadow(0px 6px 8px rgba(244, 78, 247, 0.10));\">\n  <%# Never use svg as an image tag, it appears blurry in Safari %>\n  <%= inline_svg_tag \"ai-dark.svg\", alt: \"AI\", class: \"w-full h-full hidden theme-dark:block\" %>\n  <%= inline_svg_tag \"ai.svg\", alt: \"AI\", class: \"w-full h-full theme-dark:hidden\" %>\n</div>\n"
  },
  {
    "path": "app/views/chats/_ai_consent.html.erb",
    "content": "<div class=\"rounded-lg p-4 bg-container shadow-border-xs\">\n  <div class=\"flex justify-center mb-4\">\n    <%= render \"chats/ai_avatar\" %>\n  </div>\n\n  <h3 class=\"text-sm font-medium text-primary mb-1 -mt-2 text-center\">Enable Maybe AI</h3>\n\n  <p class=\"text-gray-600 mb-4 text-sm text-center\">\n    <% if Current.user.ai_available? %>\n      Maybe AI can answer financial questions and provide insights based on your data. To use this feature you'll need to explicitly enable it.\n    <% else %>\n      To use the AI assistant, you need to set the <code class=\"bg-surface-inset px-1 py-0.5 rounded font-mono text-xs\">OPENAI_ACCESS_TOKEN</code>\n      environment variable in your self-hosted instance.\n    <% end %>\n  </p>\n\n  <% if Current.user.ai_available? %>\n    <%= form_with url: user_path(Current.user), method: :patch, class: \"w-full\", data: { turbo: false } do |form| %>\n      <%= form.hidden_field \"user[ai_enabled]\", value: true %>\n      <%= form.hidden_field \"user[redirect_to]\", value: \"home\" %>\n      <%= form.submit \"Enable Maybe AI\", class: \"cursor-pointer hover:bg-inverse-hover w-full py-2 px-4 bg-inverse fg-inverse rounded-lg text-sm font-medium\" %>\n    <% end %>\n  <% end %>\n\n  <p class=\"text-xs text-secondary text-center mt-2\">Disable anytime.  All data sent to our LLM providers is anonymized.</p>\n</div>\n"
  },
  {
    "path": "app/views/chats/_ai_greeting.html.erb",
    "content": "<div class=\"flex items-start w-full gap-3 p-2\">\n  <%= render \"chats/ai_avatar\" %>\n\n  <div class=\"max-w-[85%] text-sm space-y-4 text-primary\">\n    <p>Hey <%= Current.user&.first_name || \"there\" %>! I'm an AI built by Maybe to help with your finances. I have access to the web and your account data.</p>\n\n    <p>\n      You can use <span class=\"bg-container border border-secondary px-1.5 py-0.5 rounded font-mono text-xs\">/</span> to access commands\n    </p>\n\n    <div class=\"space-y-3\">\n      <p>Here's a few questions you can ask:</p>\n\n      <% questions = [\n        {\n          icon: \"chart-area\",\n          text: \"Evaluate investment portfolio\"\n        },\n        {\n          icon: \"wallet-minimal\",\n          text: \"Show spending insights\"\n        },\n        {\n          icon: \"alert-triangle\",\n          text: \"Find unusual patterns\"\n        }\n      ] %>\n\n      <div class=\"space-y-2.5\">\n        <% questions.each do |question| %>\n          <button data-action=\"chat#submitSampleQuestion\"\n                  data-chat-question-param=\"<%= question[:text] %>\"\n                  class=\"w-fit flex items-center gap-2 border border-tertiary rounded-full py-1.5 px-2.5 hover:bg-gray-100\">\n            <%= icon(question[:icon]) %> <%= question[:text] %>\n          </button>\n        <% end %>\n      </div>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/chats/_chat.html.erb",
    "content": "<%# locals: (chat:) %>\n\n<%= tag.div class: \"flex items-center justify-between px-4 py-3 bg-container shadow-border-xs rounded-lg\" do %>\n  <div class=\"grow\">\n    <%= turbo_frame_tag dom_id(chat, :title) do %>\n      <%= render \"chats/chat_title\", chat: chat, ctx: \"list\" %>\n    <% end %>\n\n    <p class=\"text-sm text-secondary\">\n      <%= time_ago_in_words(chat.updated_at) %> ago\n    </p>\n  </div>\n\n  <%= render DS::Menu.new(icon_vertical: true) do |menu| %>\n    <% menu.with_item(\n      variant: \"link\",\n      text: \"Edit chat title\",\n      href: edit_chat_path(chat, ctx: \"list\"),\n      icon: \"pencil\",\n      frame: dom_id(chat, \"title\")) %>\n\n    <% menu.with_item(\n      variant: \"button\",\n      text: \"Delete chat\",\n      href: chat_path(chat),\n      icon: \"trash-2\",\n      method: :delete,\n      confirm: CustomConfirm.for_resource_deletion(\"chat\")) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/chats/_chat_nav.html.erb",
    "content": "<%# locals: (chat:) %>\n\n<nav class=\"flex items-center justify-between\">\n  <% path = chat.new_record? ? chats_path : chats_path(previous_chat_id: chat.id) %>\n\n  <div class=\"flex items-center gap-2 grow\">\n    <%= render DS::Link.new(\n      id: \"chat-nav-back\",\n      variant: \"icon\",\n      icon: \"menu\",\n      href: path,\n      frame: chat_frame,\n      text: \"All chats\"\n    ) %>\n\n    <div class=\"grow\">\n      <%= render \"chats/chat_title\", chat: chat, ctx: \"chat\" %>\n    </div>\n  </div>\n\n  <%= render DS::Menu.new(icon_vertical: true) do |menu| %>\n    <% menu.with_item(variant: \"link\", text: \"Start new chat\", href: new_chat_path, icon: \"plus\") %>\n\n    <% unless chat.new_record? %>\n      <% menu.with_item(\n        variant: \"link\",\n        text: \"Edit chat title\",\n        href: edit_chat_path(chat, ctx: \"chat\"),\n        icon: \"pencil\",\n        frame: dom_id(chat, \"title\")) %>\n\n      <% menu.with_item(\n        variant: \"button\",\n        text: \"Delete chat\",\n        href: chat_path(chat),\n        icon: \"trash-2\",\n        method: :delete,\n        confirm: CustomConfirm.for_resource_deletion(\"chat\")) %>\n    <% end %>\n  <% end %>\n</nav>\n"
  },
  {
    "path": "app/views/chats/_chat_title.html.erb",
    "content": "<%# locals: (chat:, ctx: \"list\") %>\n\n<%= turbo_frame_tag dom_id(chat, :title), class: \"block\" do %>\n  <% if chat.new_record? || ctx == \"chat\" %>\n    <h3 class=\"text-sm font-medium text-primary\"><%= chat.title || \"New chat\" %></h3>\n  <% else %>\n    <%= link_to chat_path(chat), data: { turbo_frame: chat_frame } do %>\n      <h3 class=\"truncate text-sm font-medium text-primary\"><%= chat.title %></h3>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/chats/_error.html.erb",
    "content": "<%# locals: (chat:) %>\n\n<div id=\"chat-error\" class=\"px-3 py-2 bg-red-100 border border-red-500 rounded-lg\">\n  <% if chat.debug_mode? %>\n    <div class=\"overflow-x-auto text-xs p-4 bg-red-200 rounded-md mb-2\">\n      <code><%= chat.error %></code>\n    </div>\n  <% end %>\n\n  <div class=\"flex items-center justify-between gap-2\">\n    <p class=\"text-xs text-red-500\">Failed to generate response.  Please try again.</p>\n\n    <%= render DS::Button.new(\n      text: \"Retry\",\n      href: retry_chat_path(chat),\n    ) %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/chats/_thinking_indicator.html.erb",
    "content": "<%# locals: (chat:, message: \"Thinking ...\") -%>\n\n<div id=\"thinking-indicator\" class=\"flex items-start gap-3\">\n  <%= render \"chats/ai_avatar\" %>\n  <p class=\"text-sm text-secondary animate-pulse\"><%= message %></p>\n</div>\n"
  },
  {
    "path": "app/views/chats/edit.html.erb",
    "content": "<%= turbo_frame_tag dom_id(@chat, :title), class: \"block\" do %>\n  <%= styled_form_with model: @chat, data: { controller: \"auto-submit-form\", auto_submit_form_trigger_event_value: \"blur\" } do |f| %>\n    <%= f.text_field :title,\n      data: { auto_submit_form_target: \"auto\" },\n      autofocus: true,\n      inline: true,\n      class: \"w-full rounded-md px-2 py-1 text-sm font-medium bg-transparent\" %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/chats/index.html.erb",
    "content": "<div data-controller=\"chat hotkey\">\n  <%= turbo_frame_tag chat_frame do %>\n    <div class=\"flex flex-col h-full md:p-4\">\n      <% if @chats.any? %>\n        <div class=\"grow flex flex-col\">\n          <div class=\"flex items-center justify-between my-6\">\n            <h1 class=\"text-xl font-medium\">Chats</h1>\n            <%= render DS::Link.new(\n              id: \"new-chat\",\n              icon: \"plus\",\n              variant: \"icon\",\n              href: new_chat_path,\n              frame: chat_frame,\n              text: \"New chat\"\n            ) %>\n          </div>\n          <div class=\"space-y-2 px-0.5\">\n            <%= render @chats %>\n          </div>\n        </div>\n      <% else %>\n        <div class=\"grow flex flex-col\">\n          <h1 class=\"sr-only\">Chats</h1>\n          <div class=\"mt-auto py-8\">\n            <%= render \"chats/ai_greeting\" %>\n          </div>\n          <%= render \"messages/chat_form\" %>\n        </div>\n      <% end %>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/chats/new.html.erb",
    "content": "<%= turbo_frame_tag chat_frame do %>\n  <div class=\"flex flex-col h-full md:p-4\">\n    <%= render \"chats/chat_nav\", chat: @chat %>\n\n    <div class=\"mt-auto py-8\">\n      <%= render \"chats/ai_greeting\" %>\n    </div>\n\n    <%= render \"messages/chat_form\", chat: @chat %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/chats/show.html.erb",
    "content": "<div data-controller=\"chat hotkey\">\n  <%= turbo_frame_tag chat_frame do %>\n    <%= turbo_stream_from @chat %>\n\n    <h1 class=\"sr-only\"><%= @chat.title %></h1>\n\n    <div class=\"flex flex-col h-full\">\n      <div class=\"md:p-4\">\n        <%= render \"chats/chat_nav\", chat: @chat %>\n      </div>\n\n      <div id=\"messages\" class=\"grow overflow-y-auto p-4 space-y-6 pb-24 lg:pb-4\" data-chat-target=\"messages\">\n        <% if @chat.conversation_messages.any? %>\n          <% @chat.conversation_messages.ordered.each do |message| %>\n            <%= render message %>\n          <% end %>\n        <% else %>\n          <div class=\"mt-auto\">\n            <%= render \"chats/ai_greeting\", context: \"chat\" %>\n          </div>\n        <% end %>\n\n        <% if params[:thinking].present? %>\n          <%= render \"chats/thinking_indicator\", chat: @chat %>\n        <% end %>\n\n        <% if @chat.error.present? && @chat.needs_assistant_response? %>\n          <%= render \"chats/error\", chat: @chat %>\n        <% end %>\n      </div>\n\n      <%# DESKTOP - Chat form %>\n      <div class=\"p-4 pt-0 lg:mt-auto fixed lg:static left-0 bottom-16 w-full bg-surface\">\n        <%= render \"messages/chat_form\", chat: @chat %>\n      </div>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/credit_cards/_form.html.erb",
    "content": "<%# locals: (account:, url:) %>\n\n<%= render \"accounts/form\", account: account, url: url do |form| %>\n  <%= render \"shared/ruler\", classes: \"my-4\" %>\n\n  <div class=\"space-y-2\">\n    <%= form.fields_for :accountable do |credit_card_form| %>\n      <div class=\"flex items-center gap-2\">\n        <%= credit_card_form.number_field :available_credit,\n                                        label: t(\"credit_cards.form.available_credit\"),\n                                        placeholder: t(\"credit_cards.form.available_credit_placeholder\"),\n                                        min: 0 %>\n      </div>\n\n      <div class=\"flex items-center gap-2\">\n        <%= credit_card_form.money_field :minimum_payment,\n                                        label: t(\"credit_cards.form.minimum_payment\"),\n                                        placeholder: t(\"credit_cards.form.minimum_payment_placeholder\"),\n                                        default_currency: Current.family.currency %>\n\n        <%= credit_card_form.number_field :apr,\n                                        label: t(\"credit_cards.form.apr\"),\n                                        placeholder: t(\"credit_cards.form.apr_placeholder\"),\n                                        min: 0,\n                                        step: 0.01 %>\n      </div>\n\n      <div class=\"flex items-center gap-2\">\n        <%= credit_card_form.date_field :expiration_date,\n                                      label: t(\"credit_cards.form.expiration_date\") %>\n        <%= credit_card_form.number_field :annual_fee,\n                                        label: t(\"credit_cards.form.annual_fee\"),\n                                        placeholder: t(\"credit_cards.form.annual_fee_placeholder\"),\n                                        min: 0 %>\n      </div>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/credit_cards/_overview.html.erb",
    "content": "<%# locals: (account:) %>\n\n<div class=\"grid grid-cols-3 gap-2\">\n  <%= summary_card title: t(\".amount_owed\") do %>\n    <%= format_money(account.balance_money) %>\n  <% end %>\n\n  <%= summary_card title: t(\".available_credit\") do %>\n    <%= format_money(account.credit_card.available_credit_money) || t(\".unknown\") %>\n  <% end %>\n\n  <%= summary_card title: t(\".minimum_payment\") do %>\n    <%= format_money(account.credit_card.minimum_payment_money || Money.new(0, account.currency)) %>\n  <% end %>\n\n  <%= summary_card title: t(\".apr\") do %>\n    <%= account.credit_card.apr ? number_to_percentage(account.credit_card.apr, precision: 2) : t(\".unknown\") %>\n  <% end %>\n\n  <%= summary_card title: t(\".expiration_date\") do %>\n    <%= account.credit_card.expiration_date ? l(account.credit_card.expiration_date, format: :long) : t(\".unknown\") %>\n  <% end %>\n\n  <%= summary_card title: t(\".annual_fee\") do %>\n    <%= format_money(account.credit_card.annual_fee_money || Money.new(0, account.currency)) %>\n  <% end %>\n</div>\n\n<div class=\"flex justify-center py-8\">\n  <%= render DS::Link.new(\n    text: \"Edit account details\",\n    variant: \"ghost\",\n    href: edit_credit_card_path(account),\n    frame: :modal\n  ) %>\n</div>\n"
  },
  {
    "path": "app/views/credit_cards/edit.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".edit\", account: @account.name)) %>\n\n  <% dialog.with_body do %>\n    <%= render \"form\", account: @account, url: credit_card_path(@account) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/credit_cards/new.html.erb",
    "content": "<% if params[:step] == \"method_select\" %>\n  <%= render \"accounts/new/method_selector\",\n             path: new_credit_card_path(return_to: params[:return_to]),\n             show_us_link: @show_us_link,\n             show_eu_link: @show_eu_link,\n             accountable_type: \"CreditCard\" %>\n<% else %>\n  <%= render DS::Dialog.new do |dialog| %>\n    <% dialog.with_header(title: t(\".title\")) %>\n    <% dialog.with_body do %>\n      <%= render \"credit_cards/form\", account: @account, url: credit_cards_path %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cryptos/_form.html.erb",
    "content": "<%# locals: (account:, url:) %>\n\n<%= render \"accounts/form\", account: account, url: url %>\n"
  },
  {
    "path": "app/views/cryptos/edit.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".edit\", account: @account.name)) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", account: @account, url: crypto_path(@account) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/cryptos/new.html.erb",
    "content": "<% if params[:step] == \"method_select\" %>\n  <%= render \"accounts/new/method_selector\",\n             path: new_crypto_path(return_to: params[:return_to]),\n             show_us_link: @show_us_link,\n             show_eu_link: @show_eu_link,\n             accountable_type: \"Crypto\" %>\n<% else %>\n  <%= render DS::Dialog.new do |dialog| %>\n    <% dialog.with_header(title: t(\".title\")) %>\n    <% dialog.with_body do %>\n      <%= render \"form\", account: @account, url: cryptos_path %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/depositories/_form.html.erb",
    "content": "<%# locals: (account:, url:) %>\n\n<%= render \"accounts/form\", account: account, url: url do |form| %>\n  <%= form.select :subtype,\n                 Depository::SUBTYPES.map { |k, v| [v[:long], k] },\n                 { label: true, prompt: t(\"depositories.form.subtype_prompt\"), include_blank: t(\"depositories.form.none\") } %>\n<% end %>\n"
  },
  {
    "path": "app/views/depositories/edit.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".edit\", account: @account.name)) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", account: @account, url: depository_path(@account) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/depositories/new.html.erb",
    "content": "<% if params[:step] == \"method_select\" %>\n  <%= render \"accounts/new/method_selector\",\n             path: new_depository_path(return_to: params[:return_to]),\n             show_us_link: @show_us_link,\n             show_eu_link: @show_eu_link,\n             accountable_type: \"Depository\" %>\n<% else %>\n  <%= render DS::Dialog.new do |dialog| %>\n    <% dialog.with_header(title: t(\".title\")) %>\n    <% dialog.with_body do %>\n      <%= render \"depositories/form\", account: @account, url: depositories_path %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/developer_messages/_developer_message.html.erb",
    "content": "<%# locals: (developer_message:) %>\n\n<div id=\"<%= dom_id(developer_message) %>\" class=\"my-2 <%= developer_message.debug? ? \"bg-yellow-50 border-yellow-200\" : \"bg-blue-50 border-blue-200\" %> px-3 py-2 rounded-lg max-w-[85%] ml-auto border\">\n  <span class=\"text-secondary text-xs\"><%= developer_message.debug? ? \"Debug message (internal only)\" : \"System instruction (sent to AI)\" %></span>\n  <p class=\"text-primary text-sm\"><%= developer_message.content %></p>\n</div>\n"
  },
  {
    "path": "app/views/doorkeeper/applications/_delete_form.html.erb",
    "content": "<%- submit_btn_css ||= \"btn btn-link\" %>\n<%= form_tag oauth_application_path(application), method: :delete do %>\n  <%= submit_tag t(\"doorkeeper.applications.buttons.destroy\"),\n                 onclick: \"return confirm('#{ t('doorkeeper.applications.confirmations.destroy') }')\",\n                 class: submit_btn_css %>\n<% end %>\n"
  },
  {
    "path": "app/views/doorkeeper/applications/_form.html.erb",
    "content": "<%= form_for application, url: doorkeeper_submit_path(application), as: :doorkeeper_application, html: { role: \"form\" } do |f| %>\n  <% if application.errors.any? %>\n    <div class=\"alert alert-danger\" data-alert><p><%= t(\"doorkeeper.applications.form.error\") %></p></div>\n  <% end %>\n\n  <div class=\"form-group row\">\n    <%= f.label :name, class: \"col-sm-2 col-form-label font-weight-bold\" %>\n    <div class=\"col-sm-10\">\n      <%= f.text_field :name, class: \"form-control #{ 'is-invalid' if application.errors[:name].present? }\", required: true %>\n      <%= doorkeeper_errors_for application, :name %>\n    </div>\n  </div>\n\n  <div class=\"form-group row\">\n    <%= f.label :redirect_uri, class: \"col-sm-2 col-form-label font-weight-bold\" %>\n    <div class=\"col-sm-10\">\n      <%= f.text_area :redirect_uri, class: \"form-control #{ 'is-invalid' if application.errors[:redirect_uri].present? }\" %>\n      <%= doorkeeper_errors_for application, :redirect_uri %>\n      <span class=\"form-text text-secondary\">\n        <%= t(\"doorkeeper.applications.help.redirect_uri\") %>\n      </span>\n\n      <% if Doorkeeper.configuration.allow_blank_redirect_uri?(application) %>\n        <span class=\"form-text text-secondary\">\n          <%= t(\"doorkeeper.applications.help.blank_redirect_uri\") %>\n        </span>\n      <% end %>\n    </div>\n  </div>\n\n  <div class=\"form-group row\">\n    <%= f.label :confidential, class: \"col-sm-2 form-check-label font-weight-bold\" %>\n    <div class=\"col-sm-10\">\n      <%= f.check_box :confidential, class: \"checkbox #{ 'is-invalid' if application.errors[:confidential].present? }\" %>\n      <%= doorkeeper_errors_for application, :confidential %>\n      <span class=\"form-text text-secondary\">\n        <%= t(\"doorkeeper.applications.help.confidential\") %>\n      </span>\n    </div>\n  </div>\n\n  <div class=\"form-group row\">\n    <%= f.label :scopes, class: \"col-sm-2 col-form-label font-weight-bold\" %>\n    <div class=\"col-sm-10\">\n      <%= f.text_field :scopes, class: \"form-control #{ 'has-error' if application.errors[:scopes].present? }\" %>\n      <%= doorkeeper_errors_for application, :scopes %>\n      <span class=\"form-text text-secondary\">\n        <%= t(\"doorkeeper.applications.help.scopes\") %>\n      </span>\n    </div>\n  </div>\n\n  <div class=\"form-group\">\n    <div class=\"col-sm-offset-2 col-sm-10\">\n      <%= f.submit t(\"doorkeeper.applications.buttons.submit\"), class: \"btn btn-primary\" %>\n      <%= link_to t(\"doorkeeper.applications.buttons.cancel\"), oauth_applications_path, class: \"btn btn-secondary\" %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/doorkeeper/applications/edit.html.erb",
    "content": "<div class=\"border-bottom mb-4\">\n  <h1><%= t(\".title\") %></h1>\n</div>\n\n<%= render \"form\", application: @application %>\n"
  },
  {
    "path": "app/views/doorkeeper/applications/index.html.erb",
    "content": "<div class=\"border-bottom mb-4\">\n  <h1><%= t(\".title\") %></h1>\n</div>\n\n<p><%= link_to t(\".new\"), new_oauth_application_path, class: \"btn btn-success\" %></p>\n\n<table class=\"table table-striped\">\n  <thead>\n  <tr>\n    <th><%= t(\".name\") %></th>\n    <th><%= t(\".callback_url\") %></th>\n    <th><%= t(\".confidential\") %></th>\n    <th><%= t(\".actions\") %></th>\n    <th></th>\n  </tr>\n  </thead>\n  <tbody>\n  <% @applications.each do |application| %>\n    <tr id=\"application_<%= application.id %>\">\n      <td class=\"align-middle\">\n        <%= link_to application.name, oauth_application_path(application) %>\n      </td>\n      <td class=\"align-middle\">\n        <%= simple_format(application.redirect_uri) %>\n      </td>\n      <td class=\"align-middle\">\n        <%= application.confidential? ? t(\"doorkeeper.applications.index.confidentiality.yes\") : t(\"doorkeeper.applications.index.confidentiality.no\") %>\n      </td>\n      <td class=\"align-middle\">\n        <%= link_to t(\"doorkeeper.applications.buttons.edit\"), edit_oauth_application_path(application), class: \"btn btn-link\" %>\n      </td>\n      <td class=\"align-middle\">\n        <%= render \"delete_form\", application: application %>\n      </td>\n    </tr>\n  <% end %>\n  </tbody>\n</table>\n"
  },
  {
    "path": "app/views/doorkeeper/applications/new.html.erb",
    "content": "<div class=\"border-bottom mb-4\">\n  <h1><%= t(\".title\") %></h1>\n</div>\n\n<%= render \"form\", application: @application %>\n"
  },
  {
    "path": "app/views/doorkeeper/applications/show.html.erb",
    "content": "<div class=\"border-bottom mb-4\">\n  <h1><%= t(\".title\", name: @application.name) %></h1>\n</div>\n\n<div class=\"row\">\n  <div class=\"col-md-8\">\n    <h4><%= t(\".application_id\") %>:</h4>\n    <p><code class=\"bg-light\" id=\"application_id\"><%= @application.uid %></code></p>\n\n    <h4><%= t(\".secret\") %>:</h4>\n    <p>\n      <code class=\"bg-light\" id=\"secret\">\n        <% secret = flash[:application_secret].presence || @application.plaintext_secret %>\n        <% if secret.blank? && Doorkeeper.config.application_secret_hashed? %>\n          <span class=\"bg-light font-italic text-uppercase text-muted\"><%= t(\".secret_hashed\") %></span>\n        <% else %>\n          <%= secret %>\n        <% end %>\n      </code>\n    </p>\n\n    <h4><%= t(\".scopes\") %>:</h4>\n    <p>\n      <code class=\"bg-light\" id=\"scopes\">\n        <% if @application.scopes.present? %>\n          <%= @application.scopes %>\n        <% else %>\n          <span class=\"bg-light font-italic text-uppercase text-muted\"><%= t(\".not_defined\") %></span>\n        <% end %>\n      </code>\n    </p>\n\n    <h4><%= t(\".confidential\") %>:</h4>\n    <p><code class=\"bg-light\" id=\"confidential\"><%= @application.confidential? %></code></p>\n\n    <h4><%= t(\".callback_urls\") %>:</h4>\n\n    <% if @application.redirect_uri.present? %>\n      <table>\n        <% @application.redirect_uri.split.each do |uri| %>\n          <tr>\n            <td>\n              <code class=\"bg-light\"><%= uri %></code>\n            </td>\n            <td>\n              <%= link_to t(\"doorkeeper.applications.buttons.authorize\"), oauth_authorization_path(client_id: @application.uid, redirect_uri: uri, response_type: \"code\", scope: @application.scopes), class: \"btn btn-success\", target: \"_blank\" %>\n            </td>\n          </tr>\n        <% end %>\n      </table>\n    <% else %>\n      <span class=\"bg-light font-italic text-uppercase text-muted\"><%= t(\".not_defined\") %></span>\n    <% end %>\n  </div>\n\n  <div class=\"col-md-4\">\n    <h3><%= t(\".actions\") %></h3>\n\n    <p><%= link_to t(\"doorkeeper.applications.buttons.edit\"), edit_oauth_application_path(@application), class: \"btn btn-primary\" %></p>\n\n    <p><%= render \"delete_form\", application: @application, submit_btn_css: \"btn btn-danger\" %></p>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/doorkeeper/authorizations/error.html.erb",
    "content": "<div class=\"bg-container rounded-xl p-6 space-y-6\">\n  <div class=\"text-center space-y-2\">\n    <div class=\"mx-auto w-12 h-12 rounded-full bg-destructive-surface flex items-center justify-center mb-4\">\n      <%= icon(\"alert-circle\", class: \"w-6 h-6 text-destructive\") %>\n    </div>\n    <h1 class=\"text-2xl font-medium text-primary\"><%= t(\"doorkeeper.authorizations.error.title\") %></h1>\n  </div>\n\n  <div class=\"bg-surface-inset rounded-lg p-4\">\n    <p class=\"text-sm text-secondary\">\n      <%= (local_assigns[:error_response] ? error_response : @pre_auth.error_response).body[:error_description] %>\n    </p>\n  </div>\n\n  <div class=\"text-center\">\n    <%= render DS::Link.new(\n      text: \"Go back\",\n      href: \"javascript:history.back()\",\n      variant: :secondary\n    ) %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/doorkeeper/authorizations/form_post.html.erb",
    "content": "<div class=\"bg-container rounded-xl p-6 space-y-6\">\n  <div class=\"text-center space-y-2\">\n    <div class=\"mx-auto w-12 h-12 rounded-full bg-surface-inset flex items-center justify-center mb-4\">\n      <%= icon(\"loader-circle\", class: \"w-6 h-6 text-primary animate-spin\") %>\n    </div>\n    <h1 class=\"text-2xl font-medium text-primary\"><%= t(\".title\") %></h1>\n    <p class=\"text-sm text-secondary\">Redirecting you back to the application...</p>\n  </div>\n</div>\n\n<% turbo_disabled = @pre_auth.redirect_uri&.start_with?(\"maybeapp://\") || params[:display] == \"mobile\" %>\n<%= form_tag @pre_auth.redirect_uri, method: :post, name: :redirect_form, authenticity_token: false, data: { turbo: !turbo_disabled } do %>\n  <% auth.body.compact.each do |key, value| %>\n    <%= hidden_field_tag key, value %>\n  <% end %>\n<% end %>\n\n<script>\n  window.onload = function () {\n    document.forms['redirect_form'].submit();\n  };\n</script>\n"
  },
  {
    "path": "app/views/doorkeeper/authorizations/new.html.erb",
    "content": "<% if params[:redirect_uri]&.start_with?('maybeapp://') || params[:display] == 'mobile' %>\n  <meta name=\"turbo-visit-control\" content=\"reload\">\n<% end %>\n\n<div class=\"bg-container rounded-xl p-6 space-y-6\">\n  <div class=\"space-y-2 text-center\">\n    <p class=\"text-sm text-secondary\">\n      <%= raw t(\".prompt\", client_name: content_tag(:span, @pre_auth.client.name, class: \"font-medium text-primary\")) %>\n    </p>\n  </div>\n\n  <% if @pre_auth.scopes.count > 0 %>\n    <div class=\"bg-surface-inset rounded-lg p-4 space-y-3\">\n      <p class=\"text-sm font-medium text-primary\"><%= t(\".able_to\") %>:</p>\n      <ul class=\"space-y-2\">\n        <% @pre_auth.scopes.each do |scope| %>\n          <li class=\"flex items-start gap-2 text-sm text-secondary\">\n            <%= icon(\"check\", class: \"w-4 h-4 mt-0.5 text-success\") %>\n            <span><%= t scope, scope: [:doorkeeper, :scopes] %></span>\n          </li>\n        <% end %>\n      </ul>\n    </div>\n  <% end %>\n\n  <div class=\"space-y-3\">\n    <% turbo_disabled = params[:redirect_uri]&.start_with?(\"maybeapp://\") || params[:display] == \"mobile\" %>\n    <%= form_tag oauth_authorization_path, method: :post, class: \"w-full\", data: { turbo: !turbo_disabled } do %>\n      <%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>\n      <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %>\n      <%= hidden_field_tag :state, @pre_auth.state, id: nil %>\n      <%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %>\n      <%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %>\n      <%= hidden_field_tag :scope, @pre_auth.scope, id: nil %>\n      <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %>\n      <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %>\n      <% if params[:display].present? %>\n        <%= hidden_field_tag :display, params[:display], id: nil %>\n      <% end %>\n      <%= render DS::Button.new(\n        text: t(\"doorkeeper.authorizations.buttons.authorize\"),\n        variant: :primary,\n        size: :lg,\n        full_width: true,\n        href: oauth_authorization_path,\n        data: { disable_with: \"Authorizing...\" }\n      ) %>\n    <% end %>\n\n    <%= form_tag oauth_authorization_path, method: :delete, class: \"w-full\", data: { turbo: !turbo_disabled } do %>\n      <%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>\n      <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %>\n      <%= hidden_field_tag :state, @pre_auth.state, id: nil %>\n      <%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %>\n      <%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %>\n      <%= hidden_field_tag :scope, @pre_auth.scope, id: nil %>\n      <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %>\n      <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %>\n      <% if params[:display].present? %>\n        <%= hidden_field_tag :display, params[:display], id: nil %>\n      <% end %>\n      <%= render DS::Button.new(\n        text: t(\"doorkeeper.authorizations.buttons.deny\"),\n        variant: :outline,\n        size: :lg,\n        full_width: true,\n        href: oauth_authorization_path,\n        data: { disable_with: \"Denying...\" }\n      ) %>\n    <% end %>\n  </div>\n\n  <p class=\"text-xs text-tertiary text-center\">\n    By authorizing, you allow this app to access your Maybe data according to the permissions above.\n  </p>\n</div>\n"
  },
  {
    "path": "app/views/doorkeeper/authorizations/show.html.erb",
    "content": "<div class=\"bg-container rounded-xl p-6 space-y-6\">\n  <div class=\"text-center space-y-2\">\n    <div class=\"mx-auto w-12 h-12 rounded-full bg-success-surface flex items-center justify-center mb-4\">\n      <%= icon(\"check\", class: \"w-6 h-6 text-success\") %>\n    </div>\n    <h1 class=\"text-2xl font-medium text-primary\"><%= t(\".title\") %></h1>\n  </div>\n\n  <div class=\"bg-surface-inset rounded-lg p-4\">\n    <p class=\"text-xs text-secondary mb-2\">Authorization Code:</p>\n    <code id=\"authorization_code\" class=\"block text-sm font-mono text-primary break-all\"><%= params[:code] %></code>\n  </div>\n\n  <p class=\"text-sm text-secondary text-center\">\n    Copy this code and paste it into the application.\n  </p>\n</div>\n"
  },
  {
    "path": "app/views/doorkeeper/authorized_applications/_delete_form.html.erb",
    "content": "<%- submit_btn_css ||= \"btn btn-link\" %>\n<%= form_tag oauth_authorized_application_path(application), method: :delete do %>\n  <%= submit_tag t(\"doorkeeper.authorized_applications.buttons.revoke\"), onclick: \"return confirm('#{ t('doorkeeper.authorized_applications.confirmations.revoke') }')\", class: submit_btn_css %>\n<% end %>\n"
  },
  {
    "path": "app/views/doorkeeper/authorized_applications/index.html.erb",
    "content": "<header class=\"page-header\">\n  <h1><%= t(\"doorkeeper.authorized_applications.index.title\") %></h1>\n</header>\n\n<main role=\"main\">\n  <table class=\"table table-striped\">\n    <thead>\n    <tr>\n      <th><%= t(\"doorkeeper.authorized_applications.index.application\") %></th>\n      <th><%= t(\"doorkeeper.authorized_applications.index.created_at\") %></th>\n      <th></th>\n    </tr>\n    </thead>\n    <tbody>\n    <% @applications.each do |application| %>\n      <tr>\n        <td><%= application.name %></td>\n        <td><%= application.created_at.strftime(t(\"doorkeeper.authorized_applications.index.date_format\")) %></td>\n        <td><%= render \"delete_form\", application: application %></td>\n      </tr>\n    <% end %>\n    </tbody>\n  </table>\n</main>\n"
  },
  {
    "path": "app/views/email_confirmation_mailer/confirmation_email.html.erb",
    "content": "<h1><%= t(\".greeting\") %></h1>\n\n<p><%= t(\".body\") %></p>\n\n<%= link_to @cta, @confirmation_url, class: \"button\" %>\n\n<p class=\"footer\"><%= t(\".expiry_notice\", hours: 24) %></p>\n"
  },
  {
    "path": "app/views/email_confirmation_mailer/confirmation_email.text.erb",
    "content": "EmailConfirmation#confirmation_email\n\n<%= t(\".greeting\") %>\n\n<%= t(\".body\") %>\n\n<%= t(\".cta\") %>: <%= @confirmation_url %>\n\n<%= t(\".expiry_notice\", hours: 24) %>\n"
  },
  {
    "path": "app/views/entries/_empty.html.erb",
    "content": "<div class=\"flex flex-col items-center justify-center py-40\">\n  <p class=\"text-secondary mb-2\"><%= t(\".title\") %></p>\n  <p class=\"text-subdued max-w-xs text-center\"><%= t(\".description\") %></p>\n</div>\n"
  },
  {
    "path": "app/views/entries/_entry.html.erb",
    "content": "<%# locals: (entry:, balance_trend: nil, view_ctx: \"global\") %>\n\n<%= render partial: entry.entryable.to_partial_path,\n           locals: { entry: entry, balance_trend: balance_trend, view_ctx: view_ctx } %>\n"
  },
  {
    "path": "app/views/entries/_entry_group.html.erb",
    "content": "<%# locals: (date:, entries:, content:, totals: false) %>\n\n<div id=\"entry-group-<%= date %>\" class=\"bg-container-inset rounded-xl p-1 w-full\" data-bulk-select-target=\"group\">\n  <div class=\"py-2 px-4 flex items-center justify-between font-medium text-xs text-secondary\">\n    <div class=\"flex pl-0.5 items-center gap-4\">\n        <%= check_box_tag \"#{date}_entries_selection\",\n                          class: [\"checkbox checkbox--light\", \"hidden\": entries.size == 0],\n                          id: \"selection_entry_#{date}\",\n                          data: { action: \"bulk-select#toggleGroupSelection\" } %>\n\n      <p class=\"uppercase space-x-1.5\">\n        <%= tag.span I18n.l(date, format: :long) %>\n        <span>·</span>\n        <%= tag.span entries.size %>\n      </p>\n    </div>\n\n    <% if totals %>\n      <div id=\"entry-group-<%= date %>-totals\">\n        <%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>\n      </div>\n    <% end %>\n  </div>\n  <div class=\"bg-container shadow-border-xs rounded-lg\">\n    <%= content %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/entries/_loading.html.erb",
    "content": "<div class=\"bg-container space-y-4 p-5 shadow-border-xs rounded-xl\">\n  <div class=\"p-5 flex justify-center items-center\">\n    <%= tag.p t(\".loading\"), class: \"text-secondary animate-pulse text-sm\" %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/entries/_selection_bar.html.erb",
    "content": "<div class=\"fixed bottom-6 z-10 flex items-center justify-between rounded-xl bg-gray-900 px-4 text-sm text-white w-[420px] py-1.5\">\n  <div class=\"flex items-center gap-2\">\n    <%= check_box_tag \"entry_selection\", 1, true, class: \"checkbox checkbox--dark\", data: { action: \"bulk-select#deselectAll\" } %>\n\n    <p data-bulk-select-target=\"selectionBarText\"></p>\n  </div>\n\n  <div class=\"flex items-center gap-1 text-secondary\">\n    <%= form_with url: transactions_bulk_deletion_path, data: { turbo_confirm: CustomConfirm.for_resource_deletion(\"entry\").to_data_attribute, turbo_frame: \"_top\" } do %>\n      <button type=\"button\" data-bulk-select-scope-param=\"bulk_delete\" data-action=\"bulk-select#submitBulkRequest\" class=\"p-1.5 group hover:bg-inverse flex items-center justify-center rounded-md\" title=\"Delete\">\n        <%= icon \"trash-2\", class: \"group-hover:text-inverse\" %>\n      </button>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/family_exports/_list.html.erb",
    "content": "<%= turbo_frame_tag \"family_exports\",\n    data: exports.any? { |e| e.pending? || e.processing? } ? {\n      turbo_refresh_url: family_exports_path,\n      turbo_refresh_interval: 3000\n    } : {} do %>\n  <div class=\"mt-4 space-y-3 max-h-96 overflow-y-auto\">\n    <% if exports.any? %>\n      <% exports.each do |export| %>\n        <div class=\"flex items-center justify-between bg-container p-4 rounded-lg border border-primary\">\n          <div>\n            <p class=\"text-sm font-medium text-primary\">Export from <%= export.created_at.strftime(\"%B %d, %Y at %I:%M %p\") %></p>\n            <p class=\"text-xs text-secondary\"><%= export.filename %></p>\n          </div>\n\n          <% if export.processing? || export.pending? %>\n            <div class=\"flex items-center gap-2 text-secondary\">\n              <div class=\"animate-spin h-4 w-4 border-2 border-secondary border-t-transparent rounded-full\"></div>\n              <span class=\"text-sm\">Exporting...</span>\n            </div>\n          <% elsif export.completed? %>\n            <%= link_to download_family_export_path(export),\n                class: \"flex items-center gap-2 text-primary hover:text-primary-hover\",\n                data: { turbo_frame: \"_top\" } do %>\n              <%= icon \"download\", class: \"w-5 h-5\" %>\n              <span class=\"text-sm font-medium\">Download</span>\n            <% end %>\n          <% elsif export.failed? %>\n            <div class=\"flex items-center gap-2 text-destructive\">\n              <%= icon \"alert-circle\", class: \"w-4 h-4\" %>\n              <span class=\"text-sm\">Failed</span>\n            </div>\n          <% end %>\n        </div>\n      <% end %>\n    <% else %>\n      <p class=\"text-sm text-secondary text-center py-4\">No exports yet</p>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/family_exports/index.html.erb",
    "content": "<%= render \"list\", exports: @exports %>\n"
  },
  {
    "path": "app/views/family_exports/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: \"Export your data\", subtitle: \"Download all your financial data\") %>\n\n  <% dialog.with_body do %>\n    <div class=\"space-y-4\">\n      <div class=\"bg-container-inset rounded-lg p-4 space-y-3\">\n        <h3 class=\"font-medium text-primary\">What's included:</h3>\n        <ul class=\"space-y-2 text-sm text-secondary\">\n          <li class=\"flex items-start gap-2\">\n            <%= icon \"check\", class: \"shrink-0 mt-0.5 text-positive\" %>\n            <span>All accounts and balances</span>\n          </li>\n          <li class=\"flex items-start gap-2\">\n            <%= icon \"check\", class: \"shrink-0 mt-0.5 text-positive\" %>\n            <span>Transaction history</span>\n          </li>\n          <li class=\"flex items-start gap-2\">\n            <%= icon \"check\", class: \"shrink-0 mt-0.5 text-positive\" %>\n            <span>Investment trades</span>\n          </li>\n          <li class=\"flex items-start gap-2\">\n            <%= icon \"check\", class: \"shrink-0 mt-0.5 text-positive\" %>\n            <span>Categories and tags</span>\n          </li>\n        </ul>\n      </div>\n\n      <div class=\"bg-amber-50 border border-amber-200 rounded-lg p-3\">\n        <p class=\"text-sm text-amber-800\">\n          <strong>Note:</strong> This export includes all of your data, but only some of the data can be imported back into Maybe via the CSV import feature. We support account, transaction (with category and tags), and trade imports. Other account data cannot be imported and is for your records only.\n        </p>\n      </div>\n\n      <%= form_with url: family_exports_path, method: :post, class: \"space-y-4\" do |form| %>\n        <div class=\"flex gap-3\">\n          <%= link_to \"Cancel\", \"#\", class: \"flex-1 text-center px-4 py-2 border border-primary rounded-lg hover:bg-surface-hover\", data: { action: \"click->modal#close\" } %>\n          <%= form.submit \"Export data\", class: \"flex-1 bg-inverse fg-inverse rounded-lg px-4 py-2 cursor-pointer\" %>\n        </div>\n      <% end %>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/family_merchants/_family_merchant.html.erb",
    "content": "<%# locals: (family_merchant:) %>\n\n<div class=\"flex justify-between items-center p-4 bg-container\">\n  <div class=\"flex w-full items-center gap-2.5\">\n    <% if family_merchant.logo_url %>\n      <div class=\"w-8 h-8 rounded-full flex justify-center items-center\">\n        <%= image_tag family_merchant.logo_url, class: \"w-8 h-8 rounded-full\" %>\n      </div>\n    <% else %>\n      <%= render partial: \"shared/color_avatar\", locals: { name: family_merchant.name, color: family_merchant.color } %>\n    <% end %>\n\n    <p class=\"text-primary text-sm truncate\">\n      <%= family_merchant.name %>\n    </p>\n  </div>\n  <div class=\"justify-self-end\">\n    <%= render DS::Menu.new do |menu| %>\n      <% menu.with_item(variant: \"link\", text: \"Edit\", href: edit_family_merchant_path(family_merchant), icon: \"pencil\", data: { turbo_frame: \"modal\" }) %>\n      <% menu.with_item(\n        variant: \"button\",\n        text: \"Delete\",\n        href: family_merchant_path(family_merchant),\n        icon: \"trash-2\",\n        method: :delete,\n        confirm: CustomConfirm.for_resource_deletion(family_merchant.name)) %>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/family_merchants/_form.html.erb",
    "content": "<%# locals: (family_merchant:) %>\n\n<div data-controller=\"color-avatar\">\n  <%= styled_form_with model: family_merchant, class: \"space-y-4\" do |f| %>\n    <section class=\"space-y-4\">\n      <% if family_merchant.errors.any? %>\n        <%= render \"shared/form_errors\", model: family_merchant %>\n      <% end %>\n\n      <div class=\"w-fit m-auto mb-4\">\n        <%= render partial: \"shared/color_avatar\", locals: { name: family_merchant.name, color: family_merchant.color } %>\n      </div>\n      <div class=\"flex gap-2 items-center justify-center\">\n        <% FamilyMerchant::COLORS.each do |color| %>\n          <label class=\"relative\">\n            <%= f.radio_button :color, color, class: \"sr-only peer\", data: { action: \"change->color-avatar#handleColorChange\" } %>\n            <div class=\"w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500\" style=\"background-color: <%= color %>\"></div>\n          </label>\n        <% end %>\n      </div>\n      <div class=\"relative flex items-center border border-secondary rounded-lg text-subdued\">\n        <%= f.text_field :name, placeholder: t(\".name_placeholder\"), autofocus: true, required: true, data: { color_avatar_target: \"name\" } %>\n      </div>\n    </section>\n\n    <section>\n      <%= f.submit %>\n    </section>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/family_merchants/edit.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".title\")) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", family_merchant: @family_merchant %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/family_merchants/index.html.erb",
    "content": "<header class=\"flex items-center justify-between\">\n  <h1 class=\"text-primary text-xl font-medium\">Merchants</h1>\n\n  <%= render DS::Link.new(\n    text: \"New merchant\",\n    variant: \"primary\",\n    href: new_family_merchant_path,\n    frame: :modal\n  ) %>\n</header>\n\n<div class=\"bg-container rounded-xl shadow-border-xs p-4\">\n  <% if @family_merchants.any? %>\n    <div class=\"rounded-xl bg-container-inset space-y-1 p-1\">\n      <div class=\"flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase\">\n        <p><%= t(\".title\") %></p>\n        <span class=\"text-subdued\">&middot;</span>\n        <p><%= @family_merchants.count %></p>\n      </div>\n\n      <div class=\"bg-container rounded-lg shadow-border-xs\">\n        <div class=\"overflow-hidden rounded-lg\">\n          <%= render partial: \"family_merchants/family_merchant\", collection: @family_merchants, spacer_template: \"shared/ruler\" %>\n        </div>\n      </div>\n    </div>\n  <% else %>\n    <div class=\"flex justify-center items-center py-20\">\n      <div class=\"text-center flex flex-col items-center max-w-[300px]\">\n        <p class=\"text-primary mb-1 font-medium text-sm\"><%= t(\".empty\") %></p>\n\n        <%= render DS::Link.new(\n          text: t(\".new\"),\n          icon: \"plus\",\n          href: new_family_merchant_path,\n          frame: :modal\n        ) %>\n      </div>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/family_merchants/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".title\")) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", family_merchant: @family_merchant %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/holdings/_cash.html.erb",
    "content": "<%# locals: (account:) %>\n\n<% currency = Money::Currency.new(account.currency) %>\n\n<div class=\"grid grid-cols-12 items-center text-primary text-sm font-medium p-4\">\n  <div class=\"col-span-4 flex items-center gap-4\">\n    <%= render DS::FilledIcon.new(\n      variant: :text,\n      text: currency.symbol,\n      rounded: true,\n      size: \"lg\"\n    ) %>\n\n    <div class=\"space-y-0.5\">\n      <%= tag.p t(\".brokerage_cash\"), class: \"text-primary\" %>\n      <%= tag.p account.currency, class: \"text-secondary text-xs uppercase\" %>\n    </div>\n  </div>\n\n  <div class=\"col-span-2 flex justify-end items-center gap-2\">\n    <% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %>\n\n    <%= render \"shared/progress_circle\", progress: cash_weight %>\n    <%= tag.p number_to_percentage(cash_weight, precision: 1) %>\n  </div>\n\n  <div class=\"col-span-2 text-right\">\n    <%= tag.p \"--\", class: \"text-secondary\" %>\n  </div>\n\n  <div class=\"col-span-2 text-right\">\n    <%= tag.p format_money account.cash_balance_money %>\n  </div>\n\n  <div class=\"col-span-2 text-right\">\n    <%= tag.p \"--\", class: \"text-secondary\" %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/holdings/_holding.html.erb",
    "content": "<%# locals: (holding:) %>\n\n<%= turbo_frame_tag dom_id(holding) do %>\n  <div class=\"grid grid-cols-12 items-center text-primary text-sm font-medium p-4\">\n    <div class=\"col-span-4 flex items-center gap-4\">\n      <%= image_tag \"https://logo.synthfinance.com/ticker/#{holding.ticker}\", class: \"w-9 h-9 rounded-full\", loading: \"lazy\" %>\n\n      <div class=\"space-y-0.5\">\n        <%= link_to holding.name, holding_path(holding), data: { turbo_frame: :drawer }, class: \"hover:underline\" %>\n\n        <% if holding.amount %>\n          <%= tag.p holding.ticker, class: \"text-secondary text-xs uppercase\" %>\n        <% else %>\n          <%= render \"missing_price_tooltip\" %>\n        <% end %>\n      </div>\n    </div>\n\n    <div class=\"col-span-2 flex justify-end items-center gap-2\">\n      <% if holding.weight %>\n        <%= render \"shared/progress_circle\", progress: holding.weight %>\n        <%= tag.p number_to_percentage(holding.weight, precision: 1) %>\n      <% else %>\n        <%= tag.p \"--\", class: \"text-secondary mb-5\" %>\n      <% end %>\n    </div>\n\n    <div class=\"col-span-2 text-right\">\n      <%= tag.p format_money holding.avg_cost %>\n      <%= tag.p t(\".per_share\"), class: \"font-normal text-secondary\" %>\n    </div>\n\n    <div class=\"col-span-2 text-right\">\n      <% if holding.amount_money %>\n        <%= tag.p format_money holding.amount_money %>\n      <% else %>\n        <%= tag.p \"--\", class: \"text-secondary\" %>\n      <% end %>\n      <%= tag.p t(\".shares\", qty: number_with_precision(holding.qty, precision: 1)), class: \"font-normal text-secondary\" %>\n    </div>\n\n    <div class=\"col-span-2 text-right\">\n      <% if holding.trend %>\n        <%= tag.p format_money(holding.trend.value), style: \"color: #{holding.trend.color};\" %>\n        <%= tag.p \"(#{number_to_percentage(holding.trend.percent, precision: 1)})\", style: \"color: #{holding.trend.color};\" %>\n      <% else %>\n        <%= tag.p \"--\", class: \"text-secondary mb-4\" %>\n      <% end %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/holdings/_missing_price_tooltip.html.erb",
    "content": "<div data-controller=\"tooltip\" data-tooltip-cross-axis-value=\"50\">\n  <div class=\"flex items-center gap-1 text-warning\">\n    <%= icon \"info\", size: \"sm\", color: \"current\" %>\n    <%= tag.span t(\".missing_data\"), class: \"font-normal text-xs\" %>\n  </div>\n  <div role=\"tooltip\" data-tooltip-target=\"tooltip\" class=\"tooltip bg-gray-700 text-sm p-2 rounded w-64\">\n    <div class=\"fg-inverse\">\n      <%= t(\".description\") %>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/holdings/index.html.erb",
    "content": "<%= turbo_frame_tag dom_id(@account, \"holdings\") do %>\n  <div class=\"bg-container space-y-4 p-5 rounded-xl shadow-border-xs\">\n    <div class=\"flex items-center justify-between\">\n      <%= tag.h2 t(\".holdings\"), class: \"font-medium text-lg\" %>\n      <%= link_to new_trade_path(account_id: @account.id),\n                  id: dom_id(@account, \"new_trade\"),\n                  data: { turbo_frame: :modal },\n                  class: \"flex gap-1 font-medium items-center bg-gray-50 text-primary p-2 rounded-lg\" do %>\n        <span class=\"text-primary\">\n          <%= icon(\"plus\", color: \"current\") %>\n        </span>\n        <%= tag.span t(\".new_holding\"), class: \"text-sm\" %>\n      <% end %>\n    </div>\n\n    <div class=\"bg-container-inset rounded-xl p-1\">\n      <div class=\"grid grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-4 py-2\">\n        <%= tag.p t(\".name\"), class: \"col-span-4\" %>\n        <%= tag.p t(\".weight\"), class: \"col-span-2 justify-self-end\" %>\n        <%= tag.p t(\".average_cost\"), class: \"col-span-2 justify-self-end\" %>\n        <%= tag.p t(\".holdings\"), class: \"col-span-2 justify-self-end\" %>\n        <%= tag.p t(\".return\"), class: \"col-span-2 justify-self-end\" %>\n      </div>\n\n      <div class=\"bg-container rounded-lg shadow-border-xs\">\n        <%= render \"holdings/cash\", account: @account %>\n        <%= render \"shared/ruler\" %>\n\n        <% if @account.current_holdings.any? %>\n          <%= render partial: \"holdings/holding\", collection: @account.current_holdings, spacer_template: \"shared/ruler\" %>\n        <% end %>\n      </div>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/holdings/new.html.erb",
    "content": "<p>Coming soon...</p>\n"
  },
  {
    "path": "app/views/holdings/show.html.erb",
    "content": "<%= render DS::Dialog.new(variant: \"drawer\") do |dialog| %>\n  <% dialog.with_header do %>\n    <div class=\"flex items-center justify-between\">\n      <div>\n        <%= tag.h3 @holding.name, class: \"text-2xl font-medium text-primary\" %>\n        <%= tag.p @holding.ticker, class: \"text-sm text-secondary\" %>\n      </div>\n\n      <%= image_tag \"https://logo.synthfinance.com/ticker/#{@holding.ticker}\", loading: \"lazy\", class: \"w-9 h-9 rounded-full\" %>\n    </div>\n  <% end %>\n\n  <% dialog.with_body do %>\n    <% dialog.with_section(title: t(\".overview\"), open: true) do %>\n      <div class=\"pb-4\">\n        <dl class=\"space-y-3 px-3 py-2\">\n          <div class=\"flex items-center justify-between text-sm\">\n            <dt class=\"text-secondary\"><%= t(\".ticker_label\") %></dt>\n            <dd class=\"text-primary\"><%= @holding.ticker %></dd>\n          </div>\n\n          <div class=\"flex items-center justify-between text-sm\">\n            <dt class=\"text-secondary\"><%= t(\".current_market_price_label\") %></dt>\n            <dd class=\"text-primary\"><%= @holding.security.current_price ? format_money(@holding.security.current_price) : t(\".unknown\") %></dd>\n          </div>\n\n          <div class=\"flex items-center justify-between text-sm\">\n            <dt class=\"text-secondary\"><%= t(\".portfolio_weight_label\") %></dt>\n            <dd class=\"text-primary\"><%= @holding.weight ? number_to_percentage(@holding.weight, precision: 2) : t(\".unknown\") %></dd>\n          </div>\n\n          <div class=\"flex items-center justify-between text-sm\">\n            <dt class=\"text-secondary\"><%= t(\".avg_cost_label\") %></dt>\n            <dd class=\"text-primary\"><%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(\".unknown\") %></dd>\n          </div>\n\n          <div class=\"flex items-center justify-between text-sm\">\n            <dt class=\"text-secondary\"><%= t(\".total_return_label\") %></dt>\n            <dd style=\"color: <%= @holding.trend&.color %>;\">\n              <%= @holding.trend ? render(\"shared/trend_change\", trend: @holding.trend) : t(\".unknown\") %>\n            </dd>\n          </div>\n        </dl>\n      </div>\n    <% end %>\n\n    <% dialog.with_section(title: t(\".history\"), open: true) do %>\n      <div class=\"space-y-2\">\n        <div class=\"px-3 py-4\">\n          <% if @holding.trades.any? %>\n            <ul class=\"space-y-2\">\n              <% @holding.trades.each_with_index do |trade_entry, index| %>\n                <li class=\"flex gap-4 text-sm space-y-1\">\n                  <div class=\"flex flex-col items-center gap-1.5 pt-2\">\n                    <div class=\"rounded-full h-1.5 w-1.5 bg-gray-300\"></div>\n                    <% unless index == @holding.trades.length - 1 %>\n                      <div class=\"h-12 w-px bg-alpha-black-200\"></div>\n                    <% end %>\n                  </div>\n\n                  <div>\n                    <p class=\"text-secondary text-xs uppercase\"><%= l(trade_entry.date, format: :long) %></p>\n\n                    <p><%= t(\n                      \".trade_history_entry\",\n                      qty: trade_entry.trade.qty,\n                      security: trade_entry.trade.security.ticker,\n                      price: trade_entry.trade.price_money.format\n                    ) %></p>\n                  </div>\n                </li>\n              <% end %>\n            </ul>\n\n          <% else %>\n            <p class=\"text-secondary\">No trade history available for this holding.</p>\n          <% end %>\n        </div>\n      </div>\n    <% end %>\n\n    <% unless @holding.account.plaid_account_id.present? %>\n      <% dialog.with_section(title: t(\".settings\"), open: true) do %>\n        <div class=\"pb-4\">\n          <div class=\"flex items-center justify-between gap-2 p-3\">\n            <div class=\"text-sm space-y-1\">\n              <h4 class=\"text-primary\"><%= t(\".delete_title\") %></h4>\n              <p class=\"text-secondary\"><%= t(\".delete_subtitle\") %></p>\n            </div>\n\n            <%= button_to t(\".delete\"),\n                holding_path(@holding),\n                method: :delete,\n                class: \"rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary\",\n                data: { turbo_confirm: true } %>\n          </div>\n        </div>\n      <% end %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/impersonation_sessions/_approval_bar.html.erb",
    "content": "<% pending_session = Current.true_user.impersonated_support_sessions.pending.first %>\n<% in_progress_session = Current.true_user.impersonated_support_sessions.in_progress.first %>\n\n<div class=\"sticky top-0 left-0 w-full bg-black flex items-center justify-between font-mono\">\n  <div class=\"flex items-center bg-red-600 text-inverse px-6 py-4\">\n    <%= icon \"alert-triangle\", size: \"lg\", color: \"current\", class: \"mr-2\" %>\n    <span class=\"text-inverse font-semibold uppercase\">Access <%= in_progress_session.present? ? \"Session\" : \"Request\" %></span>\n  </div>\n  <div class=\"flex items-center space-x-2 px-2 py-2 text-white gap-4\">\n    <% if pending_session.present? %>\n      <p class=\"text-xs max-w-3xl text-right\">Maybe support staff has requested access to your account (likely to help you with a support request). If you approve the request, all activity they take will be logged for security and audit purposes.</p>\n      <%= button_to \"Approve\", approve_impersonation_session_path(pending_session), method: :put, class: \"inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-green-500\" %>\n      <%= button_to \"Reject\", reject_impersonation_session_path(pending_session), method: :put, class: \"inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500\" %>\n    <% elsif in_progress_session.present? %>\n      <p class=\"text-xs max-w-3xl text-right\">Someone from the Maybe Finance team is currently viewing your data.  You may end the session at any time.</p>\n      <%= button_to \"End Session\", complete_impersonation_session_path(in_progress_session), method: :put, class: \"inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500\" %>\n    <% else %>\n      <p class=\"text-xs max-w-3xl text-right text-red-500\">Something went wrong.  Please contact us.</p>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/impersonation_sessions/_super_admin_bar.html.erb",
    "content": "<div class=\"sticky top-0 left-0 w-full bg-black flex items-center justify-between font-mono\">\n  <div class=\"flex items-center bg-red-600 px-6 py-4\">\n    <%= icon \"alert-triangle\", size: \"lg\", color: \"current\", class: \"mr-2\" %>\n    <span class=\"text-inverse font-semibold uppercase\">Super Admin</span>\n  </div>\n  <div>\n    <%= link_to \"Jobs\", sidekiq_web_url, class: \"text-white underline hover:text-gray-100\" %>\n  </div>\n\n  <div class=\"flex items-center space-x-2 px-2 py-2 text-white\">\n    <% if Current.session.active_impersonator_session.present? %>\n      <div class=\"flex items-center space-x-3 bg-gray-800 border border-gray-700 rounded-md pl-3\">\n        <div class=\"text-sm\">\n          Impersonating: <span class=\"font-semibold text-red-400\"><%= Current.impersonated_user.email %></span>\n        </div>\n        <%= button_to \"Leave\", leave_impersonation_sessions_path, method: :delete, class: \"items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500\" %>\n        <%= button_to \"Terminate\", complete_impersonation_session_path(Current.session.active_impersonator_session), method: :put, class: \"items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500\" %>\n      </div>\n    <% else %>\n      <% if Current.true_user.impersonator_support_sessions.in_progress.any? %>\n        <%= form_with url: join_impersonation_sessions_path, class: \"flex items-center space-x-2 mr-4\" do |f| %>\n          <%= f.select :impersonation_session_id,\n            Current.true_user.impersonator_support_sessions.in_progress.map { |session|\n              [\"#{session.impersonated.email} (#{session.status})\", session.id]\n            },\n            { prompt: \"Join a session\" },\n            { class: \"rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono\" } %>\n          <%= f.submit \"Join\",\n            class: \"inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500\" %>\n        <% end %>\n      <% end %>\n\n      <%= form_with model: ImpersonationSession.new, class: \"flex items-center space-x-2\" do |f| %>\n        <%= f.text_field :impersonated_id, class: \"rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono w-96\", placeholder: \"UUID\", autocomplete: \"off\" %>\n        <%= f.submit \"Request Impersonation\", class: \"inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500\" %>\n      <% end %>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/import/cleans/show.html.erb",
    "content": "<%= content_for :header_nav do %>\n  <%= render \"imports/nav\", import: @import %>\n<% end %>\n\n<%= content_for :previous_path, import_configuration_path(@import) %>\n\n<div class=\"space-y-4 mx-auto max-w-5xl\">\n  <div class=\"text-center space-y-2 max-w-[400px] mx-auto mb-4\">\n    <h2 class=\"text-3xl text-primary font-medium\"><%= t(\".title\") %></h2>\n    <p class=\"text-secondary text-sm\"><%= t(\".description\") %></p>\n  </div>\n\n  <% if @import.cleaned? %>\n    <div class=\"bg-container border border-tertiary rounded-lg p-3 flex flex-col md:flex-row items-start md:items-center justify-between gap-2 md:gap-0\">\n      <div class=\"flex items-center gap-2\">\n        <%= icon \"check-circle\", size: \"sm\", color: \"success\" %>\n        <p class=\"text-success text-sm\">Your data has been cleaned</p>\n      </div>\n\n      <%= render DS::Link.new(\n        text: \"Next step\",\n        variant: \"primary\",\n        href: import_confirm_path(@import),\n        frame: :_top,\n        class: \"w-full md:w-auto\"\n      ) %>\n    </div>\n  <% else %>\n    <div class=\"bg-container border border-tertiary rounded-lg p-3 flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0\">\n      <div class=\"flex items-center gap-2\">\n        <%= icon \"alert-triangle\", size: \"sm\", color: \"destructive\" %>\n        <p class=\"text-destructive text-sm hidden md:block\"><%= t(\".errors_notice\") %></p>\n        <p class=\"text-destructive text-sm md:hidden\"><%= t(\".errors_notice_mobile\") %></p>\n      </div>\n\n      <div class=\"flex justify-center w-full md:w-auto\">\n        <div class=\"bg-gray-50 rounded-lg inline-flex p-1 space-x-2 text-sm text-primary font-medium w-full md:w-auto\">\n          <%= link_to \"All rows\", import_clean_path(@import, per_page: params[:per_page], view: \"all\"), class: \"p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] != 'errors' ? 'bg-container' : ''}\" %>\n          <%= link_to \"Error rows\", import_clean_path(@import, per_page: params[:per_page], view: \"errors\"), class: \"p-2 rounded-lg flex-1 md:flex-auto text-center #{params[:view] == 'errors' ? 'bg-container' : ''}\" %>\n        </div>\n      </div>\n    </div>\n  <% end %>\n\n  <div class=\"pb-12\">\n    <div class=\"bg-container-inset rounded-xl p-1 mb-6\">\n      <div class=\"overflow-x-auto\">\n        <div style=\"grid-template-columns: repeat(<%= @import.column_keys.count %>, minmax(150px, 1fr));\" class=\"grid items-center uppercase text-xs font-medium text-secondary py-3\">\n          <% @import.column_keys.each do |key| %>\n            <div class=\"px-5\"><%= import_col_label(key) %></div>\n          <% end %>\n        </div>\n\n        <div class=\"bg-container shadow-border-xs rounded-xl divide-y divide-alpha-black-200 theme-dark:divide-alpha-white-200\">\n          <% @rows.each do |row| %>\n            <%= render \"import/rows/form\", row: row %>\n          <% end %>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <div class=\"fixed bottom-0 left-1/2 -translate-x-1/2 w-full p-12\">\n    <div class=\"shadow-border-xs rounded-lg p-3 max-w-2xl mx-auto bg-container\">\n      <%= render \"shared/pagination\", pagy: @pagy %>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/import/configurations/_account_import.html.erb",
    "content": "<%# locals: (import:) %>\n\n<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: \"space-y-4\" do |form| %>\n  <%= form.select :entity_type_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Entity Type\" } %>\n  <%= form.select :name_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Name\" }, required: true %>\n  <%= form.select :amount_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Balance\" }, required: true %>\n  <%= form.select :currency_col_label, import.csv_headers, { include_blank: \"Default\", label: \"Currency\" } %>\n\n  <%= form.submit \"Apply configuration\", disabled: import.complete? %>\n<% end %>\n"
  },
  {
    "path": "app/views/import/configurations/_mint_import.html.erb",
    "content": "<%# locals: (import:) %>\n\n<div class=\"flex items-center justify-between border border-secondary rounded-lg bg-green-500/5 p-5 gap-4 mb-4\">\n  <span class=\"text-green-500\">\n    <%= icon(\"check-circle\", color: \"current\") %>\n  </span>\n  <p class=\"text-sm text-primary italic\">We have pre-configured your Mint import for you. Please proceed to the next step.</p>\n</div>\n\n<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: \"space-y-4\" do |form| %>\n  <div class=\"flex items-center gap-4\">\n    <%= form.select :date_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Date\" }, required: true, disabled: import.complete? %>\n    <%= form.select :date_format, Family::DATE_FORMATS, { label: t(\".date_format_label\")}, label: true, required: true, disabled: import.complete? %>\n  </div>\n\n  <div class=\"flex items-center gap-4\">\n    <%= form.select :amount_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Amount\" }, required: true, disabled: import.complete? %>\n    <%= form.select :signage_convention, [[\"Incomes are negative\", \"inflows_negative\"], [\"Incomes are positive\", \"inflows_positive\"]], { label: true }, disabled: import.complete? %>\n  </div>\n\n  <div class=\"flex items-center gap-4\">\n    <%= form.select :currency_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Currency\" }, disabled: import.complete? %>\n    <%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: \"Format\", prompt: \"Select format\" }, required: true %>\n  </div>\n\n  <% unless import.account.present? %>\n    <%= form.select :account_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Account (optional)\" }, disabled: import.complete? %>\n  <% end %>\n\n  <%= form.select :name_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Name (optional)\" }, disabled: import.complete? %>\n  <%= form.select :category_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Category (optional)\" }, disabled: import.complete? %>\n  <%= form.select :tags_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Tags (optional)\" }, disabled: import.complete? %>\n\n  <%= form.submit \"Apply configuration\", disabled: import.complete? %>\n<% end %>\n"
  },
  {
    "path": "app/views/import/configurations/_trade_import.html.erb",
    "content": "<%# locals: (import:) %>\n\n<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: \"space-y-4\" do |form| %>\n  <div class=\"space-y-4\">\n    <div class=\"flex items-center gap-4\">\n      <%= form.select :date_col_label, import.csv_headers, { include_blank: \"Select column\", label: \"Date\" }, required: true %>\n      <%= form.select :date_format, Family::DATE_FORMATS, { label: t(\".date_format_label\")}, label: true, required: true %>\n    </div>\n\n    <div class=\"flex items-center gap-4\">\n      <%= form.select :qty_col_label, import.csv_headers, { include_blank: \"Select column\", label: \"Quantity\" }, required: true %>\n      <%= form.select :signage_convention, [[\"Buys are positive qty\", \"inflows_positive\"], [\"Buys are negative qty\", \"inflows_negative\"]], label: true, required: true %>\n    </div>\n\n    <div class=\"flex items-center gap-4\">\n      <%= form.select :currency_col_label, import.csv_headers, { include_blank: \"Default\", label: \"Currency\" } %>\n      <%= form.select :number_format, Import::NUMBER_FORMATS.keys, { label: \"Format\", prompt: \"Select format\" }, required: true %>\n    </div>\n\n    <%= form.select :ticker_col_label, import.csv_headers, { include_blank: \"Select column\", label: \"Ticker\" }, required: true %>\n    <%= form.select :exchange_operating_mic_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Stock exchange code\" } %>\n    <%= form.select :price_col_label, import.csv_headers, { include_blank: \"Select column\", label: \"Price\" }, required: true %>\n\n    <% unless import.account.present? %>\n      <%= form.select :account_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Account\" } %>\n    <% end %>\n\n    <%= form.select :name_col_label, import.csv_headers, { include_blank: \"Leave empty\", label: \"Name\" } %>\n\n    <% unless Security.provider %>\n      <div class=\"alert alert-warning\">\n        <p>\n          <strong>Note:</strong> The security prices provider is not configured.  Your trade imports will work, but Maybe will not backfill price history.  Please go to your settings to configure this.\n        </p>\n      </div>\n    <% end %>\n  </div>\n\n  <%= form.submit \"Apply configuration\", disabled: import.complete? %>\n<% end %>\n"
  },
  {
    "path": "app/views/import/configurations/_transaction_import.html.erb",
    "content": "<%# locals: (import:) %>\n\n<%= styled_form_with model: @import,\n                     url: import_configuration_path(@import),\n                     scope: :import,\n                     method: :patch,\n                     class: \"space-y-4\",\n                     data: {\n                       controller: \"import\",\n                       import_csv_value: import.csv_rows.to_json,\n                       import_amount_type_column_key_value: @import.entity_type_col_label\n                     } do |form| %>\n\n  <%# Date Configuration %>\n  <div class=\"flex items-center gap-4\">\n    <%= form.select :date_col_label,\n                    import.csv_headers,\n                    { label: \"Date\", prompt: \"Select column\" },\n                    required: true %>\n    <%= form.select :date_format,\n                    Family::DATE_FORMATS,\n                    { label: t(\".date_format_label\"), prompt: \"Select format\" },\n                    required: true %>\n  </div>\n\n  <%# Amount Configuration %>\n  <div class=\"flex items-center gap-4\">\n    <%= form.select :amount_col_label,\n                    import.csv_headers,\n                    { label: \"Amount\", container_class: \"w-2/5\", prompt: \"Select column\" },\n                    required: true %>\n    <%= form.select :currency_col_label,\n                    import.csv_headers,\n                    { include_blank: \"Default\", label: \"Currency\", container_class: \"w-1/5\" } %>\n    <%= form.select :number_format,\n                    Import::NUMBER_FORMATS.keys,\n                    { label: \"Format\", prompt: \"Select format\", container_class: \"w-2/5\" },\n                    required: true %>\n  </div>\n\n  <%# Amount Type Strategy %>\n  <%= form.select :amount_type_strategy,\n                  Import::AMOUNT_TYPE_STRATEGIES.map { |strategy| [strategy.humanize, strategy] },\n                  { label: \"Amount type strategy\", prompt: \"Select strategy\" },\n                  required: true,\n                  data: {\n                    action: \"import#handleAmountTypeStrategyChange\",\n                    import_target: \"amountTypeStrategySelect\"\n                  } %>\n\n  <%# Signed Amount Configuration %>\n  <%= tag.fieldset data: { import_target: \"signedAmountFieldset\" },\n                   class: @import.amount_type_strategy == \"signed_amount\" ? \"block\" : \"hidden\" do %>\n    <div class=\"flex items-center gap-2\">\n      <span class=\"text-sm shrink-0 text-secondary\">↪</span>\n      <%= form.select :signage_convention,\n                      [[\"Incomes are positive\", \"inflows_positive\"], [\"Incomes are negative\", \"inflows_negative\"]],\n                      { label: \"Amount type\", prompt: \"Select convention\" },\n                      required: @import.amount_type_strategy == \"signed_amount\" %>\n    </div>\n  <% end %>\n\n  <%# Custom Column Configuration %>\n  <%= tag.fieldset data: { import_target: \"customColumnFieldset\" },\n                   class: @import.amount_type_strategy == \"custom_column\" ? \"block\" : \"hidden\" do %>\n    <div class=\"space-y-2\">\n      <div class=\"flex items-center gap-2 text-sm\">\n        <span class=\"shrink-0 text-secondary\">↪</span>\n        <span class=\"text-secondary\">Set</span>\n        <%= form.select :entity_type_col_label,\n                        import.csv_headers,\n                        { prompt: \"Select column\", container_class: \"w-48 px-3 py-1.5 border border-secondary rounded-md\" },\n                        required: @import.amount_type_strategy == \"custom_column\",\n                        data: { action: \"import#handleAmountTypeChange\" } %>\n        <span class=\"text-secondary\">as amount type column</span>\n      </div>\n\n      <div class=\"items-center gap-2 text-sm <%= @import.entity_type_col_label.nil? ? \"hidden\" : \"flex\" %>\" data-import-target=\"amountTypeValue\">\n        <span class=\"shrink-0 text-secondary\">↪</span>\n        <span class=\"text-secondary\">Set</span>\n        <%= form.select :amount_type_inflow_value,\n                        @import.selectable_amount_type_values,\n                        { prompt: \"Select column\", container_class: \"w-48 px-3 py-1.5 border border-secondary rounded-md\" },\n                        required: @import.amount_type_strategy == \"custom_column\" %>\n        <span class=\"text-secondary\">as \"income\" (inflow) value</span>\n      </div>\n    </div>\n  <% end %>\n\n  <%# Optional Fields %>\n  <% unless import.account.present? %>\n    <%= form.select :account_col_label,\n                    import.csv_headers,\n                    { include_blank: \"Leave empty\", label: \"Account\" } %>\n  <% end %>\n\n  <%= form.select :name_col_label,\n                  import.csv_headers,\n                  { include_blank: \"Leave empty\", label: \"Name\" } %>\n  <%= form.select :category_col_label,\n                  import.csv_headers,\n                  { include_blank: \"Leave empty\", label: \"Category\" } %>\n  <%= form.select :tags_col_label,\n                  import.csv_headers,\n                  { include_blank: \"Leave empty\", label: \"Tags\" } %>\n  <%= form.select :notes_col_label,\n                  import.csv_headers,\n                  { include_blank: \"Leave empty\", label: \"Notes\" } %>\n\n  <%= form.submit \"Apply configuration\", disabled: import.complete? %>\n<% end %>\n"
  },
  {
    "path": "app/views/import/configurations/show.html.erb",
    "content": "<%= content_for :header_nav do %>\n  <%= render \"imports/nav\", import: @import %>\n<% end %>\n\n<%= content_for :previous_path, import_upload_path(@import) %>\n\n<% if @import.suggested_template.present? && params[:template_hint] == \"true\" %>\n  <div class=\"py-12\">\n    <div class=\"shadow-border-xs rounded-lg p-4 max-w-lg mx-auto\">\n      <h3 class=\"text-sm font-medium mb-2 flex items-center gap-2\">\n        <span class=\"text-success\">\n          <%= icon \"sparkles\" %>\n        </span>\n\n        Template configuration found\n      </h3>\n\n      <p class=\"text-sm text-secondary\">We found a configuration from a previous import for this account.  Would you like to apply it to this import?</p>\n\n      <div class=\"mt-4 flex gap-2 items-center\">\n        <%= render DS::Link.new(text: \"Manually configure\", href: import_configuration_path(@import), variant: \"outline\") %>\n        <%= render DS::Button.new(text: \"Apply template\", href: apply_template_import_path(@import), method: :put, data: { turbo_frame: :_top }) %>\n      </div>\n    </div>\n  </div>\n<% else %>\n  <div class=\"space-y-6\">\n    <div class=\"text-center space-y-2\">\n      <h1 class=\"text-3xl text-primary font-medium\"><%= t(\".title\") %></h1>\n      <p class=\"text-secondary text-sm\"><%= t(\".description\") %></p>\n    </div>\n\n    <div class=\"mx-auto max-w-lg space-y-4\">\n      <%= render partial: permitted_import_configuration_path(@import), locals: { import: @import } %>\n      <%= render \"imports/table\", headers: @import.csv_headers, rows: @import.csv_sample, caption: \"Sample data from your uploaded CSV\" %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/import/confirms/_mappings.html.erb",
    "content": "<%# locals: (import:, mapping_class:, step_idx:) %>\n\n<% mappings = mapping_class.for_import(import) %>\n<% is_last_step = step_idx == import.mapping_steps.count - 1 %>\n\n<div class=\"w-full max-w-full\">\n  <% if mapping_class == Import::AccountMapping && import.account.nil? %>\n    <% if import.requires_account? %>\n      <div class=\"w-full max-w-full overflow-hidden mb-4\">\n        <div class=\"overflow-x-auto\">\n          <div class=\"flex items-center justify-between p-4 gap-4 text-secondary bg-red-100 border border-red-200 rounded-lg w-[650px] min-w-0 mx-auto\">\n            <%= tag.p t(\".no_accounts\"), class: \"text-sm\" %>\n\n            <%= render DS::Link.new(\n              text: \"Create account\",\n              variant: \"primary\",\n              href: new_account_path(return_to: import_confirm_path(import)),\n              frame: :modal\n            ) %>\n          </div>\n        </div>\n      </div>\n    <% elsif import.has_unassigned_account? %>\n      <div class=\"w-full max-w-full overflow-hidden mb-4\">\n        <div class=\"overflow-x-auto\">\n          <div class=\"flex items-center justify-between p-4 gap-4 text-secondary bg-yellow-100 border border-yellow-200 rounded-lg w-[650px] min-w-0 mx-auto\">\n            <%= tag.p t(\".unassigned_account\"), class: \"text-sm\" %>\n            <%= render DS::Link.new(\n              text: t(\".create_account\"),\n              variant: \"primary\",\n              href: new_account_path(return_to: import_confirm_path(import)),\n              frame: :modal\n            ) %>\n          </div>\n        </div>\n      </div>\n    <% end %>\n  <% end %>\n\n  <div class=\"space-y-4 w-full max-w-full\">\n    <div class=\"w-full max-w-full overflow-hidden\">\n      <div class=\"overflow-x-auto\">\n        <div class=\"bg-container-inset rounded-xl p-1 space-y-1 w-[650px] min-w-0 mx-auto\">\n          <div class=\"grid grid-cols-3 gap-2 text-xs font-medium text-secondary uppercase px-5 py-3\">\n            <p><%= t(\".csv_mapping_label\", mapping: mapping_label(mapping_class)) %></p>\n            <p><%= t(\".maybe_mapping_label\", mapping: mapping_label(mapping_class)) %></p>\n            <p class=\"justify-self-end\"><%= t(\".rows_label\") %></p>\n          </div>\n\n          <div class=\"shadow-border-xs rounded-md divide-y divide-alpha-black-100 text-sm\">\n            <% mappings.sort_by(&:key).each do |mapping| %>\n              <div class=\"px-5 py-3 bg-container first:rounded-tl-xl first:rounded-tr-xl last:rounded-bl-xl last:rounded-br-xl\">\n                <%= render partial: \"import/mappings/form\", locals: { mapping: mapping } %>\n              </div>\n            <% end %>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"flex justify-center w-full\">\n      <%= render DS::Link.new(\n        text: \"Next\",\n        variant: \"primary\",\n        href: is_last_step ? import_path(import) : url_for(step: step_idx + 2),\n        icon: \"arrow-right\",\n        icon_position: \"right\",\n        class: \"w-full md:w-auto\"\n      ) %>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/import/confirms/show.html.erb",
    "content": "<%= content_for :header_nav do %>\n  <%= render \"imports/nav\", import: @import %>\n<% end %>\n\n<%= content_for :previous_path, import_clean_path(@import) %>\n\n<% step_idx = (params[:step] || \"1\").to_i - 1 %>\n<% step_mapping_class = @import.mapping_steps[step_idx] %>\n\n<div class=\"space-y-12 mx-auto max-w-md mb-6\">\n  <div class=\"flex justify-center items-center gap-2\">\n    <% @import.mapping_steps.each_with_index do |step_mapping_class, idx| %>\n      <% is_active = step_idx == idx %>\n\n      <%= link_to url_for(step: idx + 1), class: \"w-5 h-[3px] #{is_active ? 'bg-gray-900' : 'bg-gray-100'} rounded-xl hover:bg-gray-300 transition-colors duration-200\" do %>\n        <span class=\"sr-only\">Step <%= idx + 1 %></span>\n      <% end %>\n    <% end %>\n  </div>\n\n  <div class=\"text-center space-y-2\">\n    <h1 class=\"text-3xl text-primary font-medium\">\n      <%= t(\".#{step_mapping_class.name.demodulize.underscore}_title\", import_type: @import.type.underscore.humanize) %>\n    </h1>\n    <p class=\"text-secondary text-sm\">\n      <%= t(\".#{step_mapping_class.name.demodulize.underscore}_description\", import_type: @import.type.underscore.humanize) %>\n    </p>\n  </div>\n</div>\n\n<div class=\"max-w-3xl mx-auto flex flex-col items-center\">\n  <%= render partial: \"import/confirms/mappings\", locals: { import: @import, mapping_class: step_mapping_class, step_idx: step_idx } %>\n</div>\n"
  },
  {
    "path": "app/views/import/mappings/_form.html.erb",
    "content": "<%# locals: (mapping:) %>\n\n<%= styled_form_with model: mapping,\n                     scope: :import_mapping,\n                     url: import_mapping_path(mapping.import, mapping),\n                     class: \"grid grid-cols-3 gap-2 items-center\",\n                     data: { controller: \"auto-submit-form\" },\n                     html: { id: dom_id(mapping, :form) } do |form| %>\n  <span><%= mapping.key.blank? ? \"(unassigned)\" : mapping.key %></span>\n\n  <% if mapping.mappable_class.present? %>\n    <%= form.hidden_field :mappable_type, value: mapping.mappable_class, id: dom_id(mapping, :mappable_type) %>\n    <%= form.select :mappable_id,\n                      mapping.selectable_values,\n                      { container_class: mapping.invalid? ? \"border-red-500\" : nil, include_blank: mapping.requires_selection? ? \"Select an option\" : \"Leave unassigned\", selected: mapping.create_when_empty? ? mapping.class::CREATE_NEW_KEY : mapping.mappable_id },\n                      \"data-auto-submit-form-target\": \"auto\", \"data-autosubmit-trigger-event\": \"change\", disabled: mapping.import.complete?, id: dom_id(mapping, :mappable_id) %>\n  <% else %>\n    <%= form.select :value, mapping.selectable_values,\n                      { container_class: mapping.invalid? ? \"border-red-500\" : nil, include_blank: mapping.requires_selection? ? \"Select an option\" : \"Leave unassigned\" },\n                      \"data-auto-submit-form-target\": \"auto\", \"data-autosubmit-trigger-event\": \"change\", disabled: mapping.import.complete?, id: dom_id(mapping, :value) %>\n  <% end %>\n\n  <%= form.hidden_field :key, value: mapping.key, id: dom_id(mapping, :key) %>\n  <%= form.hidden_field :type, value: mapping.type, id: dom_id(mapping, :type) %>\n\n  <span class=\"justify-self-end\">\n    <%= mapping.values_count %>\n  </span>\n<% end %>\n"
  },
  {
    "path": "app/views/import/rows/_form.html.erb",
    "content": "<%# locals: (row:) %>\n\n<div style=\"grid-template-columns: repeat(<%= row.import.column_keys.count %>, minmax(150px, 1fr));\" class=\"first:rounded-tl-lg first:rounded-tr-lg last:rounded-bl-lg last:rounded-br-lg grid divide-x divide-alpha-black-200 theme-dark:divide-alpha-white-200 group\">\n  <% row.import.column_keys.each_with_index do |key, idx| %>\n    <%= turbo_frame_tag dom_id(row, key), title: row.valid? ? nil : row.errors.full_messages.join(\", \") do %>\n      <%= form_with(\n            model: [row.import, row],\n            scope: :import_row,\n            url: import_row_path(row.import, row),\n            method: :patch,\n            data: {\n              controller: \"auto-submit-form mobile-cell-interaction\",\n              auto_submit_form_trigger_value: \"blur\",\n              mobile_cell_interaction_error_value: !cell_is_valid?(row, key) ? row.errors[key].join(\", \") : \"\",\n            }\n          ) do |form| %>\n        <div class=\"relative\">\n          <%= form.text_field key,\n                \"data-auto-submit-form-target\": \"auto\",\n                \"data-action\": \"focus->mobile-cell-interaction#highlightCell blur->mobile-cell-interaction#unhighlightCell touchstart->mobile-cell-interaction#handleCellTouch\",\n                \"data-mobile-cell-interaction-target\": \"field\",\n                class: [\n                  cell_class(row, key),\n                  idx == 0 ? \"group-first:rounded-tl-lg group-last:rounded-bl-lg\" : \"\",\n                  idx == row.import.column_keys.count - 1 ? \"group-first:rounded-tr-lg group-last:rounded-br-lg\" : \"\",\n                  \"focus:outline-none focus:z-10 relative\",\n                ],\n                disabled: row.import.complete? %>\n\n          <% if !cell_is_valid?(row, key) %>\n            <span class=\"absolute right-2 top-1/2 -translate-y-1/2 text-destructive md:hidden\"\n                  data-action=\"click->mobile-cell-interaction#toggleErrorMessage\"\n              data-mobile-cell-interaction-target=\"errorIcon\">\n              <%= icon \"alert-circle\", size: \"sm\", color: \"destructive\" %>\n            </span>\n\n            <div class=\"absolute left-4 right-4 bottom-full mb-2 p-2 bg-red-50 border border-red-200 rounded-lg shadow-lg text-xs text-red-600 hidden md:hidden z-20\"\n                 data-mobile-cell-interaction-target=\"errorTooltip\">\n              <%= row.errors[key].join(\", \") %>\n            </div>\n          <% end %>\n\n          <div class=\"absolute inset-0 bg-primary/5 pointer-events-none opacity-0 transition-opacity duration-150 ease-in-out z-0\" data-mobile-cell-interaction-target=\"highlight\"></div>\n        </div>\n      <% end %>\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/import/rows/show.html.erb",
    "content": "<%= render \"import/rows/form\", row: @row %>\n"
  },
  {
    "path": "app/views/import/uploads/show.html.erb",
    "content": "<%= content_for :header_nav do %>\n  <%= render \"imports/nav\", import: @import %>\n<% end %>\n\n<%= content_for :previous_path, imports_path %>\n\n<div class=\"space-y-4\">\n  <div class=\"space-y-4 mx-auto max-w-md\">\n    <div class=\"text-center space-y-2\">\n      <h1 class=\"text-3xl text-primary font-medium\"><%= t(\".title\") %></h1>\n      <p class=\"text-secondary text-sm\"><%= t(\".description\") %></p>\n    </div>\n\n    <%= render DS::Tabs.new(active_tab: params[:tab] || \"csv-upload\", url_param_key: \"tab\", testid: \"import-tabs\") do |tabs| %>\n      <% tabs.with_nav do |nav| %>\n        <% nav.with_btn(id: \"csv-upload\", label: \"Upload CSV\") %>\n        <% nav.with_btn(id: \"csv-paste\", label: \"Copy & Paste\") %>\n      <% end %>\n\n      <% tabs.with_panel(tab_id: \"csv-upload\") do %>\n        <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: \"space-y-2\" do |form| %>\n          <%= form.select :col_sep, Import::SEPARATORS, label: true %>\n\n          <% if @import.type == \"TransactionImport\" || @import.type == \"TradeImport\" %>\n            <%= form.select :account_id, @import.family.accounts.visible.pluck(:name, :id), { label: \"Account (optional)\", include_blank: \"Multi-account import\", selected: @import.account_id } %>\n          <% end %>\n\n          <div class=\"flex flex-col items-center justify-center w-full h-64 border border-secondary border-dashed rounded-xl cursor-pointer\" data-controller=\"file-upload\" data-action=\"click->file-upload#triggerFileInput\" data-file-upload-target=\"uploadArea\">\n            <div class=\"flex flex-col items-center justify-center pt-5 pb-6\">\n              <div data-file-upload-target=\"uploadText\" class=\"flex flex-col items-center\">\n                <%= icon(\"plus\", size: \"lg\", class: \"mb-4 mx-auto\") %>\n                <p class=\"mb-2 text-md text-gray text-center\">\n                  <span class=\"font-medium text-primary\">Browse</span> to add your CSV file here\n                </p>\n              </div>\n\n              <div class=\"flex flex-col gap-4 items-center hidden mb-2\" data-file-upload-target=\"fileName\">\n                <span class=\"text-primary\">\n                  <%= icon(\"file-text\", size: \"lg\", color: \"current\") %>\n                </span>\n                <p class=\"text-md font-medium text-primary\"></p>\n              </div>\n\n              <%= form.file_field :csv_file, class: \"hidden\", \"data-auto-submit-form-target\": \"auto\", \"data-file-upload-target\": \"input\" %>\n            </div>\n          </div>\n\n          <%= form.submit \"Upload CSV\", disabled: @import.complete? %>\n        <% end %>\n      <% end %>\n\n      <% tabs.with_panel(tab_id: \"csv-paste\") do %>\n        <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: \"space-y-2\" do |form| %>\n          <%= form.select :col_sep, Import::SEPARATORS, label: true %>\n\n          <% if @import.type == \"TransactionImport\" || @import.type == \"TradeImport\" %>\n            <%= form.select :account_id, @import.family.accounts.visible.pluck(:name, :id), { label: \"Account (optional)\", include_blank: \"Multi-account import\", selected: @import.account_id } %>\n          <% end %>\n\n          <%= form.text_area :raw_file_str,\n                       rows: 10,\n                       required: true,\n                       placeholder: \"Paste your CSV file contents here\",\n                       \"data-auto-submit-form-target\": \"auto\" %>\n\n          <%= form.submit \"Upload CSV\", disabled: @import.complete? %>\n        <% end %>\n      <% end %>\n    <% end %>\n  </div>\n\n  <div class=\"flex justify-center\">\n    <span class=\"text-secondary text-sm\">\n      <%= link_to \"Download a sample CSV\", \"/imports/#{@import.id}/upload/sample_csv\", class: \"text-primary underline\", data: { turbo: false } %> to see the required CSV format\n    </span>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/imports/_empty.html.erb",
    "content": "<div class=\"flex justify-center items-center py-20\">\n  <div class=\"text-center flex flex-col items-center max-w-[300px] gap-4\">\n    <p class=\"text-primary mb-1 font-medium text-sm\"><%= t(\".message\") %></p>\n\n    <%= render DS::Link.new(\n      text: t(\".new\"),\n      variant: \"primary\",\n      href: new_import_path,\n      icon: \"plus\",\n      frame: :modal\n    ) %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/imports/_failure.html.erb",
    "content": "<%# locals: (import:) %>\n\n<div class=\"h-full flex flex-col justify-center items-center\">\n  <div class=\"space-y-6 max-w-sm\">\n    <div class=\"mx-auto bg-red-500/5 h-8 w-8 rounded-full flex items-center justify-center\">\n      <%= icon \"alert-octagon\", color: \"destructive\" %>\n    </div>\n\n    <div class=\"text-center space-y-2\">\n      <h1 class=\"font-medium text-primary text-center text-3xl\">Import failed</h1>\n      <p class=\"text-sm text-secondary\">Please check that your file format, for any errors and that all required fields are filled, then come back and try again.</p>\n    </div>\n\n    <%= render DS::Button.new(text: \"Try again\", href: publish_import_path(import), full_width: true) %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/imports/_import.html.erb",
    "content": "<div id=\"<%= dom_id import %>\" class=\"flex items-center justify-between mx-4 py-4\">\n\n  <div class=\"flex items-center gap-2 mb-1\">\n    <%= link_to import_path(import), class: \"text-sm text-primary hover:underline\" do %>\n      <% if import.account.present? %>\n        <%= import.account.name + \" \" %>\n      <% end %>\n\n      <%= t(\".label\", type: import.type.titleize, datetime: import.updated_at.strftime(\"%b %-d, %Y at %l:%M %p\")) %>\n    <% end %>\n\n    <% if import.pending? %>\n      <span class=\"px-1 py text-xs rounded-full bg-gray-500/5 text-secondary border border-alpha-black-50\">\n        <%= t(\".in_progress\") %>\n      </span>\n    <% elsif import.importing? %>\n      <span class=\"px-1 py text-xs animate-pulse rounded-full bg-orange-500/5 text-orange-500 border border-alpha-black-50\">\n        <%= t(\".uploading\") %>\n      </span>\n    <% elsif import.failed? %>\n      <span class=\"px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50\">\n        <%= t(\".failed\") %>\n      </span>\n    <% elsif import.reverting? %>\n      <span class=\"px-1 py text-xs rounded-full bg-orange-500/5 text-orange-500 border border-alpha-black-50\">\n        <%= t(\".reverting\") %>\n      </span>\n    <% elsif import.revert_failed? %>\n      <span class=\"px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50\">\n        <%= t(\".revert_failed\") %>\n      </span>\n    <% elsif import.complete? %>\n      <span class=\"px-1 py text-xs rounded-full bg-green-500/5 text-green-500 border border-alpha-black-50\">\n        <%= t(\".complete\") %>\n      </span>\n    <% end %>\n  </div>\n\n  <%= render DS::Menu.new do |menu| %>\n    <% menu.with_item(variant: \"link\", text: t(\".view\"), href: import_path(import), icon: \"eye\") %>\n\n    <% if import.complete? || import.revert_failed? %>\n      <% menu.with_item(\n        variant: \"button\",\n        text: t(\".revert\"),\n        href: revert_import_path(import),\n        icon: \"rotate-ccw\",\n        method: :put,\n        confirm: CustomConfirm.new(\n          title: \"Revert import?\",\n          body: \"This will delete transactions that were imported, but you will still be able to review and re-import your data at any time.\",\n          btn_text: \"Revert\"\n        )) %>\n\n    <% else %>\n      <% menu.with_item(\n        variant: \"button\",\n        text: t(\".delete\"),\n        href: import_path(import),\n        icon: \"trash-2\",\n        method: :delete,\n        confirm: CustomConfirm.for_resource_deletion(\"import\")) %>\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/imports/_importing.html.erb",
    "content": "<%# locals: (import:) %>\n\n<div class=\"h-full flex flex-col justify-center items-center\">\n  <div class=\"space-y-6 max-w-sm\">\n    <div class=\"mx-auto bg-gray-500/5 h-8 w-8 rounded-full flex items-center justify-center\">\n      <%= icon \"loader\", class: \"animate-pulse\" %>\n    </div>\n\n    <div class=\"text-center space-y-2\">\n      <h1 class=\"font-medium text-primary text-center text-3xl\">Import in progress</h1>\n      <p class=\"text-sm text-secondary\">Your import is in progress. Check the imports menu for status updates or click 'Check Status' to refresh the page for updates. Feel free to continue using the app.</p>\n    </div>\n\n    <div class=\"space-y-2 flex flex-col\">\n      <%= render DS::Link.new(text: \"Check status\", href: import_path(import), variant: \"primary\", full_width: true) %>\n      <%= render DS::Link.new(text: \"Back to dashboard\", href: root_path, variant: \"secondary\", full_width: true) %>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/imports/_nav.html.erb",
    "content": "<%# locals: (import:) %>\n\n<% steps = [\n  { name: \"Upload\", path: import_upload_path(import), is_complete: import.uploaded?, step_number: 1 },\n  { name: \"Configure\", path: import_configuration_path(import), is_complete: import.configured?, step_number: 2 },\n  { name: \"Clean\", path: import_clean_path(import), is_complete: import.cleaned?, step_number: 3 },\n  { name: \"Map\", path: import_confirm_path(import), is_complete: import.publishable?, step_number: 4 },\n  { name: \"Confirm\", path: import_path(import), is_complete: import.complete?, step_number: 5 }\n].reject { |step| step[:name] == \"Map\" && import.mapping_steps.empty? } %>\n\n<% content_for :mobile_import_progress do %>\n  <% active_step = steps.detect { |s| request.path.eql?(s[:path]) } %>\n  <% if active_step.present? %>\n    <div class=\"md:hidden text-center text-secondary text-md my-2\">\n      <span class=\"text-gray-500\">Step <%= active_step[:step_number] %> of <%= steps.size %></span>\n    </div>\n  <% end %>\n<% end %>\n\n<ul class=\"hidden md:flex items-center gap-2\">\n  <% steps.each_with_index do |step, idx| %>\n    <li class=\"flex items-center gap-2 group\">\n      <% is_current = request.path == step[:path] %>\n\n      <% text_class = if is_current\n                  \"text-primary\"\n                else\n                  step[:is_complete] ? \"text-green-600\" : \"text-secondary\"\n                end %>\n      <% step_class = if is_current\n                  \"bg-surface-inset text-primary\"\n                else\n                  step[:is_complete] ? \"bg-green-600/10 border-alpha-black-25\" : \"bg-container-inset\"\n                end %>\n\n      <%= link_to step[:path], class: \"flex items-center gap-3\" do %>\n        <div class=\"flex items-center gap-2 text-sm font-medium <%= text_class %>\">\n          <span class=\"<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent\">\n            <%= step[:is_complete] && !is_current ? icon(\"check\", size: \"sm\", color: \"current\") : idx + 1 %>\n          </span>\n\n          <span><%= step[:name] %></span>\n        </div>\n      <% end %>\n\n      <hr class=\"border border-secondary w-12 group-last:hidden\">\n    </li>\n  <% end %>\n</ul>\n"
  },
  {
    "path": "app/views/imports/_ready.html.erb",
    "content": "<%# locals: (import:) %>\n\n<div class=\"text-center space-y-2 mb-4 mx-auto max-w-md\">\n  <h1 class=\"text-3xl text-primary font-medium\"><%= t(\".title\") %></h1>\n  <p class=\"text-secondary text-sm\"><%= t(\".description\") %></p>\n</div>\n\n<div class=\"mx-auto max-w-2xl space-y-4\">\n  <div class=\"bg-container-inset rounded-xl p-1 space-y-1\">\n    <div class=\"flex justify-between items-center text-xs font-medium text-secondary uppercase px-5 py-3\">\n      <p>item</p>\n      <p class=\"justify-self-end\">count</p>\n    </div>\n\n    <div class=\"bg-container shadow-border-xs rounded-lg text-sm\">\n      <% import.dry_run.each do |key, count| %>\n        <% resource = dry_run_resource(key) %>\n\n        <div class=\"flex items-center justify-between gap-2 bg-container px-5 py-3 rounded-lg\">\n          <div class=\"flex items-center gap-3\">\n            <%= tag.div class: class_names(resource.bg_class, resource.text_class, \"w-8 h-8 rounded-full flex justify-center items-center\") do %>\n              <%= icon resource.icon, color: \"current\" %>\n            <% end %>\n\n            <p><%= resource.label %></p>\n          </div>\n\n          <p class=\"justify-self-end\"><%= count %></p>\n        </div>\n\n        <% if key != import.dry_run.keys.last %>\n          <%= render \"shared/ruler\" %>\n        <% end %>\n      <% end %>\n    </div>\n  </div>\n\n  <%= render DS::Button.new(text: \"Publish import\", href: publish_import_path(import), full_width: true) %>\n</div>\n"
  },
  {
    "path": "app/views/imports/_revert_failure.html.erb",
    "content": "<%# locals: (import:) %>\n\n<div class=\"h-full flex flex-col justify-center items-center\">\n  <div class=\"space-y-6 max-w-sm\">\n    <div class=\"mx-auto bg-red-500/5 h-8 w-8 rounded-full flex items-center justify-center\">\n      <%= icon \"alert-octagon\", color: \"destructive\" %>\n    </div>\n\n    <div class=\"text-center space-y-2\">\n      <h1 class=\"font-medium text-primary text-center text-3xl\">Reverting import failed</h1>\n      <p class=\"text-sm text-secondary\">Please try again or contact support.</p>\n    </div>\n\n    <%= render DS::Button.new(\n      text: \"Try again\",\n      full_width: true,\n      href: revert_import_path(import)\n    ) %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/imports/_success.html.erb",
    "content": "<%# locals: (import:) %>\n\n<div class=\"h-full flex flex-col justify-center items-center\">\n  <div class=\"space-y-6 max-w-sm\">\n    <div class=\"mx-auto bg-green-500/5 h-8 w-8 rounded-full flex items-center justify-center\">\n      <%= icon \"check\", color: \"success\" %>\n    </div>\n\n    <div class=\"text-center space-y-2\">\n      <h1 class=\"font-medium text-primary text-center text-3xl\">Import successful</h1>\n      <p class=\"text-sm text-secondary\">Your imported data has been successfully added to the app and is now ready for use.</p>\n    </div>\n\n    <%= render DS::Link.new(\n      text: \"Back to dashboard\",\n      variant: \"primary\",\n      full_width: true,\n      href: root_path\n    ) %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/imports/_table.html.erb",
    "content": "<%# locals: (headers: [], rows: [], caption: nil) %>\n<div class=\"bg-container-inset rounded-xl overflow-hidden md:mx-auto p-4\">\n  <% if caption %>\n    <div class=\"flex items-center mb-4\">\n      <div class=\"text-gray-500 mr-2\">\n        <%= inline_svg_tag \"icon-csv.svg\", class: \"w-4 h-4\" %>\n      </div>\n      <h2 class=\"text-sm text-gray-500 font-medium\"><%= caption %></h2>\n    </div>\n  <% end %>\n  <div class=\"inline-block min-w-fit sm:w-full rounded-lg shadow-border-xs text-sm bg-container\">\n    <table class=\"min-w-full\">\n      <thead>\n        <tr>\n          <% headers.each_with_index do |header, index| %>\n            <th class=\"\n              bg-container-inset px-3 py-2 font-medium border-b border-b-alpha-black-200 text-left whitespace-nowrap\n              <%= index == 0 ? \"rounded-tl-lg\" : \"\" %>\n              <%= index == headers.length - 1 ? \"rounded-tr-lg\" : \"\" %>\n              <%= index < headers.length - 1 ? \"border-r border-r-alpha-black-200\" : \"\" %>\n            \">\n              <%= header %>\n            </th>\n          <% end %>\n        </tr>\n      </thead>\n      <tbody class=\"\">\n        <% rows.each_with_index do |row, row_index| %>\n          <tr>\n            <% row.each_with_index do |(header, value), col_index| %>\n              <td class=\"\n                px-3 py-2 whitespace-nowrap text-left\n                <%= col_index < row.length - 1 ? \"border-r border-r-alpha-black-200\" : \"\" %>\n                <%= !caption && row_index == rows.length - 1 && col_index == 0 ? \"rounded-bl-md\" : \"\" %>\n                <%= !caption && row_index == rows.length - 1 && col_index == row.length - 1 ? \"rounded-br-md\" : \"\" %>\n              \">\n                <%= value %>\n              </td>\n            <% end %>\n          </tr>\n        <% end %>\n      </tbody>\n    </table>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/imports/index.html.erb",
    "content": "<div class=\"flex items-center justify-between\">\n  <h1 class=\"text-xl font-medium text-primary\"><%= t(\".title\") %></h1>\n\n  <%= render DS::Link.new(\n    text: \"New import\",\n    href: new_import_path,\n    icon: \"plus\",\n    variant: \"primary\",\n    frame: :modal\n  ) %>\n</div>\n\n<div class=\"bg-container shadow-border-xs rounded-xl p-4\">\n  <% if @imports.empty? %>\n    <%= render partial: \"imports/empty\" %>\n  <% else %>\n    <div class=\"rounded-xl bg-container-inset p-1\">\n      <h2 class=\"uppercase px-4 py-2 text-secondary text-xs\"><%= t(\".imports\") %> · <%= @imports.size %></h2>\n\n      <div class=\"border border-alpha-black-100 rounded-lg bg-container shadow-xs\">\n        <%= render partial: \"imports/import\", collection: @imports.ordered, spacer_template: \"shared/ruler\" %>\n      </div>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/imports/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".title\"), subtitle: t(\".description\")) %>\n\n  <% dialog.with_body do %>\n    <div class=\"rounded-xl bg-container-inset p-1\">\n      <h3 class=\"uppercase text-secondary text-xs font-medium px-3 py-1.5\"><%= t(\".sources\") %></h3>\n      <ul class=\"bg-container shadow-border-xs rounded-lg\">\n        <li>\n          <% if @pending_import.present? && (params[:type].nil? || params[:type] == @pending_import.type) %>\n            <%= link_to import_path(@pending_import), class: \"flex items-center justify-between p-4 group cursor-pointer\", data: { turbo: false } do %>\n              <div class=\"flex items-center gap-2\">\n                <div class=\"bg-orange-500/5 rounded-md w-8 h-8 flex items-center justify-center\">\n                  <span class=\"text-orange-500\">\n                    <%= icon(\"loader\", color: \"current\") %>\n                  </span>\n                </div>\n                <span class=\"text-sm text-primary group-hover:text-secondary\">\n                  <%= t(\".resume\", type: @pending_import.type.titleize) %>\n                </span>\n              </div>\n              <%= icon(\"chevron-right\") %>\n            <% end %>\n\n            <%= render \"shared/ruler\" %>\n          </li>\n        <% end %>\n\n        <% if Current.family.accounts.any? && (params[:type].nil? || params[:type] == \"TransactionImport\") %>\n          <li>\n            <%= button_to imports_path(import: { type: \"TransactionImport\" }), class: \"flex items-center justify-between p-4 group cursor-pointer w-full\", data: { turbo: false } do %>\n              <div class=\"flex items-center gap-2\">\n                <div class=\"bg-indigo-500/5 rounded-md w-8 h-8 flex items-center justify-center\">\n                  <span class=\"text-indigo-500\">\n                    <%= icon(\"file-spreadsheet\", color: \"current\") %>\n                  </span>\n                </div>\n                <span class=\"text-sm text-primary group-hover:text-secondary\">\n                  <%= t(\".import_transactions\") %>\n                </span>\n              </div>\n              <%= icon(\"chevron-right\") %>\n            <% end %>\n\n            <%= render \"shared/ruler\" %>\n          </li>\n        <% end %>\n\n        <% if Current.family.accounts.any? && (params[:type].nil? || params[:type] == \"TradeImport\") %>\n          <li>\n            <%= button_to imports_path(import: { type: \"TradeImport\" }), class: \"flex items-center justify-between p-4 group cursor-pointer w-full\", data: { turbo: false } do %>\n              <div class=\"flex items-center gap-2\">\n                <div class=\"bg-yellow-500/5 rounded-md w-8 h-8 flex items-center justify-center\">\n                  <span class=\"text-yellow-500\">\n                    <%= icon(\"square-percent\", color: \"current\") %>\n                  </span>\n                </div>\n                <span class=\"text-sm text-primary group-hover:text-secondary\">\n                  <%= t(\".import_portfolio\") %>\n                </span>\n              </div>\n              <%= icon(\"chevron-right\") %>\n            <% end %>\n\n            <%= render \"shared/ruler\" %>\n          </li>\n        <% end %>\n\n        <% if params[:type].nil? || params[:type] == \"AccountImport\" %>\n          <li>\n            <%= button_to imports_path(import: { type: \"AccountImport\" }), class: \"flex items-center justify-between p-4 group cursor-pointer w-full\", data: { turbo: false } do %>\n              <div class=\"flex items-center gap-2\">\n                <div class=\"bg-violet-500/5 rounded-md w-8 h-8 flex items-center justify-center\">\n                  <span class=\"text-violet-500\">\n                    <%= icon(\"building\", color: \"current\") %>\n                  </span>\n                </div>\n                <span class=\"text-sm text-primary group-hover:text-secondary\">\n                  <%= t(\".import_accounts\") %>\n                </span>\n              </div>\n              <%= icon(\"chevron-right\") %>\n            <% end %>\n\n            <%= render \"shared/ruler\" %>\n          </li>\n        <% end %>\n\n        <% if Current.family.accounts.any? && (params[:type].nil? || params[:type] == \"MintImport\" || params[:type] == \"TransactionImport\") %>\n          <li>\n            <%= button_to imports_path(import: { type: \"MintImport\" }), class: \"flex items-center justify-between p-4 group w-full\", data: { turbo: false } do %>\n              <div class=\"flex items-center gap-2\">\n                <%= image_tag(\"mint-logo.jpeg\", alt: \"Mint logo\", class: \"w-8 h-8 rounded-md\") %>\n                <span class=\"text-sm text-primary\">\n                  <%= t(\".import_mint\") %>\n                </span>\n              </div>\n              <%= icon(\"chevron-right\") %>\n            <% end %>\n\n            <%= render \"shared/ruler\" %>\n          </li>\n        <% end %>\n      </ul>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/imports/show.html.erb",
    "content": "<%= content_for :header_nav do %>\n  <%= render \"imports/nav\", import: @import %>\n<% end %>\n\n<%= content_for :previous_path, import_confirm_path(@import) %>\n\n<% if @import.importing? %>\n  <%= render \"imports/importing\", import: @import %>\n<% elsif @import.complete? %>\n  <%= render \"imports/success\", import: @import %>\n<% elsif @import.failed? %>\n  <%= render \"imports/failure\", import: @import %>\n<% elsif @import.revert_failed? %>\n  <%= render \"imports/revert_failure\", import: @import %>\n<% else %>\n  <%= render \"imports/ready\", import: @import %>\n<% end %>\n"
  },
  {
    "path": "app/views/investments/_form.html.erb",
    "content": "<%# locals: (account:, url:) %>\n\n<%= render \"accounts/form\", account: account, url: url do |form| %>\n  <%= form.select :subtype,\n                 Investment::SUBTYPES.map { |k, v| [v[:long], k] },\n                 { label: true, prompt: t(\"investments.form.subtype_prompt\"), include_blank: t(\"investments.form.none\") } %>\n<% end %>\n"
  },
  {
    "path": "app/views/investments/_value_tooltip.html.erb",
    "content": "<%# locals: (balance:, holdings:, cash:) %>\n\n<div data-controller=\"tooltip\" data-tooltip-placement-value=\"right\" data-tooltip-offset-value=10 data-tooltip-cross-axis-value=50>\n  <%= icon(\"info\", size: \"sm\") %>\n  <div role=\"tooltip\" data-tooltip-target=\"tooltip\" class=\"tooltip bg-gray-700 text-sm p-2 rounded w-64\">\n    <div class=\"fg-inverse\">\n      <%= t(\".total_value_tooltip\") %>\n    </div>\n    <div class=\"flex pt-3\">\n      <div class=\"text-gray-300\">\n        <%= t(\".cash\") %>\n      </div>\n      <div class=\"fg-inverse ml-auto\">\n        <%= tag.p format_money(cash, precision: 0) %>\n      </div>\n    </div>\n    <div class=\"flex\">\n      <div class=\"text-gray-300\">\n        <%= t(\".holdings\") %>\n      </div>\n      <div class=\"fg-inverse ml-auto\">\n        <%= tag.p format_money(holdings, precision: 0) %>\n      </div>\n    </div>\n\n    <hr class=\"my-2 border-secondary\">\n\n    <div class=\"flex\">\n      <div class=\"text-gray-300\">\n        <%= t(\".total\") %>\n      </div>\n      <div class=\"fg-inverse font-bold ml-auto\">\n        <%= tag.p format_money(balance, precision: 0) %>\n      </div>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/investments/edit.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".edit\", account: @account.name)) %>\n  <% dialog.with_body do %>\n    <%= render \"investments/form\", account: @account, url: investment_path(@account) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/investments/new.html.erb",
    "content": "<% if params[:step] == \"method_select\" %>\n  <%= render \"accounts/new/method_selector\",\n             path: new_investment_path(return_to: params[:return_to]),\n             show_us_link: @show_us_link,\n             show_eu_link: @show_eu_link,\n             accountable_type: \"Investment\" %>\n<% else %>\n  <%= render DS::Dialog.new do |dialog| %>\n    <% dialog.with_header(title: t(\".title\")) %>\n    <% dialog.with_body do %>\n      <%= render \"investments/form\", account: @account, url: investments_path %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/investments/tabs/_holdings.html.erb",
    "content": "<%# locals: (account:) %>\n\n<%= turbo_frame_tag dom_id(account, :holdings), src: holdings_path(account_id: account.id) do %>\n  <%= render \"entries/loading\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/invitation_mailer/invite_email.html.erb",
    "content": "<h1><%= t(\".greeting\") %></h1>\n\n<p>\n  <%= t(\".body\",\n    inviter: @invitation.inviter.display_name,\n    family: @invitation.family.name).html_safe %>\n</p>\n\n<%= link_to t(\".accept_button\"), @accept_url, class: \"button\" %>\n\n<p class=\"footer\"><%= t(\".expiry_notice\", days: 3) %></p>\n"
  },
  {
    "path": "app/views/invitations/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".title\"), subtitle: t(\".subtitle\")) %>\n\n  <% dialog.with_body do %>\n    <%= styled_form_with model: @invitation, class: \"space-y-4\", data: { turbo: false } do |form| %>\n      <%= form.email_field :email,\n        required: true,\n        placeholder: t(\".email_placeholder\"),\n        label: t(\".email_label\") %>\n\n      <%= form.select :role,\n        options_for_select([\n          [t(\".role_member\"), \"member\"],\n          [t(\".role_admin\"), \"admin\"]\n        ]),\n        {},\n        { label: t(\".role_label\") } %>\n\n      <div class=\"w-full\">\n        <%= form.submit t(\".submit\"), class: \"bg-inverse fg-inverse rounded-lg px-4 py-2 w-full\" %>\n      </div>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/invite_codes/_invite_code.html.erb",
    "content": "<%# app/views/invite_codes/_invite_code.html.erb %>\n<div class=\"invite_code pt-2\">\n  <div class=\"flex items-center justify-between p-2 w-1/2 bg-gray-25 rounded-md\" data-controller=\"clipboard\">\n    <div>\n      <span data-clipboard-target=\"source\" class=\"text-sm font-medium\"><%= invite_code.token %></span>\n    </div>\n    <button data-action=\"clipboard#copy\" class=\"shrink-0 z-10 inline-flex items-center px-1 text-sm text-secondary font-sm text-center\" type=\"button\">\n      <span data-clipboard-target=\"iconDefault\">\n        <%= icon \"copy\" %>\n      </span>\n      <span class=\"hidden inline-flex items-center\" data-clipboard-target=\"iconSuccess\">\n        <%= icon \"check\" %>\n      </span>\n    </button>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/invite_codes/index.html.erb",
    "content": "<%# app/views/invite_codes/index.html.erb %>\n<%= turbo_frame_tag \"invite_codes\" do %>\n  <% if @invite_codes.present? %>\n    <%= render @invite_codes %>\n  <% else %>\n    <div class=\"flex flex-col items-center w-full h-64 bg-container text-center justify-center\">\n      <%= icon \"binary\", size: \"lg\" %>\n      <p class=\"text-base pt-4\"><%= t(\".no_invite_codes\") %></p>\n      <p class=\"text-sm text-secondary pt-2 w-2/3\"><%= t(\".invite_code_description\") %></p>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/application.html.erb",
    "content": "<% mobile_nav_items = [\n  { name: \"Home\", path: root_path, icon: \"pie-chart\", icon_custom: false, active: page_active?(root_path) },\n  { name: \"Transactions\", path: transactions_path, icon: \"credit-card\", icon_custom: false, active: page_active?(transactions_path) },\n  { name: \"Budgets\", path: budgets_path, icon: \"map\", icon_custom: false, active: page_active?(budgets_path) },\n  { name: \"Assistant\", path: chats_path, icon: \"icon-assistant\", icon_custom: true, active: page_active?(chats_path), mobile_only: true }\n] %>\n\n<% desktop_nav_items = mobile_nav_items.reject { |item| item[:mobile_only] } %>\n<% expanded_sidebar_class = \"w-full\" %>\n<% collapsed_sidebar_class = \"w-0\" %>\n\n<%= render \"layouts/shared/htmldoc\" do %>\n  <div\n    class=\"flex flex-col lg:flex-row h-full bg-surface\"\n    data-controller=\"app-layout\"\n    data-app-layout-expanded-sidebar-class=\"<%= expanded_sidebar_class %>\"\n    data-app-layout-collapsed-sidebar-class=\"<%= collapsed_sidebar_class %>\"\n    data-app-layout-user-id-value=\"<%= Current.user.id %>\">\n    <div\n      class=\"hidden fixed inset-0 bg-surface z-20 h-full w-full pt-[calc(env(safe-area-inset-top)+0.75rem)] pr-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] pl-3 overflow-y-auto transition-all duration-300\"\n      data-app-layout-target=\"mobileSidebar\">\n      <div class=\"mb-2\">\n        <%= icon(\"x\", as_button: true, data: { action: \"app-layout#closeMobileSidebar\" }) %>\n      </div>\n\n      <%= render(\n        \"accounts/account_sidebar_tabs\",\n        family: Current.family,\n        active_tab: @account_group_tab,\n        mobile: true\n      ) %>\n    </div>\n\n    <%# MOBILE - Top nav %>\n    <nav class=\"lg:hidden flex justify-between items-center p-3\">\n      <%= icon(\"panel-left\", as_button: true, data: { action: \"app-layout#openMobileSidebar\"}) %>\n\n      <%= link_to root_path, class: \"block\" do %>\n        <%= image_tag \"logomark-color.svg\", class: \"w-9 h-9 mx-auto\" %>\n      <% end %>\n\n      <%= render \"users/user_menu\", user: Current.user, placement: \"bottom-end\", offset: 12 %>\n    </nav>\n\n    <%# DESKTOP - Left navbar %>\n    <div class=\"hidden lg:block\">\n      <nav class=\"h-full flex flex-col shrink-0 w-[84px] py-4 mr-3\">\n        <div class=\"pl-2 mb-3\">\n          <%= link_to root_path, class: \"block\" do %>\n            <%= image_tag \"logomark-color.svg\", class: \"w-9 h-9 mx-auto\" %>\n          <% end %>\n        </div>\n\n        <ul class=\"space-y-0.5\">\n          <% desktop_nav_items.reject { |item| item[:mobile_only] }.each do |nav_item| %>\n            <li>\n              <%= render \"layouts/shared/nav_item\", **nav_item %>\n            </li>\n          <% end %>\n        </ul>\n\n        <div class=\"pl-2 mt-auto mx-auto flex flex-col gap-2\">\n          <%= render DS::Button.new(\n            variant: \"icon\",\n            icon: \"message-circle-question\",\n            data: { action: \"intercom#show\" }\n          ) %>\n\n          <%= render \"users/user_menu\", user: Current.user %>\n        </div>\n      </nav>\n    </div>\n\n    <%# DESKTOP - Left sidebar %>\n    <%= tag.div class: class_names(\n        \"hidden lg:block py-4 overflow-y-auto shrink-0 max-w-[320px] transition-all duration-300\",\n        Current.user.show_sidebar? ? expanded_sidebar_class : collapsed_sidebar_class,\n       ),\n       data: { app_layout_target: \"leftSidebar\" } do %>\n      <% if content_for?(:sidebar) %>\n        <%= yield :sidebar %>\n      <% else %>\n        <div class=\"h-full flex flex-col\">\n          <div class=\"overflow-y-auto grow\">\n            <%= render \"accounts/account_sidebar_tabs\", family: Current.family, active_tab: @account_group_tab %>\n          </div>\n\n          <% if Current.family.trialing? && !self_hosted? %>\n            <div class=\"px-4 py-3 space-y-4 bg-container shadow-border-xs rounded-xl\">\n              <div class=\"flex items-start justify-between\">\n                <div>\n                  <p class=\"text-sm font-medium text-primary\">Free trial</p>\n                  <p class=\"text-sm text-secondary\"><%= Current.family.days_left_in_trial %> days remaining</p>\n                </div>\n\n                <%= render DS::Link.new(\n                  text: \"Upgrade\",\n                  href: upgrade_subscription_path,\n                ) %>\n              </div>\n\n              <div class=\"flex items-center gap-0.5 h-1.5\">\n                <div class=\"h-full bg-warning rounded-full\" style=\"width: <%= Current.family.percentage_of_trial_completed %>%\"></div>\n                <div class=\"h-full bg-surface-inset rounded-full\" style=\"width: <%= Current.family.percentage_of_trial_remaining %>%\"></div>\n              </div>\n            </div>\n          <% end %>\n        </div>\n      <% end %>\n    <% end %>\n\n    <%# SHARED - Main content %>\n    <%= tag.main class: class_names(\"grow overflow-y-auto px-3 lg:px-10 py-4 w-full mx-auto max-w-5xl\"), data: { app_layout_target: \"content\" } do %>\n      <div class=\"hidden lg:flex gap-2 items-center justify-between mb-6\">\n        <div class=\"flex items-center gap-2\">\n          <%= icon(\"panel-left\", as_button: true, data: { action: \"app-layout#toggleLeftSidebar\" }) %>\n\n          <% if content_for?(:breadcrumbs) %>\n            <%= yield :breadcrumbs %>\n          <% else %>\n            <%= render \"layouts/shared/breadcrumbs\", breadcrumbs: @breadcrumbs %>\n          <% end %>\n        </div>\n        <%= icon(\"panel-right\", as_button: true, data: { action: \"app-layout#toggleRightSidebar\" }) %>\n      </div>\n\n      <% if content_for?(:page_header) %>\n        <%= yield :page_header %>\n      <% end %>\n\n      <%= yield %>\n    <% end %>\n\n    <%# DESKTOP - Right sidebar %>\n    <%= tag.div class: class_names(\n          \"hidden lg:block h-full overflow-y-auto shrink-0 max-w-[400px] transition-all duration-300\",\n          Current.user.show_ai_sidebar? ? expanded_sidebar_class : collapsed_sidebar_class,\n        ),\n        data: { app_layout_target: \"rightSidebar\" } do %>\n      <%= tag.div id: \"chat-container\", class: \"relative h-full\", data: { controller: \"chat hotkey\", turbo_permanent: true } do %>\n        <div class=\"flex flex-col h-full justify-between shrink-0\">\n          <%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: \"lazy\", class: \"h-full\" do %>\n            <div class=\"flex justify-center items-center h-full\">\n              <%= icon(\"loader-circle\", class: \"animate-spin\") %>\n            </div>\n          <% end %>\n        </div>\n\n        <% unless Current.user.ai_enabled? %>\n          <div class=\"absolute backdrop-blur-lg inset-0 h-full w-full flex flex-col justify-center items-center pl-0.5 pr-4\">\n            <%= render \"chats/ai_consent\" %>\n          </div>\n        <% end %>\n      <% end %>\n    <% end %>\n\n    <%# MOBILE - Bottom Nav %>\n    <%= tag.nav class: \"lg:hidden bg-surface shrink-0 z-10 pb-[env(safe-area-inset-bottom)] border-t border-tertiary flex justify-around\" do %>\n      <% mobile_nav_items.each do |nav_item| %>\n        <%= render \"layouts/shared/nav_item\", **nav_item %>\n      <% end %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/auth.html.erb",
    "content": "<%= render \"layouts/shared/htmldoc\" do %>\n  <div class=\"flex flex-col h-full\">\n    <div class=\"flex flex-col h-full px-6 py-12 bg-surface\">\n      <div class=\"grow flex flex-col justify-center\">\n        <div class=\"sm:mx-auto sm:w-full sm:max-w-md\">\n          <div class=\"flex justify-center mt-2 md:mb-6\">\n            <%= image_tag \"logo-color.png\", class: \"w-16 mb-6\" %>\n          </div>\n          <div class=\"space-y-2\">\n            <h2 class=\"text-3xl font-medium text-primary text-center\">\n              <%= content_for?(:header_title) ? yield(:header_title).html_safe : t(\".your_account\") %>\n            </h2>\n            <% if (controller_name == \"sessions\" && action_name == \"new\") || (controller_name == \"registrations\" && action_name == \"new\") %>\n              <div class=\"space-y-3 md:hidden w-full my-4\">\n                <div class=\"bg-surface-inset rounded-lg p-1 flex\">\n                  <%= link_to new_session_path,\n      class: \"w-1/2 px-2 py-1 rounded-md text-sm text-center font-medium #{current_page?(new_session_path) ? 'bg-surface shadow-sm text-primary' : 'text-secondary'}\" do %>\n                    <%= t(\"layouts.auth.sign_in\") %>\n                  <% end %>\n                  <%= link_to new_registration_path,\n      class: \"w-1/2 px-2 py-1 rounded-md text-sm text-center font-medium #{!current_page?(new_session_path) ? 'bg-surface shadow-sm text-primary' : 'text-secondary'}\" do %>\n                    <%= t(\"layouts.auth.sign_up\") %>\n                  <% end %>\n                </div>\n              </div>\n            <% end %>\n            <% if controller_name == \"sessions\" %>\n              <p class=\"text-sm text-center hidden md:block\">\n                <%= tag.span t(\".no_account\"), class: \"text-secondary\" %> <%= link_to t(\".sign_up\"), new_registration_path, class: \"font-medium text-primary hover:underline transition\" %>\n              </p>\n            <% elsif controller_name == \"registrations\" %>\n              <p class=\"text-sm text-center text-gray-600 hidden md:block\">\n                <%= t(\".existing_account\") %> <%= link_to t(\".sign_in\"), new_session_path, class: \"font-medium text-primary hover:underline transition\" %>\n              </p>\n            <% end %>\n          </div>\n        </div>\n\n        <div class=\"mt-0 md:mt-8 sm:mx-auto sm:w-full sm:max-w-lg\">\n          <%= yield %>\n        </div>\n      </div>\n\n      <%= render \"layouts/shared/footer\" %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/blank.html.erb",
    "content": "<%= render \"layouts/shared/htmldoc\" do %>\n  <div class=\"min-h-screen bg-surface text-primary font-medium flex flex-col\">\n    <div class=\"flex-1\">\n      <%= yield %>\n    </div>\n\n    <% if content_for?(:footer) %>\n      <%= yield :footer %>\n    <% else %>\n      <%= render \"layouts/shared/footer\" %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/doorkeeper/admin.html.erb",
    "content": "<!DOCTYPE html>\n<html>\n<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  <title><%= t(\"doorkeeper.layouts.admin.title\") %></title>\n  <%= stylesheet_link_tag \"doorkeeper/admin/application\" %>\n  <%= csrf_meta_tags %>\n</head>\n<body>\n<nav class=\"navbar navbar-expand-lg navbar-dark bg-dark mb-5\">\n  <%= link_to t(\"doorkeeper.layouts.admin.nav.oauth2_provider\"), oauth_applications_path, class: \"navbar-brand\" %>\n\n  <div class=\"collapse navbar-collapse\">\n    <ul class=\"navbar-nav mr-auto\">\n      <li class=\"nav-item <%= \"active\" if request.path == oauth_applications_path %>\">\n        <%= link_to t(\"doorkeeper.layouts.admin.nav.applications\"), oauth_applications_path, class: \"nav-link\" %>\n      </li>\n      <% if respond_to?(:root_path) %>\n        <li class=\"nav-item\">\n          <%= link_to t(\"doorkeeper.layouts.admin.nav.home\"), root_path, class: \"nav-link\" %>\n        </li>\n      <% end %>\n    </ul>\n  </div>\n</nav>\n\n<div class=\"doorkeeper-admin container\">\n  <%- if flash[:notice].present? %>\n    <div class=\"alert alert-info\">\n      <%= flash[:notice] %>\n    </div>\n  <% end -%>\n\n  <%= yield %>\n</div>\n</body>\n</html>\n"
  },
  {
    "path": "app/views/layouts/doorkeeper/application.html.erb",
    "content": "<!DOCTYPE html>\n<% theme = Current.user&.theme || \"system\" %>\n<html\n  lang=\"en\"\n  data-theme=\"<%= theme %>\"\n  data-controller=\"theme\"\n  data-theme-user-preference-value=\"<%= Current.user&.theme || \"system\" %>\"\n  class=\"h-full text-primary overflow-hidden lg:overflow-auto font-sans\">\n  <head>\n    <%= render \"layouts/shared/head\" %>\n  </head>\n\n  <body class=\"h-full overflow-hidden lg:overflow-auto antialiased\">\n    <div class=\"flex flex-col h-full\">\n      <div class=\"flex flex-col h-full px-6 py-12 bg-surface\">\n        <div class=\"grow flex flex-col justify-center\">\n          <div class=\"sm:mx-auto sm:w-full sm:max-w-md\">\n            <div class=\"flex justify-center mt-2 md:mb-6\">\n              <%= image_tag \"logo-color.png\", class: \"w-16 mb-6\" %>\n            </div>\n            <div class=\"space-y-2\">\n              <h2 class=\"text-3xl font-medium text-primary text-center\">\n                Maybe Authorization\n              </h2>\n            </div>\n          </div>\n\n          <div class=\"mt-5 md:mt-8 sm:mx-auto sm:w-full sm:max-w-lg\">\n            <%- if flash[:notice].present? %>\n              <div class=\"mb-4 p-3 rounded-lg bg-surface-inset text-sm text-secondary\">\n                <%= flash[:notice] %>\n              </div>\n            <% end -%>\n            <%- if flash[:alert].present? %>\n              <div class=\"mb-4 p-3 rounded-lg bg-destructive-surface text-sm text-destructive\">\n                <%= flash[:alert] %>\n              </div>\n            <% end -%>\n\n            <%= yield %>\n          </div>\n        </div>\n\n        <%= render \"layouts/shared/footer\" %>\n      </div>\n    </div>\n  </body>\n</html>\n"
  },
  {
    "path": "app/views/layouts/imports.html.erb",
    "content": "<%= render \"layouts/shared/htmldoc\" do %>\n  <div class=\"flex flex-col h-full bg-container\">\n    <header class=\"flex items-center justify-between p-8\">\n      <%= render DS::Link.new(\n        variant: \"icon\",\n        icon: \"arrow-left\",\n        href: content_for(:previous_path) || imports_path\n      ) %>\n\n      <nav>\n        <%= yield :header_nav %>\n      </nav>\n\n      <%= render DS::Link.new(\n        variant: \"icon\",\n        icon: \"x\",\n        href: imports_path\n      ) %>\n    </header>\n\n    <main class=\"grow px-8 md:pt-12 pb-32 overflow-y-auto\">\n      <div class=\"flex md:hidden justify-center w-full\">\n        <%= yield :mobile_import_progress %>\n      </div>\n\n      <%= yield %>\n    </main>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/lookbooks.html.erb",
    "content": "<!DOCTYPE html>\n<html data-theme=\"<%= params.dig(:lookbook, :display, :theme) %>\">\n  <head>\n    <title>Component Preview</title>\n    <%= csrf_meta_tags %>\n    <%= csp_meta_tag %>\n    <%= stylesheet_link_tag \"tailwind\", \"data-turbo-track\": \"reload\" %>\n    <%= javascript_include_tag \"application\", \"data-turbo-track\": \"reload\", defer: true %>\n    <%= javascript_importmap_tags %>\n  </head>\n  <body class=\"p-4 h-full bg-container <%= params.dig(:lookbook, :display, :container_classes) %>\">\n    <%= yield %>\n  </body>\n</html>\n"
  },
  {
    "path": "app/views/layouts/mailer.html.erb",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <style>\n      /* Email-safe styles that work across clients */\n      body {\n        background-color: #f8fafc;\n        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;\n        line-height: 1.5;\n        margin: 0;\n        padding: 0;\n      }\n      .container {\n        background-color: #ffffff;\n        border-radius: 8px;\n        margin: 20px auto;\n        max-width: 600px;\n        padding: 32px;\n        text-align: center;\n      }\n      h1 {\n        color: #1e293b;\n        font-size: 24px;\n        margin-bottom: 24px;\n      }\n      p {\n        color: #475569;\n        font-size: 16px;\n        margin-bottom: 16px;\n      }\n      .button {\n        background-color: #3b82f6;\n        border-radius: 6px;\n        color: #ffffff;\n        display: inline-block;\n        font-weight: 600;\n        margin: 16px 0;\n        padding: 12px 24px;\n        text-decoration: none;\n      }\n      .footer {\n        color: #64748b;\n        font-size: 14px;\n        margin-top: 32px;\n        text-align: center;\n      }\n    </style>\n  </head>\n\n  <body>\n    <div class=\"container\">\n      <%= yield %>\n    </div>\n  </body>\n</html>\n"
  },
  {
    "path": "app/views/layouts/mailer.text.erb",
    "content": "<%= yield %>\n"
  },
  {
    "path": "app/views/layouts/onboardings.html.erb",
    "content": "<%= render \"layouts/shared/htmldoc\" do %>\n  <%= yield %>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/settings.html.erb",
    "content": "<%= render \"layouts/shared/htmldoc\" do %>\n  <div class=\"flex flex-col lg:flex-row h-full bg-surface\">\n    <div class=\"p-4 w-full md:w-96 shrink-0 md:h-full md:overflow-y-auto\">\n      <%= render \"settings/settings_nav\" %>\n    </div>\n\n    <main class=\"px-4 pt-2 md:py-4 md:px-10 grow flex h-full overflow-y-auto\">\n      <div class=\"relative max-w-4xl mx-auto flex flex-col w-full h-full\">\n        <div class=\"grow space-y-4 overflow-y-auto -mx-1 px-1 pb-12\">\n          <% if content_for?(:breadcrumbs) %>\n            <%= yield :breadcrumbs %>\n          <% else %>\n            <%= render \"layouts/shared/breadcrumbs\", breadcrumbs: @breadcrumbs %>\n          <% end %>\n\n          <% if content_for?(:page_title) %>\n            <h1 class=\"text-primary text-3xl md:text-xl font-medium\">\n              <%= content_for :page_title %>\n            </h1>\n          <% end %>\n\n          <%= yield %>\n          <%= settings_nav_footer_mobile %>\n        </div>\n\n        <div class=\"mt-4\">\n          <%= settings_nav_footer %>\n        </div>\n      </div>\n    </main>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/shared/_breadcrumbs.html.erb",
    "content": "<%# locals: (breadcrumbs:) %>\n\n<div class=\"py-2 flex items-center gap-2\">\n  <% breadcrumbs.each_with_index do |(name, path), index| %>\n    <% if index > 0 %>\n      <%= icon(\"chevron-right\", color: \"gray\", size: \"sm\") %>\n    <% end %>\n\n    <% if path.present? && index < breadcrumbs.size - 1 %>\n      <%= link_to name, path, class: \"text-sm text-gray-500 font-medium\" %>\n    <% elsif index == breadcrumbs.size - 1 %>\n      <span class=\"text-primary font-medium text-sm\"><%= name %></span>\n    <% else %>\n      <span class=\"text-sm text-gray-500 font-medium\"><%= name %></span>\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/layouts/shared/_confirm_dialog.html.erb",
    "content": "<%# This dialog is used as an override to the browser's confirm API when submitting forms with data-turbo-confirm %>\n<%# See confirm_dialog_controller.js and _htmldoc.html.erb  %>\n<%= render DS::Dialog.new(id: \"confirm-dialog\", auto_open: false, data: { controller: \"confirm-dialog\" }, width: \"sm\", disable_frame: true) do |dialog| %>\n  <% dialog.with_body do %>\n    <form method=\"dialog\" class=\"space-y-4\">\n      <div class=\"space-y-2\">\n        <div class=\"flex items-center justify-between gap-2\">\n          <h3 class=\"font-medium text-primary\" data-confirm-dialog-target=\"title\">Are you sure?</h3>\n          <%= icon(\"x\", as_button: true, type: \"submit\", value: \"cancel\") %>\n        </div>\n\n        <p class=\"text-sm text-secondary\" data-confirm-dialog-target=\"subtitle\">This action cannot be undone.</p>\n      </div>\n\n      <div>\n        <% [\"primary\", \"outline-destructive\", \"destructive\"].each do |variant| %>\n          <%= render DS::Button.new(\n          text: \"Confirm\",\n          variant: variant,\n          autofocus: true,\n          full_width: true,\n          value: \"confirm\",\n          data: { variant: variant, confirm_dialog_target: \"confirmButton\" },\n          hidden: true,\n        ) %>\n        <% end %>\n      </div>\n    </form>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/shared/_footer.html.erb",
    "content": "<footer class=\"p-6\">\n  <div class=\"space-y-2 text-center text-xs text-secondary\">\n    <p>&copy; <%= Date.current.year %>, Maybe Finance, Inc.</p>\n    <div class=\"flex justify-center items-center gap-2\">\n      <%= link_to \"Privacy Policy\", privacy_path, class: \"text-secondary\", target: \"_blank\" %>\n      <span>&bull;</span>\n      <%= link_to \"Terms of Service\", terms_path, class: \"text-secondary\", target: \"_blank\" %>\n    </div>\n  </div>\n</footer>\n"
  },
  {
    "path": "app/views/layouts/shared/_head.html.erb",
    "content": "<head>\n  <title><%= content_for(:title) || \"Maybe\" %></title>\n\n  <%= csrf_meta_tags %>\n  <%= csp_meta_tag %>\n\n  <%= stylesheet_link_tag \"tailwind\", \"data-turbo-track\": \"reload\" %>\n\n  <%= javascript_include_tag \"https://cdn.plaid.com/link/v2/stable/link-initialize.js\" %>\n  <%= combobox_style_tag %>\n\n  <%= javascript_importmap_tags %>\n  <%= turbo_refreshes_with method: :morph, scroll: :preserve %>\n\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover\">\n\n  <meta name=\"mobile-web-app-capable\" content=\"yes\">\n  <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n  <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\">\n  <meta name=\"apple-mobile-web-app-title\" content=\"Maybe\">\n\n  <meta name=\"msapplication-TileColor\" content=\"#F9F9F9\">\n  <meta name=\"theme-color\" content=\"#F9F9F9\">\n\n  <link rel=\"manifest\" href=\"<%= pwa_manifest_path %>\">\n  <link rel=\"apple-touch-icon\" href=\"/logo-pwa.png\">\n  <link rel=\"apple-touch-icon\" sizes=\"512x512\" href=\"/logo-pwa.png\">\n\n  <%= yield :head %>\n</head>\n"
  },
  {
    "path": "app/views/layouts/shared/_htmldoc.html.erb",
    "content": "<!DOCTYPE html>\n\n<% theme = Current.user&.theme || \"system\" %>\n\n<html\n  lang=\"en\"\n  data-theme=\"<%= theme %>\"\n  data-controller=\"theme intercom\"\n  data-theme-user-preference-value=\"<%= Current.user&.theme || \"system\" %>\"\n  class=\"h-full text-primary overflow-hidden lg:overflow-auto font-sans <%= @os %>\">\n  <head>\n    <%= render \"layouts/shared/head\" %>\n    <%= yield :head %>\n  </head>\n\n  <body class=\"h-full overflow-hidden lg:overflow-auto antialiased\">\n    <% if Rails.env.development? %>\n      <button hidden data-controller=\"hotkey\" data-hotkey=\"t t /\" data-action=\"theme#toggle\"></button>\n    <% end %>\n\n    <div class=\"fixed z-50 top-6 md:top-4 left-1/2 -translate-x-1/2 w-full md:w-80 px-4 md:px-0 mx-auto md:mx-0 md:right-auto mt-safe\">\n      <div id=\"notification-tray\" class=\"space-y-1 w-full\">\n        <%= render_flash_notifications %>\n\n        <div id=\"cta\"></div>\n      </div>\n    </div>\n\n    <% if Current.family %>\n      <%= turbo_stream_from Current.family %>\n    <% end %>\n\n    <%= turbo_frame_tag \"modal\" %>\n    <%= turbo_frame_tag \"drawer\" %>\n\n    <%# Custom overrides for browser's confirm API  %>\n    <%= render \"layouts/shared/confirm_dialog\" %>\n\n    <%= render \"impersonation_sessions/super_admin_bar\" if Current.true_user&.super_admin? && show_super_admin_bar? %>\n    <%= render \"impersonation_sessions/approval_bar\" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %>\n\n    <%= yield %>\n  </body>\n</html>\n"
  },
  {
    "path": "app/views/layouts/shared/_nav_item.html.erb",
    "content": "<%# locals:(name:, path:, icon:, icon_custom:, active:, mobile_only: false) %>\n\n<%= link_to path, class: \"space-y-1 group block relative pb-1\" do %>\n  <div class=\"grow flex flex-col lg:flex-row gap-1 items-center\">\n    <%= tag.div class: class_names(\"w-4 h-1 lg:w-1 lg:h-4 rounded-bl-sm rounded-br-sm lg:rounded-tr-sm lg:rounded-br-sm lg:rounded-bl-none\", \"bg-nav-indicator\" => active) %>\n\n    <%= tag.div class: class_names(\n      \"w-8 h-8 flex items-center justify-center mx-auto rounded-lg\",\n      active ? \"bg-container shadow-xs text-primary\" : \"group-hover:bg-surface-hover text-secondary\"\n    ) do %>\n      <%= icon(icon, color: active ? \"current\" : \"default\", custom: icon_custom) %>\n    <% end %>\n  </div>\n\n  <div class=\"grow flex justify-center lg:pl-2\">\n    <%= tag.p class: class_names(\"font-medium text-[11px]\", active ? \"text-primary\" : \"text-secondary\") do %>\n      <%= name %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/layouts/shared/_page_header.html.erb",
    "content": "<%# This partial renders the page header with title and optional subtitle %>\n<header class=\"space-y-6\">\n  <% if local_assigns[:title].present? %>\n    <div class=\"space-y-1\">\n      <h1 class=\"text-3xl font-medium text-primary\"><%= title %></h1>\n      <% if local_assigns[:subtitle].present? %>\n        <p class=\"text-gray-500\"><%= subtitle %></p>\n      <% end %>\n    </div>\n  <% end %>\n</header>\n"
  },
  {
    "path": "app/views/layouts/wizard.html.erb",
    "content": "<%= render \"layouts/shared/htmldoc\" do %>\n  <div class=\"bg-surface flex flex-col h-full\">\n    <header class=\"flex items-center justify-between p-8\">\n      <% if content_for?(:prev_nav) %>\n        <%= yield :prev_nav %>\n      <% else %>\n        <%= render DS::Link.new(\n          variant: \"icon\",\n          icon: \"arrow-left\",\n          href: content_for(:previous_path) || root_path\n        ) %>\n      <% end %>\n\n      <nav>\n        <%= yield :header_nav %>\n      </nav>\n\n      <% if content_for?(:cancel_action) %>\n        <%= yield :cancel_action %>\n      <% else %>\n        <%= render DS::Link.new(\n          variant: \"icon\",\n          icon: \"x\",\n          href: content_for(:cancel_path) || root_path\n        ) %>\n      <% end %>\n    </header>\n\n    <main class=\"grow px-8 pt-12 pb-32 overflow-y-auto\">\n      <%= yield %>\n    </main>\n\n    <% if content_for?(:footer) %>\n      <%= yield :footer %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/loans/_form.html.erb",
    "content": "<%# locals: (account:, url:) %>\n\n<%= render \"accounts/form\", account: account, url: url do |form| %>\n  <%= render \"shared/ruler\", classes: \"my-4\" %>\n\n  <div class=\"space-y-2\">\n    <%= form.fields_for :accountable do |loan_form| %>\n      <div class=\"flex items-center gap-2\">\n        <%= loan_form.money_field :initial_balance,\n                                 label: t(\"loans.form.initial_balance\"),\n                                 default_currency: Current.family.currency,\n                                 required: true %>\n      </div>\n\n      <div class=\"flex items-center gap-2\">\n        <%= loan_form.number_field :interest_rate,\n                                 label: t(\"loans.form.interest_rate\"),\n                                 placeholder: t(\"loans.form.interest_rate_placeholder\"),\n                                 min: 0,\n                                 step: 0.005 %>\n        <%= loan_form.select :rate_type,\n                           [[\"Fixed\", \"fixed\"], [\"Variable\", \"variable\"], [\"Adjustable\", \"adjustable\"]],\n                           { label: t(\"loans.form.rate_type\") } %>\n      </div>\n\n      <div class=\"flex items-center gap-2\">\n        <%= loan_form.number_field :term_months,\n                                 label: t(\"loans.form.term_months\"),\n                                 placeholder: t(\"loans.form.term_months_placeholder\") %>\n      </div>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/loans/edit.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".edit\", account: @account.name)) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", account: @account, url: loan_path(@account) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/loans/new.html.erb",
    "content": "<% if params[:step] == \"method_select\" %>\n  <%= render \"accounts/new/method_selector\",\n             path: new_loan_path(return_to: params[:return_to]),\n             show_us_link: @show_us_link,\n             show_eu_link: @show_eu_link,\n             accountable_type: \"Loan\" %>\n<% else %>\n  <%= render DS::Dialog.new do |dialog| %>\n    <% dialog.with_header(title: t(\".title\")) %>\n    <% dialog.with_body do %>\n      <%= render \"loans/form\", account: @account, url: loans_path %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/loans/tabs/_overview.html.erb",
    "content": "<%# locals: (account:) %>\n\n<div class=\"grid grid-cols-3 gap-2\">\n  <%= summary_card title: t(\".original_principal\") do %>\n    <%= format_money account.loan.original_balance %>\n  <% end %>\n\n  <%= summary_card title: t(\".remaining_principal\") do %>\n    <%= format_money account.balance_money %>\n  <% end %>\n\n  <%= summary_card title: t(\".interest_rate\") do %>\n    <% if account.loan.interest_rate.present? %>\n      <%= number_to_percentage(account.loan.interest_rate, precision: 3) %>\n    <% else %>\n      <%= t(\".unknown\") %>\n    <% end %>\n  <% end %>\n\n  <%= summary_card title: t(\".monthly_payment\") do %>\n    <% if account.loan.rate_type.present? && account.loan.rate_type != 'fixed' %>\n      <%= t(\".not_applicable\") %>\n    <% elsif account.loan.rate_type == 'fixed' && account.loan.monthly_payment.present? %>\n      <%= format_money(account.loan.monthly_payment) %>\n    <% else %>\n      <%= t(\".unknown\") %>\n    <% end %>\n  <% end %>\n\n  <%= summary_card title: t(\".term\") do %>\n    <% if account.loan.term_months.present? %>\n      <% if account.loan.term_months < 12 %>\n        <%= pluralize(account.loan.term_months, \"month\") %>\n      <% else %>\n        <%= pluralize(account.loan.term_months / 12, \"year\") %>\n      <% end %>\n    <% else %>\n      <%= t(\".unknown\") %>\n    <% end %>\n  <% end %>\n\n  <%= summary_card title: t(\".type\") do %>\n    <%= account.loan.rate_type&.titleize || t(\".unknown\") %>\n  <% end %>\n</div>\n\n<div class=\"flex justify-center py-8\">\n  <%= render DS::Link.new(\n    text: \"Edit loan details\",\n    variant: \"ghost\",\n    href: edit_loan_path(account),\n    frame: :modal\n  ) %>\n</div>\n"
  },
  {
    "path": "app/views/messages/_chat_form.html.erb",
    "content": "<%# locals: (chat: nil, message_hint: nil) %>\n\n<div id=\"chat-form\" class=\"space-y-2\">\n  <% model = chat && chat.persisted? ? [chat, Message.new] : Chat.new %>\n\n  <%= form_with model: model,\n                class: \"flex lg:flex-col gap-2 bg-container px-2 py-1.5 rounded-full lg:rounded-lg shadow-border-xs\",\n                data: { chat_target: \"form\" } do |f| %>\n\n    <%# In the future, this will be a dropdown with different AI models %>\n    <%= f.hidden_field :ai_model, value: \"gpt-4.1\" %>\n\n    <%= f.text_area :content, placeholder: \"Ask anything ...\", value: message_hint,\n                  class: \"w-full border-0 focus:ring-0 text-sm resize-none px-1 bg-transparent\",\n                  data: { chat_target: \"input\", action: \"input->chat#autoResize keydown->chat#handleInputKeyDown\" },\n                  rows: 1 %>\n\n    <div class=\"flex items-center justify-between gap-1\">\n      <div class=\"items-center gap-1 hidden lg:flex\">\n        <%# These are disabled for now, but in the future, will all open specific menus with their own context and search %>\n        <% [\"plus\", \"command\", \"at-sign\", \"mouse-pointer-click\"].each do |icon| %>\n          <%= icon(icon, as_button: true, disabled: true, class: \"cursor-not-allowed\", title: \"Coming soon\") %>\n        <% end %>\n      </div>\n\n      <%= icon(\"arrow-up\", as_button: true, type: \"submit\") %>\n    </div>\n  <% end %>\n\n  <p class=\"text-xs text-secondary\">AI responses are informational only and are not financial advice.</p>\n</div>\n"
  },
  {
    "path": "app/views/mfa/backup_codes.html.erb",
    "content": "<%\n  header_title t(\".title\")\n  header_description t(\".description\")\n%>\n\n<% content_for :sidebar do %>\n  <%= render \"settings/settings_nav\" %>\n<% end %>\n\n<div class=\"space-y-4\">\n  <h1 class=\"text-primary text-xl font-medium mb-4\"><%= t(\".page_title\") %></h1>\n  <%= settings_section title: t(\".backup_codes_title\"), subtitle: t(\".backup_codes_description\") do %>\n    <div class=\"space-y-6\">\n      <div class=\"grid grid-cols-2 gap-4\">\n        <% @backup_codes.each do |code| %>\n          <div class=\"p-3 bg-surface-inset rounded-lg font-mono text-lg\">\n            <%= code %>\n          </div>\n        <% end %>\n      </div>\n\n      <%= render DS::Link.new(\n        text: t(\".continue\"),\n        href: settings_security_path,\n        variant: \"primary\",\n        full_width: true\n      ) %>\n    </div>\n  <% end %>\n\n  <%= settings_nav_footer %>\n</div>\n"
  },
  {
    "path": "app/views/mfa/new.html.erb",
    "content": "<%\n  header_title t(\".title\")\n  header_description t(\".description\")\n%>\n\n<% content_for :sidebar do %>\n  <%= render \"settings/settings_nav\" %>\n<% end %>\n\n<div class=\"space-y-4\">\n  <h1 class=\"text-primary text-xl font-medium mb-4\"><%= t(\".page_title\") %></h1>\n  <%= settings_section title: t(\".scan_title\"), subtitle: t(\".scan_description\") do %>\n    <div class=\"space-y-6\">\n      <div class=\"qrcode\">\n        <%= generate_mfa_qr_code(Current.user.provisioning_uri) %>\n      </div>\n\n      <div class=\"mt-6\">\n        <h3 class=\"text-sm font-medium text-primary\"><%= t(\".secret_title\") %></h3>\n        <div class=\"mt-2 text-sm text-secondary\">\n          <p><%= t(\".secret_description\") %></p>\n        </div>\n        <div class=\"mt-2 flex items-center gap-2\" data-controller=\"clipboard\">\n          <span data-clipboard-target=\"source\" class=\"hidden\"><%= Current.user.otp_secret %></span>\n          <input type=\"text\"\n                 readonly\n                 autocomplete=\"off\"\n                 value=\"<%= Current.user.otp_secret %>\"\n                 class=\"text-sm bg-container px-2 py-1 rounded border border-secondary w-96 font-mono\">\n          <button data-action=\"clipboard#copy\" class=\"text-secondary hover:text-gray-700\">\n            <span data-clipboard-target=\"iconDefault\">\n              <%= icon \"copy\" %>\n            </span>\n            <span class=\"hidden\" data-clipboard-target=\"iconSuccess\">\n              <%= icon \"check\" %>\n            </span>\n          </button>\n        </div>\n      </div>\n\n      <div>\n        <h3 class=\"text-lg font-medium leading-6 text-primary\"><%= t(\".verify_title\") %></h3>\n        <div class=\"mt-2 text-sm text-secondary\">\n          <p><%= t(\".verify_description\") %></p>\n        </div>\n      </div>\n\n      <%= styled_form_with url: mfa_path, method: :post, class: \"mt-5\", data: { turbo: false } do |f| %>\n        <div>\n          <%= f.text_field :code,\n            required: true,\n            autofocus: true,\n            autocomplete: \"one-time-code\",\n            inputmode: \"numeric\",\n            pattern: \"[0-9]*\",\n            label: t(\".code_label\"),\n            placeholder: t(\".code_placeholder\") %>\n\n          <div class=\"flex justify-end mt-4\">\n            <%= f.submit t(\".verify_button\") %>\n          </div>\n        </div>\n      <% end %>\n    </div>\n  <% end %>\n\n  <%= settings_nav_footer %>\n</div>\n"
  },
  {
    "path": "app/views/mfa/verify.html.erb",
    "content": "<%\n  header_title t(\".title\")\n  header_description t(\".description\")\n%>\n\n<%= styled_form_with url: verify_mfa_path, method: :post, class: \"space-y-4 mt-4 md:mt-0\", data: { turbo: false } do |form| %>\n  <%= form.text_field :code,\n    required: true,\n    autofocus: true,\n    autocomplete: \"one-time-code\",\n    type: \"number\",\n    label: t(\".page_title\") %>\n\n  <%= form.submit t(\".verify_button\") %>\n<% end %>\n"
  },
  {
    "path": "app/views/onboardings/_logout.html.erb",
    "content": " <%= render DS::Button.new(\n        text: \"Sign out\",\n        icon: \"log-out\",\n        icon_position: :right,\n        variant: \"ghost\",\n        href: session_path(Current.session),\n        method: :delete\n      ) %>\n"
  },
  {
    "path": "app/views/onboardings/_onboarding_nav.html.erb",
    "content": "<%# locals: (user:) %>\n\n<% steps = [\n  { name: \"Setup\", path: onboarding_path, is_complete: user.first_name.present?, step_number: 1 },\n  { name: \"Preferences\", path: preferences_onboarding_path, is_complete: user.set_onboarding_preferences_at.present?, step_number: 2 },\n  { name: \"Goals\", path: goals_onboarding_path , is_complete: user.set_onboarding_goals_at.present?, step_number: 3 },\n  { name: \"Start\", path: trial_onboarding_path, is_complete: user.onboarded?, step_number: 4 },\n] %>\n\n<%# Don't show last step if self hosted %>\n<% steps.pop if self_hosted? %>\n\n<ul class=\"hidden md:flex items-center gap-2\">\n  <% steps.each_with_index do |step, idx| %>\n    <li class=\"flex items-center gap-2 group\">\n      <% is_current = request.path == step[:path] %>\n\n      <% text_class = if is_current\n                  \"text-primary\"\n                else\n                  step[:is_complete] ? \"text-green-600\" : \"text-secondary\"\n                end %>\n      <% step_class = if is_current\n                  \"bg-surface-inset text-primary\"\n                else\n                  step[:is_complete] ? \"bg-green-600/10 border-alpha-black-25\" : \"bg-container-inset\"\n                end %>\n\n      <%= link_to step[:path], class: \"flex items-center gap-3\" do %>\n        <div class=\"flex items-center gap-2 text-sm font-medium <%= text_class %>\">\n          <span class=\"<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent\">\n            <%= step[:is_complete] && !is_current ? icon(\"check\", size: \"sm\", color: \"current\") : idx + 1 %>\n          </span>\n\n          <span><%= step[:name] %></span>\n        </div>\n      <% end %>\n\n      <hr class=\"border border-secondary w-12 group-last:hidden\">\n    </li>\n  <% end %>\n</ul>\n"
  },
  {
    "path": "app/views/onboardings/goals.html.erb",
    "content": "<%= content_for :previous_path, preferences_onboarding_path %>\n\n<%= content_for :header_nav do %>\n  <%= render \"onboardings/onboarding_nav\", user: @user %>\n<% end %>\n\n<%= content_for :cancel_action do %>\n  <%= render \"onboardings/logout\" %>\n<% end %>\n\n<%= content_for :footer do %>\n  <%= render \"layouts/shared/footer\" %>\n<% end %>\n\n<div class=\"grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-6 px-4 md:px-0\">\n  <div>\n    <div class=\"space-y-1 mb-6 text-center\">\n      <h1 class=\"text-2xl font-medium md:text-2xl\">What brings you to Maybe?</h1>\n      <p class=\"text-secondary text-sm\">Select one or more goals that you have with using Maybe as your personal finance tool.</p>\n    </div>\n\n    <%= form_with model: @user do |form| %>\n      <%= form.hidden_field :redirect_to, value: self_hosted? ? \"home\" : \"trial\" %>\n      <%= form.hidden_field :set_onboarding_goals_at, value: Time.current %>\n      <%= form.hidden_field :onboarded_at, value: Time.current %>\n\n      <div class=\"space-y-3\">\n        <% [\n          { icon: \"layers\", label: \"See all my accounts in one place\", value: \"unified_accounts\" },\n          { icon: \"banknote\", label: \"Understand cashflow and expenses\", value: \"cashflow\" },\n          { icon: \"pie-chart\", label: \"Manage financial plans and budgeting\", value: \"budgeting\" },\n          { icon: \"users\", label: \"Manage finances with a partner\", value: \"partner\" },\n          { icon: \"area-chart\", label: \"Track investments\", value: \"investments\" },\n          { icon: \"bot\", label: \"Let AI help me understand my finances\", value: \"ai_insights\" },\n          { icon: \"settings-2\", label: \"Analyze and optimize accounts\", value: \"optimization\" },\n          { icon: \"frown\", label: \"Reduce financial stress or anxiety\", value: \"reduce_stress\" }\n        ].each do |goal| %>\n          <label class=\"flex items-center gap-2.5 p-4 rounded-lg border border-tertiary cursor-pointer hover:bg-container transition-colors [&:has(input:checked)]:border-solid [&:has(input:checked)]:bg-container\">\n            <%= form.check_box :goals, { multiple: true, class: \"sr-only\" }, goal[:value], nil %>\n            <%= icon goal[:icon] %>\n            <span class=\"text-primary text-sm\"><%= goal[:label] %></span>\n          </label>\n        <% end %>\n      </div>\n\n      <div class=\"mt-6\">\n        <%= render DS::Button.new(\n          text: \"Next\",\n          full_width: true\n        ) %>\n      </div>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/onboardings/preferences.html.erb",
    "content": "<%= content_for :previous_path, onboarding_path %>\n\n<%= content_for :header_nav do %>\n  <%= render \"onboardings/onboarding_nav\", user: @user %>\n<% end %>\n\n<%= content_for :cancel_action do %>\n  <%= render \"onboardings/logout\" %>\n<% end %>\n\n<div class=\"grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-6 px-4 md:px-0\" data-controller=\"onboarding\">\n  <div>\n    <div class=\"space-y-1 mb-6\">\n      <h1 class=\"text-2xl font-medium\"><%= t(\".title\") %></h1>\n      <p class=\"text-secondary text-sm\"><%= t(\".subtitle\") %></p>\n    </div>\n\n    <div class=\"p-1 bg-alpha-black-25 mb-2 rounded-lg\">\n      <div class=\"bg-container p-4 rounded-lg flex gap-8 shadow-border-xs\">\n        <div class=\"space-y-2\">\n          <%= tag.p t(\".example\"), class: \"text-secondary text-sm\" %>\n          <%= tag.p format_money(Money.new(2325.25, params[:currency] || @user.family.currency)), class: \"text-primary font-medium text-2xl\" %>\n          <p class=\"text-sm\">\n            <span class=\"text-green-500 font-medium\">+<%= format_money(Money.new(78.90, params[:currency] || @user.family.currency)) %></span>\n            <span class=\"text-green-500 font-medium\">(+<%= format_money(Money.new(6.39, params[:currency] || @user.family.currency)) %>)</span>\n            <span class=\"text-secondary\">as of <%= format_date(Date.parse(\"2024-10-23\"), :default, format_code: params[:date_format] || @user.family.date_format) %></span>\n          </p>\n        </div>\n\n        <% placeholder_series_data = [\n            { date: Date.current - 14.days, value: 200 },\n            { date: Date.current - 13.days, value: 200 },\n            { date: Date.current - 12.days, value: 220 },\n            { date: Date.current - 11.days, value: 220 },\n            { date: Date.current - 10.days, value: 220 },\n            { date: Date.current - 9.days, value: 220 },\n            { date: Date.current - 8.days, value: 220 },\n            { date: Date.current - 7.days, value: 220 },\n            { date: Date.current - 6.days, value: 230 },\n            { date: Date.current - 5.days, value: 230 },\n            { date: Date.current - 4.days, value: 250 },\n            { date: Date.current - 3.days, value: 250 },\n            { date: Date.current - 2.days, value: 265 },\n            { date: Date.current - 1.day, value: 265 },\n            { date: Date.current, value: 265 }\n          ] %>\n\n        <% placeholder_series = Series.from_raw_values(placeholder_series_data) %>\n\n        <div class=\"flex items-center w-2/5\">\n          <div class=\"h-12 w-full\">\n            <div\n                id=\"previewChart\"\n                class=\"h-full w-full\"\n                data-controller=\"time-series-chart\"\n                data-time-series-chart-data-value=\"<%= placeholder_series.to_json %>\"\n                data-time-series-chart-use-labels-value=\"false\"\n                data-time-series-chart-use-tooltip-value=\"false\"></div>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <p class=\"text-secondary text-xs mb-4\"><%= t(\".preview\") %></p>\n\n    <%= styled_form_with model: @user, data: { turbo: false } do |form| %>\n      <%= form.hidden_field :set_onboarding_preferences_at, value: Time.current %>\n      <%= form.hidden_field :redirect_to, value: \"goals\" %>\n\n      <div class=\"mb-4\">\n        <%= form.select :theme, [[\"System\", \"system\"], [\"Light\", \"light\"], [\"Dark\", \"dark\"]], { label: \"Color theme\" }, data: { action: \"onboarding#setTheme\" } %>\n      </div>\n\n      <div class=\"space-y-4 mb-4\">\n        <%= form.fields_for :family do |family_form| %>\n\n          <%= family_form.select :locale,\n            language_options,\n            { label: t(\".locale\"), required: true, selected: params[:locale] || @user.family.locale },\n            { data: { action: \"onboarding#setLocale\" } } %>\n\n          <%= family_form.select :currency,\n            Money::Currency.as_options.map { |currency| [ \"#{currency.name} (#{currency.iso_code})\", currency.iso_code ] },\n            { label: t(\".currency\"), required: true, selected: params[:currency] || @user.family.currency },\n            { data: { action: \"onboarding#setCurrency\" } } %>\n\n          <%= family_form.select :date_format,\n            Family::DATE_FORMATS,\n            { label: t(\".date_format\"), required: true, selected: params[:date_format] || @user.family.date_format },\n            { data: { action: \"onboarding#setDateFormat\" } } %>\n\n        <% end %>\n      </div>\n\n      <%= form.submit t(\".submit\") %>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/onboardings/show.html.erb",
    "content": "<%= content_for :prev_nav do %>\n  <%= image_tag \"logomark-color.svg\", class: \"w-10 h-10\" %>\n<% end %>\n\n<%= content_for :header_nav do %>\n  <%= render \"onboardings/onboarding_nav\", user: @user %>\n<% end %>\n\n<%= content_for :cancel_action do %>\n  <%= render \"onboardings/logout\" %>\n<% end %>\n\n<div class=\"grow max-w-lg w-full mx-auto bg-surface flex flex-col justify-center md:py-0 py-6 px-4 md:px-0\">\n  <div>\n    <div class=\"space-y-1 mb-6 text-center\">\n      <h1 class=\"text-2xl font-medium md:text-2xl\">Let's set up your account</h1>\n      <p class=\"text-secondary text-sm\">First things first, let's get your profile set up.</p>\n    </div>\n\n    <%= styled_form_with model: @user do |form| %>\n      <%= form.hidden_field :redirect_to, value: @invitation ? \"home\" : \"onboarding_preferences\" %>\n      <%= form.hidden_field :onboarded_at, value: Time.current if @invitation %>\n\n      <div class=\"mb-6\">\n        <%= render \"settings/user_avatar_field\", form: form, user: @user %>\n      </div>\n\n      <div class=\"flex flex-col md:flex-row md:justify-between md:items-center md:gap-4 space-y-4 md:space-y-0 mb-4\">\n        <%= form.text_field :first_name, placeholder: \"First name\", label: \"First name\", container_class: \"bg-container md:w-1/2 w-full\", required: true %>\n        <%= form.text_field :last_name, placeholder: \"Last name\", label: \"Last name\", container_class: \"bg-container md:w-1/2 w-full\", required: true %>\n      </div>\n\n      <% unless @invitation %>\n        <div class=\"space-y-4 mb-4\">\n          <%= form.fields_for :family do |family_form| %>\n            <%= family_form.text_field :name, placeholder: \"Household name\", label: \"Household name\" %>\n\n            <%= family_form.select :country,\n              country_options,\n              { label: \"Country\" },\n              required: true %>\n          <% end %>\n        </div>\n      <% end %>\n\n      <%= form.submit \"Continue\" %>\n    <% end %>\n  </div>\n</div>\n\n<%= render \"layouts/shared/footer\" %>\n</div>\n"
  },
  {
    "path": "app/views/onboardings/trial.html.erb",
    "content": "<%= content_for :previous_path, goals_onboarding_path %>\n\n<%= content_for :header_nav do %>\n  <%= render \"onboardings/onboarding_nav\", user: @user %>\n<% end %>\n\n<%= content_for :cancel_action do %>\n  <%= render \"onboardings/logout\" %>\n<% end %>\n\n<%= content_for :footer do %>\n  <%= render \"layouts/shared/footer\" %>\n<% end %>\n\n<div class=\"grow flex flex-col gap-12 items-center justify-center\">\n  <div class=\"max-w-sm mx-auto flex flex-col items-center\">\n    <%= image_tag \"logo-color.png\", class: \"w-16 mb-6\" %>\n\n    <p class=\"text-xl lg:text-3xl text-primary font-display font-medium\">\n      Try Maybe for 14 days.\n    </p>\n\n    <h2 class=\"text-xl lg:text-3xl font-display text-secondary font-medium mb-2\">\n      No credit card required\n    </h2>\n\n    <p class=\"text-sm text-secondary text-center mb-8\">\n      Starting the trial activates your account for Maybe.  You won't need to enter payment details.\n    </p>\n\n    <div class=\"w-full\">\n      <% if Current.family.can_start_trial? %>\n        <%= render DS::Button.new(\n        text: \"Try Maybe for 14 days\",\n        href: subscription_path,\n        full_width: true,\n        data: { turbo: false }\n      ) %>\n      <% elsif Current.family.trialing? %>\n        <%= render DS::Link.new(\n          text: \"Continue trial\",\n          href: root_path,\n          full_width: true,\n        ) %>\n      <% else %>\n        <%= render DS::Link.new(\n          text: \"Upgrade\",\n          href: upgrade_subscription_path,\n          full_width: true,\n        ) %>\n      <% end %>\n    </div>\n  </div>\n\n  <div class=\"space-y-8\">\n    <h2 class=\"text-center text-lg lg:text-2xl font-medium text-primary\">How your trial will work</h2>\n\n    <div class=\"flex gap-3\">\n      <div class=\"rounded-xl p-1 bg-gray-400/20 theme-dark:bg-gray-500/20 flex flex-col justify-between items-center text-secondary\">\n        <%= render DS::FilledIcon.new(icon: \"unlock-keyhole\", variant: :inverse) %>\n        <%= render DS::FilledIcon.new(icon: \"bell\", variant: :inverse) %>\n        <%= render DS::FilledIcon.new(icon: \"credit-card\", variant: :inverse) %>\n      </div>\n\n      <div class=\"space-y-12\">\n        <div class=\"space-y-1.5 text-sm\">\n          <p class=\"text-primary font-medium\">Today</p>\n          <p class=\"text-secondary\">You'll get free access to Maybe for 14 days</p>\n        </div>\n\n        <div class=\"space-y-1.5 text-sm\">\n          <p class=\"text-primary font-medium\">In 13 days (<%= 13.days.from_now.strftime(\"%B %d\") %>)</p>\n          <p class=\"text-secondary\">We'll notify you to remind you when your trial will end.</p>\n        </div>\n\n        <div class=\"space-y-1.5 text-sm\">\n          <p class=\"text-primary font-medium\">In 14 days (<%= 14.days.from_now.strftime(\"%B %d\") %>)</p>\n          <p class=\"text-secondary\">Your trial ends &mdash; subscribe to continue using Maybe</p>\n        </div>\n      </div>\n    </div>\n  </div>\n\n  <div class=\"space-y-8 max-w-2xl mx-auto\">\n    <h2 class=\"text-center text-lg lg:text-2xl font-medium text-primary\">Here's what's included</h2>\n\n    <div class=\"grid grid-cols-1 lg:grid-cols-3 gap-x-12 gap-y-6 text-secondary\">\n      <div class=\"flex flex-col gap-4 items-center\">\n        <%= render DS::FilledIcon.new(icon: \"landmark\", variant: :surface) %>\n        <p class=\"text-sm text-primary text-center\">More than 10,000 institutions to connect to</p>\n      </div>\n\n      <div class=\"flex flex-col gap-4 items-center\">\n        <%= render DS::FilledIcon.new(icon: \"layers\", variant: :surface) %>\n        <p class=\"text-sm text-primary text-center\">Connect unlimited accounts and account types</p>\n      </div>\n\n      <div class=\"flex flex-col gap-4 items-center\">\n        <%= render DS::FilledIcon.new(icon: \"line-chart\", variant: :surface) %>\n        <p class=\"text-sm text-primary text-center\">Performance and investment returns across portfolio</p>\n      </div>\n\n      <div class=\"flex flex-col gap-4 items-center\">\n        <%= render DS::FilledIcon.new(icon: \"credit-card\", variant: :surface) %>\n        <p class=\"text-sm text-primary text-center\">Comprehensive transaction tracking experience</p>\n      </div>\n\n      <div class=\"flex flex-col gap-4 items-center\">\n        <%= render \"chats/ai_avatar\" %>\n        <p class=\"text-sm text-primary text-center\">Unlimited access and chats with Maybe AI</p>\n      </div>\n\n      <div class=\"flex flex-col gap-4 items-center\">\n        <%= render DS::FilledIcon.new(icon: \"keyboard\", variant: :surface) %>\n        <p class=\"text-sm text-primary text-center\">Manual account tracking that works well</p>\n      </div>\n\n      <div class=\"flex flex-col gap-4 items-center\">\n        <%= render DS::FilledIcon.new(icon: \"globe-2\", variant: :surface) %>\n        <p class=\"text-sm text-primary text-center\">Multiple currencies and near global coverage</p>\n      </div>\n\n      <div class=\"flex flex-col gap-4 items-center\">\n        <%= render DS::FilledIcon.new(icon: \"ship\", variant: :surface) %>\n        <p class=\"text-sm text-primary text-center\">Early access to newly released features</p>\n      </div>\n\n      <div class=\"flex flex-col gap-4 items-center\">\n        <%= render DS::FilledIcon.new(icon: \"messages-square\", variant: :surface) %>\n        <p class=\"text-sm text-primary text-center\">Priority human support from team</p>\n      </div>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/other_assets/_form.html.erb",
    "content": "<%# locals: (account:, url:) %>\n\n<%= render \"accounts/form\", account: account, url: url %>\n"
  },
  {
    "path": "app/views/other_assets/edit.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".edit\", account: @account.name)) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", account: @account, url: other_asset_path(@account) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/other_assets/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".title\")) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", account: @account, url: other_assets_path %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/other_liabilities/_form.html.erb",
    "content": "<%# locals: (account:, url:) %>\n\n<%= render \"accounts/form\", account: account, url: url %>\n"
  },
  {
    "path": "app/views/other_liabilities/edit.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".edit\", account: @account.name)) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", account: @account, url: other_liability_path(@account) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/other_liabilities/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".title\")) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", account: @account, url: other_liabilities_path %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/pages/changelog.html.erb",
    "content": "<%= content_for :page_title, t(\".title\") %>\n\n<div class=\"bg-container shadow-border-xs rounded-xl p-4 grow overflow-y-auto\">\n  <div class=\"flex flex-col md:flex-row justify-between gap-4 mb-12 last:mb-0\">\n    <div class=\"w-full md:w-1/3\">\n      <div class=\"md:px-3 flex items-center gap-3\">\n        <% if @release_notes[:avatar].present? %>\n          <div class=\"fg-inverse shrink-0 w-9 h-9\">\n            <%= image_tag @release_notes[:avatar], class: \"rounded-full w-full h-full object-cover\" %>\n          </div>\n        <% else %>\n          <div class=\"bg-gray-300 text-gray-600 shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-sm font-medium\">\n            <%= @release_notes[:username]&.first&.upcase || \"?\" %>\n          </div>\n        <% end %>\n        <div>\n          <a class=\"text-primary font-medium text-sm\" href=\"https://github.com/<%= @release_notes[:username] %>\"><%= \"@#{@release_notes[:username]}\" %></a>\n          <div class=\"text-secondary text-sm\"><%= @release_notes[:published_at]&.strftime(\"%B %d, %Y\") || \"Date unavailable\" %></div>\n        </div>\n      </div>\n    </div>\n    <div class=\"w-full md:w-2/3 text-secondary text-sm prose prose--github-release-notes\">\n      <h2 class=\"mb-5 text-xl text-primary\"><%= @release_notes[:name] %></h2>\n      <%= (@release_notes[:body] || \"No release notes available\").html_safe %>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/pages/dashboard/_balance_sheet.html.erb",
    "content": "<%# locals: (balance_sheet:, **args) %>\n\n<div class=\"space-y-4\" id=\"balance-sheet\">\n  <% balance_sheet.classification_groups.each do |classification_group| %>\n    <div class=\"bg-container shadow-border-xs rounded-xl space-y-4 p-4\">\n      <div class=\"flex items-center gap-2\">\n        <h2 class=\"text-lg font-medium inline-flex items-center gap-1.5\">\n          <span class=\"<%= \"animate-pulse\" if classification_group.syncing? %>\">\n            <%= classification_group.name %>\n          </span>\n\n          <% if classification_group.account_groups.any? %>\n            <span class=\"text-secondary\">&middot;</span>\n            <span class=\"text-secondary font-medium text-lg\"><%= classification_group.total_money.format(precision: 0) %></span>\n          <% end %>\n        </h2>\n      </div>\n\n      <% if classification_group.account_groups.any? %>\n        <div class=\"space-y-4\">\n          <div class=\"flex gap-1\">\n            <% classification_group.account_groups.each do |account_group| %>\n              <div class=\"h-1.5 rounded-sm\" style=\"width: <%= account_group.weight %>%; background-color: <%= account_group.color %>;\"></div>\n            <% end %>\n          </div>\n\n          <div class=\"flex flex-wrap gap-4\">\n            <% classification_group.account_groups.each do |account_group| %>\n              <div class=\"flex items-center gap-2 text-sm\">\n                <div class=\"h-2.5 w-2.5 rounded-full\" style=\"background-color: <%= account_group.color %>;\"></div>\n                <p class=\"text-secondary\"><%= account_group.name %></p>\n                <p class=\"text-primary font-mono\"><%= number_to_percentage(account_group.weight, precision: 0) %></p>\n              </div>\n            <% end %>\n          </div>\n        </div>\n\n        <div class=\"bg-surface rounded-xl p-1 space-y-1 overflow-x-auto\">\n          <div class=\"px-4 py-2 flex items-center uppercase text-xs font-medium text-secondary\">\n            <div class=\"w-40\">Name</div>\n            <div class=\"ml-auto text-right flex items-center gap-6\">\n              <div class=\"w-24\">\n                <p>Weight</p>\n              </div>\n              <div class=\"w-40\">\n                <p>Value</p>\n              </div>\n            </div>\n          </div>\n\n          <div class=\"shadow-border-xs rounded-lg bg-container font-medium text-sm min-w-fit\">\n            <% classification_group.account_groups.each_with_index do |account_group, idx| %>\n              <details class=\"group open:bg-surface\n                <%= idx == 0 ? \"rounded-t-lg\" : \"\" %>\n                <%= idx == classification_group.account_groups.size - 1 ? \"rounded-b-lg\" : \"\" %>\n              \">\n                <summary class=\"cursor-pointer p-4 group-open:bg-surface rounded-lg flex items-center justify-between\">\n                  <div class=\"w-40 shrink-0 flex items-center gap-4\">\n                    <%= icon(\"chevron-right\", class: \"group-open:rotate-90\") %>\n\n                    <p><%= account_group.name %></p>\n                  </div>\n\n                  <div class=\"flex items-center justify-between text-right gap-6\">\n                    <div class=\"w-28 shrink-0 flex items-center justify-end gap-2\">\n                      <%= render \"pages/dashboard/group_weight\", weight: account_group.weight, color: account_group.color %>\n                    </div>\n\n                    <div class=\"w-40 shrink-0\">\n                      <p><%= format_money(account_group.total_money) %></p>\n                    </div>\n                  </div>\n                </summary>\n\n                <div>\n                  <% account_group.accounts.each_with_index do |account, idx| %>\n                    <div class=\"pl-12 pr-4 py-3 flex items-center justify-between text-sm font-medium\">\n                      <div class=\"flex items-center gap-3\">\n                        <%= render \"accounts/logo\", account: account, size: \"sm\", color: account_group.color %>\n\n                        <%= link_to account.name, account_path(account) %>\n                      </div>\n\n                      <div class=\"ml-auto flex items-center text-right gap-6\">\n                        <div class=\"w-28 shrink-0 flex items-center justify-end gap-2\">\n                          <%\n                              # Calculate weight as percentage of classification total\n                              classification_total = classification_group.total_money.amount\n                              account_weight = classification_total.zero? ? 0 : account.converted_balance / classification_total * 100\n                          %>\n                          <%= render \"pages/dashboard/group_weight\", weight: account_weight, color: account_group.color %>\n                        </div>\n\n                        <div class=\"w-40 shrink-0\">\n                          <p><%= format_money(account.balance_money) %></p>\n                        </div>\n                      </div>\n                    </div>\n\n                    <% if idx < account_group.accounts.size - 1 %>\n                      <%= render \"shared/ruler\", classes: \"ml-21 mr-4\" %>\n                    <% end %>\n                  <% end %>\n                </div>\n              </details>\n              <% unless idx == classification_group.account_groups.size - 1 %>\n                <%= render \"shared/ruler\", classes: \"mx-4 group-ruler\" %>\n              <% end %>\n            <% end %>\n          </div>\n        </div>\n\n      <% else %>\n        <div class=\"py-10 flex flex-col items-center\">\n          <%= render DS::FilledIcon.new(\n            variant: :container,\n            icon: classification_group.icon,\n          ) %>\n\n          <p class=\"text-primary text-sm font-medium mb-1 mt-4\">No <%= classification_group.name %> yet</p>\n          <p class=\"text-secondary text-sm text-center\"><%= \"Add your #{classification_group.name} accounts to see a full breakdown\" %></p>\n        </div>\n      <% end %>\n    </div>\n  <% end %>\n</div>\n\n<%# Custom style for hiding ruler when details are open %>\n<style>\n  details[open] + .group-ruler {\n    display: none;\n  }\n</style>\n"
  },
  {
    "path": "app/views/pages/dashboard/_cashflow_sankey.html.erb",
    "content": "<%# locals: (sankey_data:, period:) %>\n<div id=\"cashflow-sankey-chart\">\n  <div class=\"flex justify-between items-center gap-4 px-4 mb-4\">\n    <h2 class=\"text-lg font-medium inline-flex items-center gap-1.5\">\n      Cashflow\n    </h2>\n\n    <%= form_with url: root_path, method: :get, data: { controller: \"auto-submit-form\", turbo_frame: \"cashflow_sankey_section\" } do |form| %>\n      <%= form.select :cashflow_period,\n                Period.as_options,\n                { selected: period.key },\n                data: { \"auto-submit-form-target\": \"auto\" },\n                class: \"bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0\" %>\n    <% end %>\n  </div>\n\n  <% if sankey_data[:links].present? %>\n    <div class=\"w-full h-96\">\n      <div\n        data-controller=\"sankey-chart\"\n        data-sankey-chart-data-value=\"<%= sankey_data.to_json %>\"\n        data-sankey-chart-currency-symbol-value=\"<%= sankey_data[:currency_symbol] %>\"\n        class=\"w-full h-full\"></div>\n    </div>\n  <% else %>\n    <div class=\"h-[300px] lg:h-[340px] bg-container py-4 flex flex-col items-center justify-center\">\n      <div class=\"space-y-3 text-center flex flex-col items-center\">\n        <%= render DS::FilledIcon.new(\n          variant: :container,\n          icon: \"activity\" # cashflow placeholder icon\n        ) %>\n\n        <p class=\"text-sm font-medium text-primary\">No cash flow data for this time period</p>\n        <p class=\"text-secondary text-sm\">Add transactions to display cash flow data or expand the time period</p>\n        <%= render DS::Link.new(\n          text: \"Add transaction\",\n          icon: \"plus\",\n          href: new_transaction_path,\n          frame: :modal\n        ) %>\n      </div>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/pages/dashboard/_group_weight.html.erb",
    "content": "<%# locals: (weight:, color:) %>\n\n<% effective_weight = weight.presence || 0 %>\n\n<div class=\"w-full flex items-center justify-between gap-2\">\n  <div class=\"flex gap-[3px]\">\n    <% 10.times do |i| %>\n      <div class=\"w-0.5 h-2.5 rounded-lg <%= i < (effective_weight / 10.0).ceil ? \"\" : \"opacity-20\" %>\" style=\"background-color: <%= color %>;\"></div>\n    <% end %>\n  </div>\n  <p class=\"text-sm\"><%= number_to_percentage(effective_weight, precision: 2) %></p>\n</div>\n"
  },
  {
    "path": "app/views/pages/dashboard/_net_worth_chart.html.erb",
    "content": "<%# locals: (balance_sheet:, period:, **args) %>\n\n<div id=\"net-worth-chart\">\n  <% series = balance_sheet.net_worth_series(period: period) %>\n  <div class=\"flex justify-between gap-4 px-4\">\n    <div class=\"space-y-2\">\n      <div class=\"space-y-2\">\n        <div class=\"flex items-center gap-2\">\n          <p class=\"text-sm text-secondary font-medium\"><%= t(\".title\") %></p>\n        </div>\n\n        <p class=\"text-primary -space-x-0.5 text-3xl font-medium <%= \"animate-pulse\" if balance_sheet.syncing? %>\">\n          <%= series.trend.current.format %>\n        </p>\n\n        <% if series.trend.nil? %>\n          <p class=\"text-sm text-secondary\"><%= t(\".data_not_available\") %></p>\n        <% else %>\n          <%= render partial: \"shared/trend_change\", locals: { trend: series.trend, comparison_label: period.comparison_label } %>\n        <% end %>\n      </div>\n    </div>\n\n    <%= form_with url: root_path, method: :get, data: { controller: \"auto-submit-form\" } do |form| %>\n      <%= form.select :period,\n                    Period.as_options,\n                    { selected: period.key },\n                    data: { \"auto-submit-form-target\": \"auto\" },\n                    class: \"bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0\" %>\n    <% end %>\n  </div>\n\n  <% if series.any? %>\n    <div\n    id=\"netWorthChart\"\n    class=\"w-full flex-1 min-h-52\"\n    data-controller=\"time-series-chart\"\n    data-time-series-chart-data-value=\"<%= series.to_json %>\"></div>\n  <% else %>\n    <div class=\"w-full h-full flex items-center justify-center\">\n      <p class=\"text-secondary text-sm\"><%= t(\".data_not_available\") %></p>\n    </div>\n  <% end %>\n\n</div>\n"
  },
  {
    "path": "app/views/pages/dashboard/_no_accounts_graph_placeholder.html.erb",
    "content": "<div class=\"h-[300px] lg:h-[340px] bg-container shadow-border-xs rounded-xl py-4 flex flex-col items-center justify-center\">\n  <div class=\"space-y-3 text-center flex flex-col items-center\">\n    <%= render DS::FilledIcon.new(\n      variant: :container,\n      icon: \"layers\",\n    ) %>\n\n    <p class=\"text-sm font-medium text-primary\">No accounts yet</p>\n    <p class=\"text-secondary text-sm\">Add accounts to display net worth data</p>\n    <%= render DS::Link.new(\n      text: \"Add account\",\n      icon: \"plus\",\n      href: new_account_path,\n      frame: :modal\n    ) %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/pages/dashboard.html.erb",
    "content": "<% content_for :page_header do %>\n  <% unless Current.family.self_hoster? %>\n    <div class=\"bg-gray-100 mb-4 rounded-xl p-4 flex gap-2 items-start\">\n      <%= icon \"triangle-alert\", color: \"warning\" %>\n      <div class=\"text-sm space-y-2\">\n        <p class=\"font-medium\">We've made a tough decision to shut down the hosted version of Maybe. Here's what's happening next:</p>\n        <ul class=\"list-disc list-inside space-y-1 ml-2\">\n          <li><%= link_to \"Read why we're doing this here\", \"https://x.com/Shpigford/status/1947725345244709240\", class: \"underline\" %></li>\n          <li>You will be refunded in full.</li>\n          <li>You have until July 31, 2025 to export your data. You can do that <%= link_to \"here\", settings_profile_path, class: \"underline\" %>.</li>\n        </ul>\n      </div>\n    </div>\n  <% end %>\n\n  <div class=\"space-y-1 mb-6 flex gap-4 justify-between items-center lg:items-start\">\n\n    <div class=\"space-y-1\">\n      <h1 class=\"text-xl lg:text-3xl font-medium text-primary\">Welcome back, <%= Current.user.first_name %></h1>\n      <p class=\"text-sm lg:text-base text-secondary\">Here's what's happening with your finances</p>\n    </div>\n\n    <%= render DS::Link.new(\n      icon: \"plus\",\n      text: \"New\",\n      href: new_account_path,\n      frame: :modal,\n      class: \"hidden lg:inline-flex\"\n    ) %>\n\n    <%= render DS::Link.new(\n        variant: \"icon-inverse\",\n        icon: \"plus\",\n        href: new_account_path,\n        frame: :modal,\n        class: \"rounded-full lg:hidden\"\n      ) %>\n  </div>\n<% end %>\n\n<div class=\"w-full space-y-6 pb-24\">\n  <% if Current.family.accounts.any? %>\n    <section class=\"bg-container py-4 rounded-xl shadow-border-xs\">\n      <%= render partial: \"pages/dashboard/net_worth_chart\", locals: {\n        balance_sheet: @balance_sheet,\n        period: @period\n      } %>\n    </section>\n    <section>\n      <%= render \"pages/dashboard/balance_sheet\", balance_sheet: @balance_sheet %>\n    </section>\n\n    <%= turbo_frame_tag \"cashflow_sankey_section\" do %>\n      <section class=\"bg-container py-4 rounded-xl shadow-border-xs\">\n        <%= render partial: \"pages/dashboard/cashflow_sankey\", locals: {\n          sankey_data: @cashflow_sankey_data,\n          period: @cashflow_period\n        } %>\n      </section>\n    <% end %>\n  <% else %>\n    <section>\n      <%= render \"pages/dashboard/no_accounts_graph_placeholder\" %>\n    </section>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/pages/feedback.html.erb",
    "content": "<%= content_for :page_title, \"Feedback\" %>\n\n<div class=\"bg-container shadow-border-xs rounded-xl p-4\">\n  <h2 class=\"text-lg font-medium text-primary mb-1\">Leave feedback</h2>\n  <p class=\"text-sm text-secondary mb-4\">Let us know if you have any specific feedback. Feel free to include links to videos or screenshots.</p>\n  <div class=\"flex flex-col md:flex-row gap-4\">\n    <%= link_to \"https://github.com/maybe-finance/maybe/discussions/categories/feature-requests\", target: \"_blank\", rel: \"noopener noreferrer\", class: \"w-full md:w-1/3 flex flex-col items-center p-4 border border-alpha-black-25 rounded-xl hover:bg-container-hover\" do %>\n      <%= image_tag \"github-icon.svg\", class: \"w-8 h-8 mb-2\" %>\n      <span class=\"text-sm font-medium text-primary text-center\">Write a feature request</span>\n    <% end %>\n\n    <% if self_hosted? %>\n      <%= link_to \"https://github.com/maybe-finance/maybe/issues/new?assignees=&labels=bug&template=bug_report.md&title=\", target: \"_blank\", rel: \"noopener noreferrer\", class: \"w-full md:w-1/3 flex flex-col items-center p-4 border border-alpha-black-25 rounded-xl hover:bg-container-hover\" do %>\n        <%= image_tag \"github-icon.svg\", class: \"w-8 h-8 mb-2\" %>\n        <span class=\"text-sm font-medium text-primary text-center\">File a bug report</span>\n      <% end %>\n    <% else %>\n      <%= tag.button class: \"w-full md:w-1/3 flex flex-col gap-2 items-center p-4 border border-alpha-black-25 rounded-xl hover:bg-container-hover\", data: { action: \"intercom#show\" } do %>\n        <%= image_tag \"github-icon.svg\", class: \"w-8 h-8 mb-2\" %>\n        <span class=\"text-sm font-medium text-primary text-center\">File a bug report</span>\n      <% end %>\n    <% end %>\n\n    <%= link_to \"https://link.maybe.co/discord\", target: \"_blank\", rel: \"noopener noreferrer\", class: \"w-full md:w-1/3 flex flex-col items-center p-4 border border-alpha-black-25 rounded-xl hover:bg-container-hover\" do %>\n      <%= image_tag \"discord-icon.svg\", class: \"w-8 h-8 mb-2\" %>\n      <span class=\"text-sm font-medium text-primary text-center\">Discuss Maybe with others</span>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/pages/redis_configuration_error.html.erb",
    "content": "<% content_for :title, \"Redis Configuration Required - Maybe\" %>\n\n<div class=\"flex items-center justify-center h-full p-4 sm:p-6 lg:p-8\">\n  <div class=\"w-full max-w-md sm:max-w-lg lg:max-w-2xl\">\n    <div class=\"bg-container border border-primary rounded-xl p-6 sm:p-8 shadow-sm\">\n      <!-- Icon and Header -->\n      <div class=\"text-center mb-8\">\n        <div class=\"mx-auto w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4\">\n          <%= icon \"alert-triangle\", class: \"w-8 h-8 text-red-600\" %>\n        </div>\n        <h1 class=\"text-xl sm:text-2xl font-bold text-primary mb-2\">Redis Configuration Required</h1>\n        <p class=\"text-sm sm:text-base text-muted\">Your self-hosted Maybe installation needs Redis to be properly configured.</p>\n      </div>\n\n      <!-- Explanation -->\n      <div class=\"mb-8\">\n        <div class=\"bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6\">\n          <div class=\"flex items-start\">\n            <%= icon \"info\", class: \"w-5 h-5 text-amber-600 mt-0.5 mr-3 flex-shrink-0\" %>\n            <div class=\"text-sm text-amber-800\">\n              <p><strong>Why is Redis required?</strong></p>\n              <p class=\"mt-1\">Maybe uses Redis to power Sidekiq background jobs for tasks like syncing account data, processing imports, and other background operations that keep your financial data up to date.</p>\n            </div>\n          </div>\n        </div>\n\n        <!-- Primary CTA -->\n        <div class=\"text-center space-y-4\">\n          <%= render DS::Link.new(\n            text: \"View Setup Guide\",\n            href: \"https://github.com/maybe-finance/maybe/blob/main/docs/hosting/docker.md\",\n            variant: \"primary\",\n            size: \"lg\",\n            icon: \"external-link\",\n            full_width: true,\n            target: \"_blank\",\n            rel: \"noopener noreferrer\"\n          ) %>\n          <p class=\"text-sm text-muted\">Follow our complete Docker setup guide to configure Redis</p>\n        </div>\n      </div>\n\n      <!-- Secondary CTA -->\n      <div class=\"pt-6 border-t border-primary\">\n        <div class=\"text-center space-y-3\">\n          <p class=\"text-sm text-muted\">Once you've configured Redis, refresh this page to continue.</p>\n          <%= render DS::Button.new(\n            text: \"Refresh Page\",\n            variant: \"secondary\",\n            icon: \"refresh-cw\",\n            type: \"button\",\n            full_width: true,\n            onclick: \"window.location.reload()\"\n          ) %>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/password_mailer/password_reset.html.erb",
    "content": "<p><%= t(\".request_made\") %></p>\n\n<p><%= link_to t(\".cta\"), edit_password_reset_url(token: params[:token]) %></p>\n\n<p><%= t(\".ignore_if_not_requested\") %></p>\n"
  },
  {
    "path": "app/views/password_resets/edit.html.erb",
    "content": "<%\n  header_title t(\".title\")\n%>\n\n<%= styled_form_with model: @user, url: password_reset_path(token: params[:token]), method: :patch, class: \"space-y-4\" do |form| %>\n  <%= form.password_field :password, required: true, label: true %>\n  <%= form.password_field :password_confirmation, required: true, label: true %>\n\n  <%= form.submit %>\n<% end %>\n"
  },
  {
    "path": "app/views/password_resets/new.html.erb",
    "content": "<% header_title t(\".title\") %>\n\n<% if params[:step] == \"pending\" %>\n  <p class=\"text-sm text-secondary text-center\"><%= t(\".requested\") %></p>\n<% else %>\n  <%= styled_form_with url: password_reset_path, class: \"space-y-4\" do |form| %>\n    <%= form.email_field :email, label: true, autofocus: false, autocomplete: \"email\", required: \"required\", placeholder: \"you@example.com\" %>\n\n    <%= form.submit t(\".submit\") %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/passwords/edit.html.erb",
    "content": "<h1><% t(\".title\") %></h1>\n\n<%= styled_form_with model: Current.user, url: password_path, class: \"space-y-4\" do |form| %>\n  <div>\n    <%= form.label :password_challenge, t(\".password_challenge\") %>\n    <%= form.password_field :password_challenge %>\n  </div>\n\n  <div>\n    <%= form.label :password, t(\".password\") %>\n    <%= form.password_field :password %>\n  </div>\n\n  <div>\n    <%= form.label :password_confirmation %>\n    <%= form.password_field :password_confirmation %>\n  </div>\n\n  <div>\n    <%= form.submit t(\".submit\") %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/plaid_items/_auto_link_opener.html.erb",
    "content": "<%# locals: (link_token:, region:, item_id:, is_update: false) %>\n\n<%= tag.div data: {\n              controller: \"plaid\",\n              plaid_link_token_value: link_token,\n              plaid_region_value: region,\n              plaid_item_id_value: item_id,\n              plaid_is_update_value: is_update\n            } %>\n"
  },
  {
    "path": "app/views/plaid_items/_plaid_item.html.erb",
    "content": "<%# locals: (plaid_item:) %>\n\n<%= tag.div id: dom_id(plaid_item) do %>\n  <details open class=\"group bg-container p-4 shadow-border-xs rounded-xl\">\n    <summary class=\"flex items-center justify-between gap-2 focus-visible:outline-hidden\">\n      <div class=\"flex items-center gap-2\">\n        <%= icon \"chevron-right\", class: \"group-open:transform group-open:rotate-90\" %>\n\n        <div class=\"flex items-center justify-center h-8 w-8 bg-blue-600/10 rounded-full\">\n          <% if plaid_item.logo.attached? %>\n            <%= image_tag plaid_item.logo, class: \"rounded-full h-full w-full\", loading: \"lazy\" %>\n          <% else %>\n            <div class=\"flex items-center justify-center\">\n              <%= tag.p plaid_item.name.first.upcase, class: \"text-blue-600 text-xs font-medium\" %>\n            </div>\n          <% end %>\n        </div>\n\n        <div class=\"pl-1 text-sm\">\n          <div class=\"flex items-center gap-2\">\n            <%= tag.p plaid_item.name, class: \"font-medium text-primary\" %>\n            <% if plaid_item.scheduled_for_deletion? %>\n              <p class=\"text-destructive text-sm animate-pulse\">(deletion in progress...)</p>\n            <% end %>\n          </div>\n          <% if plaid_item.syncing? %>\n            <div class=\"text-secondary flex items-center gap-1\">\n              <%= icon \"loader\", size: \"sm\", class: \"animate-pulse\" %>\n              <%= tag.span t(\".syncing\") %>\n            </div>\n          <% elsif plaid_item.requires_update? %>\n            <div class=\"text-warning flex items-center gap-1\">\n              <%= icon \"alert-triangle\", size: \"sm\", color: \"warning\" %>\n              <%= tag.span t(\".requires_update\") %>\n            </div>\n          <% elsif plaid_item.sync_error.present? %>\n            <div class=\"text-secondary flex items-center gap-1\">\n              <%= icon \"alert-circle\", size: \"sm\", color: \"destructive\" %>\n              <%= tag.span t(\".error\"), class: \"text-destructive\" %>\n            </div>\n          <% else %>\n            <p class=\"text-secondary\">\n              <%= plaid_item.last_synced_at ? t(\".status\", timestamp: time_ago_in_words(plaid_item.last_synced_at)) : t(\".status_never\") %>\n            </p>\n          <% end %>\n        </div>\n      </div>\n\n      <div class=\"flex items-center gap-2\">\n        <% if plaid_item.requires_update? %>\n          <%= render DS::Link.new(\n            text: t(\".update\"),\n            icon: \"refresh-cw\",\n            variant: \"secondary\",\n            href: edit_plaid_item_path(plaid_item),\n            frame: \"modal\"\n          ) %>\n        <% elsif Rails.env.development? %>\n          <%= icon(\n            \"refresh-cw\",\n            as_button: true,\n            href: sync_plaid_item_path(plaid_item)\n          ) %>\n        <% end %>\n\n        <%= render DS::Menu.new do |menu| %>\n          <% menu.with_item(\n            variant: \"button\",\n            text: t(\".delete\"),\n            icon: \"trash-2\",\n            href: plaid_item_path(plaid_item),\n            method: :delete,\n            confirm: CustomConfirm.for_resource_deletion(plaid_item.name, high_severity: true)\n          ) %>\n        <% end %>\n      </div>\n    </summary>\n\n    <% unless plaid_item.scheduled_for_deletion? %>\n      <div class=\"space-y-4 mt-4\">\n        <% if plaid_item.accounts.any? %>\n          <%= render \"accounts/index/account_groups\", accounts: plaid_item.accounts %>\n        <% else %>\n          <div class=\"p-4 flex flex-col gap-3 items-center justify-center\">\n            <p class=\"text-primary font-medium text-sm\"><%= t(\".no_accounts_title\") %></p>\n            <p class=\"text-secondary text-sm\"><%= t(\".no_accounts_description\") %></p>\n          </div>\n        <% end %>\n      </div>\n    <% end %>\n  </details>\n<% end %>\n"
  },
  {
    "path": "app/views/plaid_items/edit.html.erb",
    "content": "<%# We render this in the empty modal frame so if Plaid flow is closed, user stays on same page they were on %>\n<%= turbo_frame_tag \"modal\" do %>\n  <%= render \"plaid_items/auto_link_opener\",\n           link_token: @link_token,\n           region: @plaid_item.plaid_region,\n           item_id: @plaid_item.id,\n           is_update: true %>\n<% end %>\n"
  },
  {
    "path": "app/views/plaid_items/new.html.erb",
    "content": "<%# We render this in the empty modal frame so if Plaid flow is closed, user stays on same page they were on %>\n<%= turbo_frame_tag \"modal\" do %>\n  <%= render \"plaid_items/auto_link_opener\",\n           link_token: @link_token,\n           region: params[:region],\n           item_id: \"\",\n           is_update: false %>\n<% end %>\n"
  },
  {
    "path": "app/views/properties/_form.html.erb",
    "content": "<%# locals: (account:, url:) %>\n\n<%= render \"accounts/form\", account: account, url: url do |form| %>\n  <%= form.select :subtype,\n                 Property::SUBTYPES.map { |k, v| [v[:long], k] },\n                 { label: true, prompt: t(\"properties.form.subtype_prompt\"), include_blank: t(\"properties.form.none\") } %>\n\n  <%= render \"shared/ruler\", classes: \"my-4\" %>\n\n  <div class=\"space-y-2\">\n    <%= form.fields_for :accountable do |property_form| %>\n      <div class=\"flex items-center gap-2\">\n        <%= property_form.number_field :year_built,\n                                       label: t(\"properties.form.year_built\"),\n                                       placeholder: t(\"properties.form.year_built_placeholder\"),\n                                       min: 1800,\n                                       max: Time.current.year %>\n      </div>\n\n      <div class=\"flex items-center gap-2\">\n        <%= property_form.number_field :area_value,\n                                       label: t(\"properties.form.area\"),\n                                       placeholder: t(\"properties.form.area_placeholder\"),\n                                       min: 0 %>\n        <%= property_form.select :area_unit,\n                                 [[\"Square Feet\", \"sqft\"], [\"Square Meters\", \"sqm\"]],\n                                 { label: t(\"properties.form.area_unit\") } %>\n      </div>\n\n      <%= property_form.fields_for :address do |address_form| %>\n        <%= address_form.text_field :line1,\n                                    label: t(\"properties.form.address_line1\"),\n                                    placeholder: t(\"properties.form.address_line1_placeholder\") %>\n        <div class=\"flex items-center gap-2\">\n          <%= address_form.text_field :locality,\n                                    label: t(\"properties.form.locality\"),\n                                    placeholder: t(\"properties.form.locality_placeholder\") %>\n          <%= address_form.text_field :region,\n                                    label: t(\"properties.form.region\"),\n                                    placeholder: t(\"properties.form.region_placeholder\") %>\n        </div>\n\n        <div class=\"flex items-center gap-2\">\n          <%= address_form.text_field :postal_code,\n                                      label: t(\"properties.form.postal_code\"),\n                                      placeholder: t(\"properties.form.postal_code_placeholder\") %>\n          <%= address_form.text_field :country,\n                                      label: t(\"properties.form.country\"),\n                                      placeholder: t(\"properties.form.country_placeholder\") %>\n        </div>\n      <% end %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/properties/_form_alert.html.erb",
    "content": "<%# locals: (notice: nil, error: nil) %>\n\n<% if notice.present? %>\n  <%= render DS::Alert.new(message: notice, variant: :success) %>\n<% elsif error.present? %>\n  <%= render DS::Alert.new(message: error, variant: :error) %>\n<% end %>\n"
  },
  {
    "path": "app/views/properties/_form_tab.html.erb",
    "content": "<%# locals: (label:, href: nil, active: false) %>\n\n<% classes = class_names(\n  \"flex items-center px-3 py-2 rounded-lg text-sm font-medium\",\n  active ? \"bg-surface-inset text-primary\" : \"text-secondary\",\n) %>\n\n<% if href.present? %>\n  <%= link_to href, data: { turbo_frame: :modal }, class: class_names(classes, \"cursor-pointer hover:bg-surface-inset-hover hover:text-primary\") do %>\n    <%= label %>\n  <% end %>\n<% else %>\n  <%= tag.span class: classes do %>\n    <%= label %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/properties/_form_tabs.html.erb",
    "content": "<%# locals: (account:, active_tab:) %>\n\n<div class=\"flex flex-col gap-0.5 w-[156px] shrink-0\">\n  <%= render \"properties/form_tab\", label: \"Overview\", href: account.new_record? ? nil : edit_property_path(@account), active: active_tab == \"overview\" %>\n  <%= render \"properties/form_tab\", label: \"Value\", href: account.new_record? ? nil : balances_property_path(@account), active: active_tab == \"value\" %>\n  <%= render \"properties/form_tab\", label: \"Address\", href: account.new_record? ? nil : address_property_path(@account), active: active_tab == \"address\" %>\n</div>\n"
  },
  {
    "path": "app/views/properties/_overview_fields.html.erb",
    "content": "<%# locals: (form:) %>\n\n<div class=\"flex flex-col gap-2\">\n  <%= form.text_field :name,\n                  label: \"Name\",\n                  placeholder: \"Vacation home\",\n                  required: true %>\n\n  <%= form.select :subtype,\n                Property::SUBTYPES.map { |k, v| [v[:long], k] },\n                { prompt: \"Select type\", label: \"Property type\" }, required: true %>\n\n  <%= form.hidden_field :accountable_type, value: \"Property\" %>\n\n  <%= form.fields_for :accountable do |property_form| %>\n    <div class=\"flex items-center gap-2\">\n      <%= property_form.number_field :year_built,\n                    label: \"Year Built (optional)\",\n                    placeholder: \"1990\",\n                    min: 1800,\n                    max: Time.current.year %>\n    </div>\n\n    <div class=\"flex items-center gap-2\">\n      <%= property_form.number_field :area_value,\n                      label: \"Area (optional)\",\n                      placeholder: \"1200\",\n                      min: 0 %>\n      <%= property_form.select :area_unit,\n                      [[\"Square Feet\", \"sqft\"], [\"Square Meters\", \"sqm\"]],\n                      { label: \"Area Unit\" } %>\n    </div>\n  <% end %>\n\n</div>\n"
  },
  {
    "path": "app/views/properties/address.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: \"Enter property manually\") %>\n  <% dialog.with_body do %>\n    <div class=\"flex gap-4\">\n      <!-- Left sidebar with tabs -->\n      <%= render \"properties/form_tabs\", account: @account, active_tab: \"address\" %>\n\n      <!-- Right content area with form -->\n      <div class=\"flex-1\">\n        <%= styled_form_with model: @property, url: update_address_property_path(@account), method: :patch, data: { turbo_frame: @property.address.persisted? ? nil : :_top } do |form| %>\n          <div class=\"flex flex-col gap-2 min-h-[320px]\">\n            <%= render \"properties/form_alert\", notice: @success_message, error: @error_message %>\n\n            <%= form.fields_for :address do |address_form| %>\n              <%= address_form.text_field :line1,\n                    label: \"Address Line 1\",\n                    placeholder: \"123 Main Street\" %>\n\n              <div class=\"flex items-center gap-2\">\n                <%= address_form.text_field :locality,\n                      label: \"City\",\n                      placeholder: \"San Francisco\" %>\n                <%= address_form.text_field :region,\n                      label: \"State/Region\",\n                      placeholder: \"CA\" %>\n              </div>\n\n              <div class=\"flex items-center gap-2\">\n                <%= address_form.text_field :postal_code,\n                      label: \"Postal Code\",\n                      placeholder: \"12345\" %>\n                <%= address_form.text_field :country,\n                      label: \"Country\",\n                      placeholder: \"USA\" %>\n              </div>\n            <% end %>\n          </div>\n\n          <!-- Save button -->\n          <div class=\"flex justify-end mt-4\">\n            <%= render DS::Button.new(\n              text: \"Save\",\n              variant: \"primary\",\n            ) %>\n          </div>\n        <% end %>\n      </div>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/properties/balances.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: \"Enter property manually\") %>\n  <% dialog.with_body do %>\n    <div class=\"flex gap-4\">\n      <%= render \"properties/form_tabs\", account: @account, active_tab: \"value\" %>\n\n      <!-- Right content area with form -->\n      <div class=\"flex-1\">\n        <%= styled_form_with model: @account, url: update_balances_property_path(@account), method: :patch do |form| %>\n          <div class=\"flex flex-col gap-4 min-h-[320px]\">\n            <%= render \"properties/form_alert\", notice: @success_message, error: @error_message %>\n\n            <%= form.money_field :balance,\n                label: \"Estimated market value\",\n                label_tooltip: \"The estimated market value of your property. This number can often be found on sites like Zillow or Redfin, and is never an exact number.\",\n                placeholder: \"0\" %>\n          </div>\n\n          <!-- Next button -->\n          <div class=\"flex justify-end mt-4\">\n            <%= render DS::Button.new(\n              text: @account.active? ? \"Save\" : \"Next\",\n              variant: \"primary\",\n            ) %>\n          </div>\n        <% end %>\n      </div>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/properties/edit.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: \"Enter property manually\") %>\n  <% dialog.with_body do %>\n    <div class=\"flex gap-4\">\n      <!-- Left sidebar with tabs -->\n      <%= render \"properties/form_tabs\", account: @account, active_tab: \"overview\" %>\n\n      <!-- Right content area with form -->\n      <div class=\"flex-1\">\n        <%= styled_form_with model: @account, url: property_path(@account), method: :patch do |form| %>\n          <div class=\"flex flex-col gap-2 min-h-[320px]\">\n            <%= render \"properties/form_alert\", notice: @success_message, error: @error_message %>\n            <%= render \"properties/overview_fields\", form: form %>\n          </div>\n\n          <!-- Save button -->\n          <div class=\"flex justify-end mt-4\">\n            <%= render DS::Button.new(\n              text: @account.active? ? \"Save\" : \"Next\",\n              variant: \"primary\",\n            ) %>\n          </div>\n        <% end %>\n      </div>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/properties/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: \"Enter property manually\") %>\n  <% dialog.with_body do %>\n    <div class=\"flex gap-4\">\n      <!-- Left sidebar with tabs -->\n      <%= render \"properties/form_tabs\", account: @account, active_tab: \"overview\" %>\n\n      <!-- Right content area with form -->\n      <div class=\"flex-1\">\n        <%= styled_form_with model: @account, url: properties_path do |form| %>\n          <div class=\"flex flex-col gap-2 min-h-[320px]\">\n            <%= render \"properties/form_alert\", notice: @success_message, error: @error_message %>\n            <%= render \"properties/overview_fields\", form: form %>\n          </div>\n\n          <!-- Create button -->\n          <div class=\"flex justify-end mt-4\">\n            <%= render DS::Button.new(\n              text: \"Next\",\n              variant: \"primary\",\n            ) %>\n          </div>\n        <% end %>\n      </div>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/properties/tabs/_overview.html.erb",
    "content": "<%# locals: (account:) %>\n\n<div class=\"grid grid-cols-3 gap-2\">\n  <%= summary_card title: t(\".market_value\") do %>\n    <%= format_money(account.balance_money) %>\n  <% end %>\n\n  <%= summary_card title: t(\".purchase_price\") do %>\n    <%= account.property.purchase_price ? format_money(account.property.purchase_price) : t(\".unknown\") %>\n  <% end %>\n\n  <%= summary_card title: t(\".trend\") do %>\n    <div class=\"flex items-center gap-1\" style=\"color: <%= account.property.trend.color %>\">\n      <p class=\"text-xl font-medium\">\n        <%= account.property.trend.value %>\n      </p>\n\n      <p>(<%= account.property.trend.percent %>%)</p>\n    </div>\n  <% end %>\n\n  <%= summary_card title: t(\".year_built\") do %>\n    <%= account.property.year_built || t(\".unknown\") %>\n  <% end %>\n\n  <%= summary_card title: t(\".living_area\") do %>\n    <%= account.property.area || t(\".unknown\") %>\n  <% end %>\n</div>\n\n<div class=\"flex justify-center py-8\">\n  <%= render DS::Link.new(\n    text: \"Edit account details\",\n    href: edit_property_path(account),\n    variant: \"ghost\",\n    frame: :modal\n  ) %>\n</div>\n"
  },
  {
    "path": "app/views/pwa/manifest.json.erb",
    "content": "{\n  \"name\": \"Maybe\",\n  \"short_name\": \"Maybe\",\n  \"icons\": [\n    {\n      \"src\": \"/logo-pwa.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"512x512\"\n    },\n    {\n      \"src\": \"/logo-pwa.png\",\n      \"type\": \"image/png\",\n      \"sizes\": \"512x512\",\n      \"purpose\": \"maskable\"\n    }\n  ],\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"display_override\": [\"fullscreen\", \"minimal-ui\"],\n  \"scope\": \"/\",\n  \"description\": \"Maybe is your personal finance assistant.\",\n  \"theme_color\": \"#F9F9F9\",\n  \"background_color\": \"#F9F9F9\"\n}\n"
  },
  {
    "path": "app/views/pwa/service-worker.js",
    "content": "// Add a service worker for processing Web Push notifications:\n//\n// self.addEventListener(\"push\", async (event) => {\n//   const { title, options } = await event.data.json()\n//   event.waitUntil(self.registration.showNotification(title, options))\n// })\n// \n// self.addEventListener(\"notificationclick\", function(event) {\n//   event.notification.close()\n//   event.waitUntil(\n//     clients.matchAll({ type: \"window\" }).then((clientList) => {\n//       for (let i = 0; i < clientList.length; i++) {\n//         let client = clientList[i]\n//         let clientPath = (new URL(client.url)).pathname\n// \n//         if (clientPath == event.notification.data.path && \"focus\" in client) {\n//           return client.focus()\n//         }\n//       }\n// \n//       if (clients.openWindow) {\n//         return clients.openWindow(event.notification.data.path)\n//       }\n//     })\n//   )\n// })\n"
  },
  {
    "path": "app/views/registrations/new.html.erb",
    "content": "<%\n  header_title @invitation ? t(\".join_family_title\", family: @invitation.family.name) : t(\".title\")\n%>\n\n<% if self_hosted_first_login? %>\n  <div class=\"fixed inset-0 w-full h-fit bg-container p-5 border-b border-secondary flex flex-col gap-3 items-center text-center mb-12\">\n    <h2 class=\"font-bold text-primary text-xl\"><%= t(\".welcome_title\") %></h2>\n    <p class=\"text-secondary text-secondary text-sm\"><%= t(\".welcome_body\") %></p>\n  </div>\n<% elsif @invitation %>\n  <div class=\"space-y-1 mb-6 text-center\">\n    <p class=\"text-secondary\">\n      <%= t(\".invitation_message\",\n            inviter: @invitation.inviter.display_name,\n            role: t(\".role_#{@invitation.role}\")) %>\n    </p>\n  </div>\n<% end %>\n\n<% if @user.errors.present? %>\n  <div class=\"text-red-600 flex items-center gap-2\">\n    <%= icon(\"circle-alert\") %>\n    <p class=\"text-sm\"><%= @user.errors.full_messages.to_sentence %></p>\n  </div>\n<% end %>\n\n<%= styled_form_with model: @user, url: registration_path, class: \"space-y-4\" do |form| %>\n  <%= form.email_field :email,\n      autofocus: false,\n      autocomplete: \"email\",\n      required: \"required\",\n      placeholder: \"you@example.com\",\n      label: true,\n      disabled: @invitation.present? %>\n\n  <% if invite_code_required? && !@invitation %>\n    <%= form.text_field :invite_code, required: \"required\", label: true, value: params[:invite] %>\n  <% end %>\n\n  <%= form.hidden_field :invitation, value: @invitation&.token %>\n\n  <div data-controller=\"password-validator\">\n    <div data-controller=\"password-visibility\" class=\"relative\">\n      <%= form.password_field :password,\n          autocomplete: \"new-password\",\n          required: \"required\",\n          placeholder: t(\".password_placeholder\"),\n          label: true,\n          maxlength: 72,\n          data: {\n            password_validator_target: \"input\",\n            password_visibility_target: \"input\",\n            action: \"input->password-validator#validate\"\n          } %>\n      <button type=\"button\"\n              class=\"absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 focus:outline-none\"\n              data-action=\"click->password-visibility#toggle\">\n        <div data-password-visibility-target=\"showIcon\">\n          <%= icon(\"eye\") %>\n        </div>\n        <div data-password-visibility-target=\"hideIcon\">\n          <%= icon(\"eye-off\") %>\n        </div>\n      </button>\n    </div>\n\n    <div class=\"flex gap-4 my-4\">\n      <div class=\"h-1 bg-gray-200 rounded-full flex-grow\" data-password-validator-target=\"blockLine\" data-requirement-type=\"length\"></div>\n      <div class=\"h-1 bg-gray-200 rounded-full flex-grow\" data-password-validator-target=\"blockLine\" data-requirement-type=\"case\"></div>\n      <div class=\"h-1 bg-gray-200 rounded-full flex-grow\" data-password-validator-target=\"blockLine\" data-requirement-type=\"number\"></div>\n      <div class=\"h-1 bg-gray-200 rounded-full flex-grow\" data-password-validator-target=\"blockLine\" data-requirement-type=\"special\"></div>\n    </div>\n\n    <div class=\"space-y-1 my-4\">\n      <div class=\"flex items-center gap-2 text-secondary text-sm\" data-password-validator-target=\"requirementType\" data-requirement-type=\"length\">\n        <%= icon(\"check\", size: \"sm\") %>\n        <span>Minimum 8 characters</span>\n      </div>\n      <div class=\"flex items-center gap-2 text-secondary text-sm\" data-password-validator-target=\"requirementType\" data-requirement-type=\"case\">\n        <%= icon(\"check\", size: \"sm\") %>\n        <span>Upper and lowercase letters</span>\n      </div>\n      <div class=\"flex items-center gap-2 text-secondary text-sm\" data-password-validator-target=\"requirementType\" data-requirement-type=\"number\">\n        <%= icon(\"check\", size: \"sm\") %>\n        <span>A number (0-9)</span>\n      </div>\n      <div class=\"flex items-center gap-2 text-secondary text-sm\" data-password-validator-target=\"requirementType\" data-requirement-type=\"special\">\n        <%= icon(\"check\", size: \"sm\") %>\n        <span>A special character (!, @, #, $, %, etc)</span>\n      </div>\n    </div>\n  </div>\n\n  <%= form.submit t(\".submit\") %>\n<% end %>\n"
  },
  {
    "path": "app/views/rule/actions/_action.html.erb",
    "content": "<%# locals: (form:) %>\n\n<% action = form.object %>\n<% rule = action.rule %>\n\n<li data-controller=\"rule--actions\" data-rule--actions-action-executors-value=\"<%= rule.action_executors.to_json %>\" class=\"flex items-center gap-3\">\n  <%= form.hidden_field :_destroy, value: false, data: { rule__actions_target: \"destroyField\" } %>\n\n  <div class=\"grow flex gap-2 items-center h-full\">\n    <div class=\"grow\">\n      <%= form.select :action_type, rule.action_executors.map { |executor| [ executor.label, executor.key ] }, {}, data: { action: \"rule--actions#handleActionTypeChange\" } %>\n    </div>\n\n    <%= tag.div class: class_names(\"min-w-1/2 flex items-center gap-2\"),\n                data: { rule__actions_target: \"actionValue\" } do %>\n      <%# Initial rendering based on rule.action_executors.first from the rule form. %>\n      <%# This is currently always SetTransactionCategory from transaction_resource.rb, which is a select type. %>\n      <%# Subsequent renders are injected by the Stimulus controller, which uses the templates from below. %>\n      <span class=\"font-medium text-primary uppercase text-xs\">to</span>\n      <%= form.select :value, action.options || [], {} %>\n    <% end %>\n  </div>\n\n  <%= icon(\n    \"trash-2\",\n    size: \"sm\",\n    as_button: true,\n    data: { action: \"rule--actions#remove\", rule__actions_destroy_param: action.persisted? }) %>\n\n  <%# Templates for different input types - these will be cloned and used by the Stimulus controller %>\n  <template data-rule--actions-target=\"selectTemplate\">\n    <span class=\"font-medium text-primary uppercase text-xs\">to</span>\n    <%= form.select :value, [], {} %>\n  </template>\n\n  <template data-rule--actions-target=\"textTemplate\">\n    <span class=\"font-medium text-primary uppercase text-xs\">to</span>\n    <%= form.text_field :value, placeholder: \"Enter a value\" %>\n  </template>\n\n  <%# The function type doesn't need an input, so no template is required.%>\n</li>\n"
  },
  {
    "path": "app/views/rule/conditions/_condition.html.erb",
    "content": "<%# locals: (form:, show_prefix: true) %>\n\n<% condition = form.object %>\n<% rule = condition.rule %>\n\n<li data-controller=\"rule--conditions\" data-rule--conditions-condition-filters-value=\"<%= rule.condition_filters.to_json %>\" class=\"flex items-center gap-3\">\n\n  <%# Conditionally render the prefix %>\n  <%# Condition groups pass in show_prefix: false for subconditions since the ANY/ALL selector makes that clear %>\n  <% if show_prefix %>\n    <div class=\"pl-2\" data-condition-prefix>\n      <span class=\"font-medium uppercase text-xs\">and</span>\n    </div>\n  <% end %>\n\n  <div class=\"grow flex gap-2 items-center h-full\">\n    <%= form.hidden_field :_destroy, value: false, data: { rule__conditions_target: \"destroyField\" } %>\n\n    <div class=\"w-2/5 shrink-0\">\n      <%= form.select :condition_type, rule.condition_filters.map { |filter| [ filter.label, filter.key ] }, {}, data: { action: \"rule--conditions#handleConditionTypeChange\" } %>\n    </div>\n\n    <%= form.select :operator, condition.operators, { container_class: \"w-fit min-w-36\" }, data: { rule__conditions_target: \"operatorSelect\" } %>\n\n    <div data-rule--conditions-target=\"filterValue\" class=\"grow\">\n      <% if condition.filter.type == \"select\" %>\n        <%= form.select :value, condition.options, {} %>\n      <% else %>\n        <% if condition.filter.type == \"number\" %>\n          <%= form.number_field :value, placeholder: \"10\", step: 0.01 %>\n        <% else %>\n          <%= form.text_field :value, placeholder: \"Enter a value\" %>\n        <% end %>\n      <% end %>\n    </div>\n  </div>\n\n  <%= icon(\n    \"trash-2\",\n    as_button: true,\n    size: \"sm\",\n    data: { action: \"rule--conditions#remove\", rule__conditions_destroy_param: condition.persisted? }\n  ) %>\n</li>\n"
  },
  {
    "path": "app/views/rule/conditions/_condition_group.html.erb",
    "content": "<%# locals: (form:) %>\n\n<% condition = form.object %>\n<% rule = condition.rule %>\n\n<li data-controller=\"rule--conditions\" class=\"border border-secondary rounded-md p-4 space-y-3\">\n\n  <%= form.hidden_field :condition_type, value: \"compound\" %>\n\n  <div class=\"flex items-center justify-between gap-2\">\n    <div class=\"flex items-center gap-2\">\n      <%# Show prefix on condition groups, except the first one %>\n      <div class=\"pl-2\" data-condition-prefix>\n        <span class=\"font-medium uppercase text-xs\">and</span>\n      </div>\n      <p class=\"text-sm text-secondary\">match</p>\n      <%= form.select :operator, [[\"all\", \"and\"], [\"any\", \"or\"]], { container_class: \"w-fit\" }, data: { rules_target: \"operatorField\" } %>\n      <p class=\"text-sm text-secondary\">of the following conditions</p>\n    </div>\n\n    <%= icon(\n      \"trash-2\",\n      as_button: true,\n      size: \"sm\",\n      data: { action: \"rule--conditions#remove\" }\n    ) %>\n  </div>\n\n  <%# Sub-condition template, used by Stimulus controller to add new sub-conditions dynamically %>\n  <template data-rule--conditions-target=\"subConditionTemplate\">\n    <%= form.fields_for :sub_conditions, Rule::Condition.new(parent: condition, condition_type: rule.condition_filters.first.key), child_index: \"IDX_CHILD_PLACEHOLDER\" do |scf| %>\n      <%= render \"rule/conditions/condition\", form: scf, show_prefix: false %>\n    <% end %>\n  </template>\n\n  <ul data-rule--conditions-target=\"subConditionsList\" class=\"space-y-3\">\n    <%= form.fields_for :sub_conditions, condition.sub_conditions.select(&:persisted?) do |scf| %>\n      <%= render \"rule/conditions/condition\", form: scf, show_prefix: false %>\n    <% end %>\n  </ul>\n\n  <%= render DS::Button.new(\n    text: \"Add condition\",\n    leading_icon: \"plus\",\n    variant: \"ghost\",\n    type: \"button\",\n    data: { action: \"rule--conditions#addSubCondition\" }\n  ) %>\n</li>\n"
  },
  {
    "path": "app/views/rules/_category_rule_cta.html.erb",
    "content": "<%# locals: (cta:) %>\n\n<% message = \"Updated to #{cta[:category_name]}\" %>\n<% description = \"You can create a rule to automatically categorize transactions like this one\" %>\n\n<%= render \"shared/notifications/cta\", message: message, description: description do %>\n  <%= form_with model: Current.user, url: rule_prompt_settings_user_path(Current.user), method: :patch do |f| %>\n    <div class=\"flex gap-2 items-center mb-3 -mt-1\">\n      <%= f.check_box :rule_prompts_disabled, class: \"checkbox checkbox--light\" %>\n      <%= f.label :rule_prompts_disabled, \"Don't show this again\", class: \"text-xs text-secondary\" %>\n    </div>\n\n    <%= f.hidden_field :rule_prompt_dismissed_at, value: Time.current %>\n\n    <%= tag.div class:\"flex gap-2 justify-end\" do %>\n      <%= render DS::Button.new(text: \"Dismiss\", variant: \"secondary\") %>\n      <% rule_href = new_rule_path(resource_type: \"transaction\", action_type: \"set_transaction_category\", action_value: cta[:category_id]) %>\n      <%= render DS::Link.new(text: \"Create rule\", variant: \"primary\", href: rule_href, frame: :modal) %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/rules/_form.html.erb",
    "content": "<%# locals: (rule:) %>\n\n<%= styled_form_with model: rule, class: \"space-y-6\",\n                     data: { controller: \"rules\", rule_registry_value: rule.registry.to_json } do |f| %>\n\n  <%= f.hidden_field :resource_type, value: rule.resource_type %>\n\n  <% if @rule.errors.any? %>\n    <%= render \"shared/form_errors\", model: @rule %>\n  <% end %>\n\n  <section class=\"space-y-4\">\n    <div class=\"flex items-center gap-1 text-secondary\">\n      <%= icon \"tag\", size: \"sm\" %>\n      <h3 class=\"text-sm font-medium text-primary\">Rule name (optional)</h3>\n    </div>\n    <div class=\"ml-6\">\n      <%= f.text_field :name, placeholder: \"Enter a name for this rule\", class: \"form-field__input\" %>\n    </div>\n  </section>\n\n  <section class=\"space-y-4\">\n    <div class=\"flex items-center gap-1 text-secondary\">\n      <h3 class=\"text-sm font-medium text-primary\">IF</h3>\n    </div>\n\n    <%# Condition Group template, used by Stimulus controller to add new conditions dynamically %>\n    <template data-rules-target=\"conditionGroupTemplate\">\n      <%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: \"compound\", operator: \"and\"), child_index: \"IDX_PLACEHOLDER\" do |cf| %>\n        <%= render \"rule/conditions/condition_group\", form: cf %>\n      <% end %>\n    </template>\n\n    <%# Condition template, used by Stimulus controller to add new conditions dynamically %>\n    <template data-rules-target=\"conditionTemplate\">\n      <%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: rule.condition_filters.first.key), child_index: \"IDX_PLACEHOLDER\" do |cf| %>\n        <%= render \"rule/conditions/condition\", form: cf %>\n      <% end %>\n    </template>\n\n    <div class=\"ml-6 space-y-4\">\n      <ul data-rules-target=\"conditionsList\" class=\"space-y-3\">\n        <%= f.fields_for :conditions do |cf| %>\n          <% if cf.object.compound? %>\n            <%= render \"rule/conditions/condition_group\", form: cf %>\n          <% else %>\n            <%= render \"rule/conditions/condition\", form: cf %>\n          <% end %>\n        <% end %>\n      </ul>\n\n    <div class=\"flex items-center gap-2\">\n      <%= render DS::Button.new(text: \"Add condition\", icon: \"plus\", variant: \"ghost\", type: \"button\", data: { action: \"rules#addCondition\" }) %>\n      <%= render DS::Button.new(text: \"Add condition group\", icon: \"copy-plus\", variant: \"ghost\", type: \"button\", data: { action: \"rules#addConditionGroup\" }) %>\n    </div>\n  </section>\n\n  <section class=\"space-y-4\">\n    <div class=\"flex items-center gap-1 text-secondary\">\n      <h3 class=\"text-sm font-medium text-primary\">THEN</h3>\n    </div>\n\n    <%# Action template, used by Stimulus controller to add new actions dynamically %>\n    <template data-rules-target=\"actionTemplate\">\n      <%= f.fields_for :actions, Rule::Action.new(rule: rule, action_type: rule.action_executors.first.key), child_index: \"IDX_PLACEHOLDER\" do |af| %>\n        <%= render \"rule/actions/action\", form: af %>\n      <% end %>\n    </template>\n\n    <div class=\"ml-6 space-y-4\">\n      <ul data-rules-target=\"actionsList\" class=\"space-y-3\">\n        <%= f.fields_for :actions do |af| %>\n          <%= render \"rule/actions/action\", form: af %>\n        <% end %>\n      </ul>\n\n    <%= render DS::Button.new(text: \"Add action\", icon: \"plus\", variant: \"ghost\", type: \"button\", data: { action: \"rules#addAction\" }) %>\n  </section>\n\n  <section class=\"space-y-4\">\n    <div class=\"flex items-center gap-1 text-secondary\">\n      <h3 class=\"text-sm font-medium text-primary\">FOR</h3>\n    </div>\n\n    <div class=\"ml-6 space-y-3\">\n      <div class=\"flex items-center gap-2\">\n        <%= f.radio_button :effective_date_enabled, false, checked: rule.effective_date.nil?, data: { action: \"rules#clearEffectiveDate\" } %>\n        <%= f.label :effective_date_enabled_false, \"All past and future #{rule.resource_type}s\", class: \"text-sm text-primary\" %>\n      </div>\n\n      <div class=\"flex items-center gap-2\">\n        <div class=\"flex items-center gap-2\">\n          <%= f.radio_button :effective_date_enabled, true, checked: rule.effective_date.present? %>\n          <%= f.label :effective_date_enabled_true, \"Starting from\", class: \"text-sm text-primary\" %>\n        </div>\n\n        <%= f.date_field :effective_date, container_class: \"w-fit\", data: { rules_target: \"effectiveDateInput\" } %>\n      </div>\n    </div>\n  </section>\n\n  <%= f.submit %>\n<% end %>\n"
  },
  {
    "path": "app/views/rules/_rule.html.erb",
    "content": "<%# locals: (rule:) %>\n<div id=\"<%= dom_id(rule) %>\" class=\"flex justify-between items-center p-4 <%= rule.active? ? \"text-primary\" : \"text-secondary\" %>\">\n\n  <div class=\"text-sm space-y-1.5\">\n    <% if rule.name.present? %>\n      <h3 class=\"font-medium text-md\"><%= rule.name %></h3>\n    <% end %>\n    <% if rule.conditions.any? %>\n      <div class=\"flex items-center gap-2 mt-1\">\n        <div class=\"flex items-center gap-1 text-secondary w-16 shrink-0\">\n          <span class=\"font-mono text-xs\">IF</span>\n        </div>\n        <p class=\"flex items-center flex-wrap gap-1.5 m-0\">\n          <span class=\"px-2 py-1 border border-secondary rounded-full\">\n            <% if rule.conditions.first.compound? %>\n              <%= rule.conditions.first.sub_conditions.first.filter.label %> <%= rule.conditions.first.sub_conditions.first.operator %> <%= rule.conditions.first.sub_conditions.first.value_display %>\n            <% else %>\n              <%= rule.conditions.first.filter.label %> <%= rule.conditions.first.operator %> <%= rule.conditions.first.value_display %>\n            <% end %>\n          </span>\n          <% if rule.conditions.count > 1 %>\n            and <%= rule.conditions.count - 1 %> more <%= rule.conditions.count - 1 == 1 ? \"condition\" : \"conditions\" %>\n          <% end %>\n        </p>\n      </div>\n    <% end %>\n    <div class=\"flex items-center gap-2 mt-1\">\n      <div class=\"flex items-center gap-1 text-secondary w-16 shrink-0\">\n        <span class=\"font-mono text-xs\">THEN</span>\n      </div>\n      <p class=\"flex items-center flex-wrap gap-1.5 m-0\">\n        <span class=\"px-2 py-1 border border-secondary rounded-full\">\n          <% if rule.actions.first.value && rule.actions.first.options %>\n            <%= rule.actions.first.executor.label %> to <%= rule.actions.first.value_display %>\n          <% else %>\n            <%= rule.actions.first.executor.label %>\n          <% end %>\n        </span>\n        <% if rule.actions.count > 1 %>\n          and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? \"action\" : \"actions\" %>\n        <% end %>\n      </p>\n    </div>\n    <div class=\"flex items-center gap-2 mt-1\">\n      <div class=\"flex items-center gap-1 text-secondary w-16 shrink-0\">\n        <span class=\"font-mono text-xs\">FOR</span>\n      </div>\n      <p class=\"flex items-center flex-wrap gap-1.5 m-0\">\n        <span class=\"px-2 py-1 border border-secondary rounded-full\">\n          <% if rule.effective_date.nil? %>\n            All past and future <%= rule.resource_type.pluralize %>\n          <% else %>\n            <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date.strftime(\"%b %-d, %Y\") %>\n          <% end %>\n        </span>\n      </p>\n    </div>\n  </div>\n  <div class=\"flex items-center gap-4\">\n    <%= styled_form_with model: rule, namespace: \"rule_#{rule.id}\", data: { controller: \"auto-submit-form\" } do |f| %>\n      <%= f.toggle :active, { data: { auto_submit_form_target: \"auto\" } } %>\n    <% end %>\n    <%= render DS::Menu.new do |menu| %>\n      <% menu.with_item(variant: \"link\", text: \"Edit\", href: edit_rule_path(rule), icon: \"pencil\", data: { turbo_frame: \"modal\" }) %>\n      <% menu.with_item(variant: \"link\", text: \"Re-apply rule\", href: confirm_rule_path(rule), icon: \"refresh-cw\", data: { turbo_frame: \"modal\" }) %>\n      <% menu.with_item(\n        variant: \"button\",\n        text: \"Delete\",\n        href: rule_path(rule),\n        icon: \"trash-2\",\n        method: :delete,\n        confirm: CustomConfirm.for_resource_deletion(\"rule\")) %>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/rules/confirm.html.erb",
    "content": "<%= render DS::Dialog.new(reload_on_close: true) do |dialog| %>\n  <%\n    title = if @rule.name.present?\n              \"Confirm changes to \\\"#{@rule.name}\\\"\"\n            else\n              \"Confirm changes\"\n            end\n  %>\n  <% dialog.with_header(title: title) %>\n\n  <% dialog.with_body do %>\n    <p class=\"text-secondary text-sm mb-4\">\n      You are about to apply this rule to\n      <span class=\"text-primary font-medium\"><%= @rule.affected_resource_count %> <%= @rule.resource_type.pluralize %></span>\n      that meet the specified rule criteria.  Please confirm if you wish to proceed with this change.\n    </p>\n\n    <%= render DS::Button.new(\n      text: \"Confirm changes\",\n      href: apply_rule_path(@rule),\n      method: :post,\n      full_width: true,\n      data: { turbo_frame: \"_top\" }) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/rules/edit.html.erb",
    "content": "<%= link_to \"Back to rules\", rules_path %>\n\n<%= render DS::Dialog.new do |dialog| %>\n  <%\n    title = if @rule.name.present?\n              \"Edit #{@rule.resource_type} rule \\\"#{@rule.name}\\\"\"\n            else\n              \"Edit #{@rule.resource_type} rule\"\n            end\n  %>\n  <% dialog.with_header(title: title) %>\n\n  <% dialog.with_body do %>\n    <%= render \"rules/form\", rule: @rule %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/rules/index.html.erb",
    "content": "<header class=\"flex items-center justify-between\">\n  <h1 class=\"text-primary text-xl font-medium\">Rules</h1>\n  <div class=\"flex items-center gap-2\">\n    <% if @rules.any? %>\n      <%= render DS::Menu.new do |menu| %>\n        <% menu.with_item(\n          variant: \"button\",\n          text: \"Delete all rules\",\n          href: destroy_all_rules_path,\n          icon: \"trash-2\",\n          method: :delete,\n          confirm: CustomConfirm.for_resource_deletion(\"all rules\", high_severity: true)) %>\n      <% end %>\n    <% end %>\n    <%= render DS::Link.new(\n      text: \"New rule\",\n      variant: \"primary\",\n      href: new_rule_path(resource_type: \"transaction\"),\n      icon: \"plus\",\n      frame: :modal\n    ) %>\n  </div>\n</header>\n<% if self_hosted? %>\n  <div class=\"flex items-center gap-2 mb-2 py-4\">\n    <%= icon(\"circle-alert\", size: \"sm\") %>\n    <p class=\"text-sm text-secondary\">\n      AI-enabled rule actions will cost money.  Be sure to filter as narrowly as possible to avoid unnecessary costs.\n    </p>\n  </div>\n<% end %>\n<div class=\"bg-container rounded-xl shadow-border-xs p-4\">\n  <% if @rules.any? %>\n    <div class=\"bg-container-inset rounded-xl\">\n      <div class=\"flex justify-between px-4 py-2 text-xs uppercase\">\n        <div class=\"flex items-center gap-1.5 font-medium text-secondary\">\n          <p>Rules</p>\n          <span class=\"text-subdued\">&middot;</span>\n          <p><%= @rules.count %></p>\n        </div>\n        <div class=\"flex items-center gap-1\">\n          <span class=\"text-secondary\">Sort by:</span>\n          <%= form_with url: rules_path, method: :get, local: true, class: \"flex items-center\", data: { controller: \"auto-submit-form\" } do |form| %>\n            <%= form.select :sort_by,\n                      options_for_select([[\"Name\", \"name\"], [\"Updated At\", \"updated_at\"]], @sort_by),\n                      {},\n                      class: \"min-w-[120px] bg-transparent rounded border-none cursor-pointer text-primary uppercase text-xs w-auto\",\n                      data: { auto_submit_form_target: \"auto\", autosubmit_trigger_event: \"change\" } %>\n            <%= form.hidden_field :direction, value: @direction %>\n          <% end %>\n          <%= render DS::Link.new(\n            href: rules_path(direction: @direction == \"asc\" ? \"desc\" : \"asc\", sort_by: @sort_by),\n            variant: \"icon\",\n            icon: \"arrow-up-down\",\n            size: :sm,\n            title: \"Toggle sort direction\"\n          ) %>\n        </div>\n      </div>\n      <div class=\"p-1\">\n        <div class=\"flex flex-col bg-container rounded-lg shadow-border-xs\">\n          <%= render partial: \"rule\", collection: @rules, spacer_template: \"shared/ruler\" %>\n        </div>\n      </div>\n    </div>\n  <% else %>\n    <div class=\"flex justify-center items-center py-20\">\n      <div class=\"text-center flex flex-col items-center max-w-[500px]\">\n        <p class=\"text-sm text-primary font-medium mb-1\">No rules yet</p>\n        <p class=\"text-sm text-secondary mb-4\">Set up rules to perform actions to your transactions and other data on every account sync.</p>\n        <div class=\"flex items-center gap-2\">\n          <%= render DS::Link.new(\n            text: \"New rule\",\n            variant: \"primary\",\n            href: new_rule_path(resource_type: \"transaction\"),\n            icon: \"plus\",\n            frame: :modal\n          ) %>\n        </div>\n      </div>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/rules/new.html.erb",
    "content": "<%= link_to \"Back to rules\", rules_path %>\n\n<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: \"New #{@rule.resource_type} rule\") %>\n  <% dialog.with_body do %>\n    <%= render \"rules/form\", rule: @rule %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/securities/_combobox_security.turbo_stream.erb",
    "content": "<div class=\"flex items-center\">\n  <%= image_tag(combobox_security.logo_url, class: \"rounded-full h-8 w-8 inline-block mr-2\" ) %>\n  <div class=\"flex items-center justify-between w-full\">\n    <div class=\"flex flex-col\">\n      <span class=\"text-sm font-medium\">\n        <%= combobox_security.name.presence || combobox_security.symbol %>\n      </span>\n      <span class=\"text-xs text-secondary\">\n        <%= \"#{combobox_security.symbol} (#{combobox_security.exchange_operating_mic})\" %>\n      </span>\n    </div>\n    <% if combobox_security.country_code.present? %>\n      <div class=\"flex items-center bg-container-inset rounded-sm px-1.5 py-1 gap-1\">\n        <%= image_tag(\"https://hatscripts.github.io/circle-flags/flags/#{combobox_security.country_code.downcase}.svg\",\n                      class: \"h-4 rounded-sm\", # h-3 (12px) matches text-xs, w-5 for 3:5 aspect ratio\n                      alt: \"#{combobox_security.country_code.upcase} flag\",\n                      title: combobox_security.country_code.upcase) %>\n        <span class=\"text-xs text-secondary\">\n          <%= combobox_security.country_code.upcase %>\n        </span>\n      </div>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/securities/index.turbo_stream.erb",
    "content": "<%= async_combobox_options @securities.map(&:to_combobox_option),\n    render_in: { partial: \"securities/combobox_security\" } %>\n"
  },
  {
    "path": "app/views/sessions/new.html.erb",
    "content": "<%\n  header_title t(\".title\")\n%>\n\n<%= styled_form_with url: sessions_path, class: \"space-y-4\", data: { turbo: false } do |form| %>\n  <%= form.email_field :email, label: t(\".email\"), autofocus: false, autocomplete: \"email\", required: \"required\", placeholder: t(\".email_placeholder\") %>\n\n  <%= form.password_field :password, label: t(\".password\"), required: \"required\", placeholder: t(\".password_placeholder\") %>\n\n  <%= form.submit t(\".submit\") %>\n<% end %>\n\n<div class=\"mt-6 text-center\">\n  <%= link_to t(\".forgot_password\"), new_password_reset_path, class: \"font-medium text-sm text-primary hover:underline transition\" %>\n</div>\n"
  },
  {
    "path": "app/views/settings/_section.html.erb",
    "content": "<%# locals: (title:, subtitle: nil, content:) %>\n<section class=\"bg-container shadow-border-xs rounded-xl p-4 space-y-4\">\n  <div>\n    <h2 class=\"text-lg font-medium text-primary\"><%= title %></h2>\n    <% if subtitle.present? %>\n      <p class=\"text-secondary text-sm mt-1\"><%= subtitle %></p>\n    <% end %>\n  </div>\n  <div>\n    <%= content %>\n  </div>\n</section>\n"
  },
  {
    "path": "app/views/settings/_settings_nav.html.erb",
    "content": "<%\nnav_sections = [\n  {\n    header: t(\".general_section_title\"),\n    items: [\n      { label: t(\".profile_label\"), path: settings_profile_path, icon: \"circle-user\" },\n      { label: t(\".preferences_label\"), path: settings_preferences_path, icon: \"bolt\" },\n      { label: t(\".security_label\"), path: settings_security_path, icon: \"shield-check\" },\n      { label: \"API Key\", path: settings_api_key_path, icon: \"key\" },\n      { label: t(\".self_hosting_label\"), path: settings_hosting_path, icon: \"database\", if: self_hosted? },\n      { label: t(\".billing_label\"), path: settings_billing_path, icon: \"circle-dollar-sign\", if: !self_hosted? },\n      { label: t(\".accounts_label\"), path: accounts_path, icon: \"layers\" },\n      { label: t(\".imports_label\"), path: imports_path, icon: \"download\" }\n    ]\n  },\n  {\n    header: t(\".transactions_section_title\"),\n    items: [\n      { label: t(\".tags_label\"), path: tags_path, icon: \"tags\" },\n      { label: t(\".categories_label\"), path: categories_path, icon: \"shapes\" },\n      { label: t(\".rules_label\"), path: rules_path, icon: \"git-branch\" },\n      { label: t(\".merchants_label\"), path: family_merchants_path, icon: \"store\" }\n    ]\n  },\n  {\n    header: t(\".other_section_title\"),\n    items: [\n      { label: t(\".whats_new_label\"), path: changelog_path, icon: \"box\" },\n      { label: t(\".feedback_label\"), path: feedback_path, icon: \"megaphone\" }\n    ]\n  }\n]\n%>\n\n<div class=\"space-y-4\">\n  <div class=\"hidden lg:flex items-center gap-2 p-1.5\">\n    <%= render DS::Link.new(\n      text: \"Back\",\n      icon: \"chevron-left\",\n      href: previous_path,\n      variant: \"ghost\",\n    ) %>\n    <%= link_to previous_path, class: \"hidden md:block uppercase bg-surface-inset-hover rounded-sm px-1 py-0.5 text-xs text-secondary shadow-sm ml-1 pointer-events-none\", data: { controller: \"hotkey\", hotkey: \"Escape\" } do %>\n      <kbd>esc</kbd>\n    <% end %>\n  </div>\n  <nav class=\"space-y-4 hidden md:block\">\n    <% nav_sections.each do |section| %>\n      <section class=\"space-y-2\">\n        <div class=\"flex items-center gap-2 px-3\">\n          <h3 class=\"uppercase text-secondary font-medium text-xs\"><%= section[:header] %></h3>\n          <%= render \"shared/ruler\", classes: \"w-full\" %>\n        </div>\n        <ul class=\"space-y-1\">\n          <% section[:items].each do |item| %>\n            <% next if item[:if] == false %>\n            <li>\n              <%= render \"settings/settings_nav_item\", name: item[:label], path: item[:path], icon: item[:icon] %>\n            </li>\n          <% end %>\n        </ul>\n      </section>\n    <% end %>\n    <section>\n      <%= button_to session_path(Current.session), method: :delete, class: \"flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full\" do %>\n        <%= icon(\"log-out\", color: \"current\") %>\n        <span><%= t(\".logout\") %></span>\n      <% end %>\n    </section>\n  </nav>\n  <nav class=\"space-y-4 overflow-y-auto md:hidden\" id=\"mobile-settings-nav\" data-controller=\"preserve-scroll scroll-on-connect\">\n    <ul class=\"flex space-y-1\">\n      <li>\n        <%= render DS::Link.new(\n          text: \"Back\",\n          icon: \"chevron-left\",\n          href: previous_path,\n          variant: \"ghost\",\n        ) %>\n      </li>\n      <% nav_sections.each do |section| %>\n        <% section[:items].each do |item| %>\n          <% next if item[:if] == false %>\n          <li>\n            <%= render \"settings/settings_nav_item\", name: item[:label], path: item[:path], icon: item[:icon] %>\n          </li>\n        <% end %>\n      <% end %>\n      <%= button_to session_path(Current.session), method: :delete, class: \"flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full\" do %>\n        <%= icon(\"log-out\", color: \"current\") %>\n        <span><%= t(\".logout\") %></span>\n      <% end %>\n    </ul>\n  </nav>\n</div>\n"
  },
  {
    "path": "app/views/settings/_settings_nav_item.html.erb",
    "content": "<%# locals: (name:, path:, icon:) %>\n\n<%= link_to path, class: class_names(\n  \"flex items-center gap-2 whitespace-nowrap px-3 py-2 rounded-lg text-sm\",\n  page_active?(path) ? \"text-primary bg-container shadow-border-xs\" : \"text-secondary hover:bg-surface-hover border-transparent\"\n), aria: { current: (\"page\" if page_active?(path)) } do %>\n  <%= icon(icon) if icon %>\n  <%= name %>\n<% end %>\n"
  },
  {
    "path": "app/views/settings/_settings_nav_link_large.html.erb",
    "content": "<%# locals: path, direction, title %>\n<%= link_to path, class: \"hidden md:flex w-full bg-container hover:bg-container-inset rounded-xl border border-alpha-black-25 shadow-xs p-4 items-center justify-between\" do %>\n  <% if direction == 'previous' %>\n    <%= icon(\"arrow-left\") %>\n  <% end %>\n  <div class=\"<%= \"grow\" if direction == \"next\" %> <%= \"text-right\" if direction == \"previous\" %>\">\n    <span class=\"block text-sm text-secondary\"><%= t(\".#{direction}\") %></span>\n    <span class=\"block text-sm font-medium text-primary\"><%= title %></span>\n  </div>\n  <% if direction == 'next' %>\n    <%= icon(\"arrow-right\") %>\n  <% end %>\n<% end %>\n\n<%# Mobile version %>\n<%= link_to path, class: \"md:hidden w-full bg-container hover:bg-container-inset rounded-xl border border-alpha-black-25 shadow-xs py-3 px-4\" do %>\n  <div class=\"flex items-center justify-between\">\n    <% if direction == 'previous' %>\n      <div class=\"flex items-center gap-3\">\n        <%= icon(\"arrow-left\") %>\n        <span class=\"text-sm text-secondary\">Back</span>\n      </div>\n      <div>\n        <span class=\"text-sm font-medium text-primary\"><%= title %></span>\n      </div>\n    <% else %>\n      <div>\n        <span class=\"text-sm text-secondary\">Next</span>\n      </div>\n      <div class=\"flex items-center gap-3\">\n        <span class=\"text-sm font-medium text-primary\"><%= title %></span>\n        <%= icon(\"arrow-right\") %>\n      </div>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/settings/_user_avatar.html.erb",
    "content": "<%# locals: (avatar_url: nil, initials: \"U\", lazy: false) %>\n\n<% if avatar_url.present? %>\n  <%= image_tag avatar_url, class: \"rounded-full w-full h-full object-cover\", loading: lazy ? \"lazy\" : \"eager\" %>\n<% else %>\n  <div class=\"w-full h-full bg-surface-inset hover:bg-surface-inset-hover text-secondary rounded-full flex items-center justify-center text-lg uppercase\"><%= initials %></div>\n<% end %>\n"
  },
  {
    "path": "app/views/settings/_user_avatar_field.html.erb",
    "content": "<%# locals: (form:, user:) %>\n\n<div class=\"flex flex-col items-center gap-4\" data-controller=\"profile-image-preview\">\n  <div class=\"relative\">\n    <button type=\"button\"\n            data-profile-image-preview-target=\"clearBtn\"\n            data-action=\"click->profile-image-preview#clearFileInput\"\n      class=\"<%= user.profile_image.attached? ? \"\" : \"hidden\" %> z-50 cursor-pointer absolute bottom-0 right-0 w-8 h-8 bg-gray-50 rounded-full flex justify-center items-center border border-white border-2\">\n      <%= icon \"x\", size: \"sm\" %>\n    </button>\n\n    <div class=\"relative flex justify-center items-center bg-surface-inset size-26 md:size-24 rounded-full border-primary border border-dashed overflow-hidden\">\n      <%# The image preview once user has uploaded a new file  %>\n      <div data-profile-image-preview-target=\"previewImage\" class=\"h-full w-full flex justify-center items-center hidden\">\n        <img src=\"\" alt=\"Preview\" class=\"w-full h-full rounded-full object-cover\">\n      </div>\n\n      <%# The placeholder image for empty avatar field  %>\n      <div data-profile-image-preview-target=\"placeholderImage\"\n         class=\"h-full w-full flex justify-center  items-center <%= user.profile_image.attached? ? \"hidden\" : \"\" %>\">\n        <div class=\"h-full w-full flex justify-center items-center bg-surface-inset\">\n          <%= icon \"image-plus\", size: \"lg\" %>\n        </div>\n      </div>\n\n      <%# The attached image if user has already uploaded one %>\n      <div data-profile-image-preview-target=\"attachedImage\"\n         class=\"h-full w-full flex justify-center items-center <%= user.profile_image.attached? ? \"\" : \"hidden\" %>\">\n        <% if user.profile_image.attached? %>\n          <div class=\"h-full w-full\">\n            <%= render \"settings/user_avatar\", avatar_url: user.profile_image.url %>\n          </div>\n        <% end %>\n      </div>\n    </div>\n  </div>\n\n  <div class=\"text-center\">\n    <%= form.hidden_field :delete_profile_image, value: \"0\", data: { profile_image_preview_target: \"deleteProfileImage\" } %>\n\n    <%= form.label :profile_image, class: \"px-3 py-2 rounded-lg text-sm hover:bg-surface-hover border border-secondary inline-flex items-center gap-2 cursor-pointer\", data: { profile_image_preview_target: \"uploadButton\" } do %>\n      <%= icon \"camera\", data: { profile_image_preview_target: \"cameraIcon\" } %>\n      <span data-profile-image-preview-target=\"uploadText\">\n        <%= t(\".choose\") %> <span class=\"text-secondary\"><%= t(\".choose_label\") %></span>\n      </span>\n      <span data-profile-image-preview-target=\"changeText\" class=\"hidden\" aria-hidden=\"true\">\n        <%= t(\".change\") %>\n      </span>\n    <% end %>\n\n    <p class=\"mt-2 text-xs text-secondary\"><%= t(\".accepted_formats\") %></p>\n\n    <%= form.file_field :profile_image,\n        accept: \"image/png, image/jpeg\",\n        class: \"hidden px-3 py-2 bg-gray-50 text-primary rounded-md text-sm font-medium\",\n        data: {\n          profile_image_preview_target: \"input\",\n          action: \"change->profile-image-preview#showFileInputPreview\"\n        } %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/settings/api_keys/created.html.erb",
    "content": "<%= content_for :page_title, \"API Key Created\" %>\n\n<%= settings_section title: \"API Key Created Successfully\", subtitle: \"Your new API key has been generated successfully.\" do %>\n  <div class=\"space-y-4\">\n    <div class=\"p-3 shadow-border-xs bg-container rounded-lg\">\n      <div class=\"flex items-start gap-3\">\n        <%= render DS::FilledIcon.new(\n          icon: \"check-circle\",\n          rounded: true,\n          size: \"lg\",\n          variant: :success\n        ) %>\n        <div class=\"flex-1\">\n          <h3 class=\"font-medium text-primary\">API Key Created Successfully!</h3>\n          <p class=\"text-secondary text-sm mt-1\">Your new API key \"<%= @api_key.name %>\" has been created and is ready to use.</p>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"bg-surface-inset rounded-xl p-4\">\n      <h4 class=\"font-medium text-primary mb-3\">Your API Key</h4>\n      <p class=\"text-secondary text-sm mb-3\">Copy and store this key securely. You'll need it to authenticate your API requests.</p>\n\n      <div class=\"bg-container rounded-lg p-3 border border-primary\" data-controller=\"clipboard\">\n        <div class=\"flex items-center justify-between gap-3\">\n          <code id=\"api-key-display\" class=\"font-mono text-sm text-primary break-all\" data-clipboard-target=\"source\"><%= @api_key.plain_key %></code>\n          <%= render DS::Button.new(\n            text: \"Copy API Key\",\n            variant: \"ghost\",\n            icon: \"copy\",\n            data: { action: \"clipboard#copy\" }\n          ) %>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"bg-surface-inset rounded-xl p-4\">\n      <h4 class=\"font-medium text-primary mb-3\">Key Details</h4>\n      <div class=\"space-y-2 text-sm\">\n        <div class=\"flex justify-between\">\n          <span class=\"text-secondary\">Name:</span>\n          <span class=\"text-primary font-medium\"><%= @api_key.name %></span>\n        </div>\n        <div class=\"flex justify-between\">\n          <span class=\"text-secondary\">Permissions:</span>\n          <span class=\"text-primary\">\n            <%= @api_key.scopes.map { |scope|\n              case scope\n              when \"read_accounts\" then \"View Accounts\"\n              when \"read_transactions\" then \"View Transactions\"\n              when \"read_balances\" then \"View Balances\"\n              when \"write_transactions\" then \"Create Transactions\"\n              else scope.humanize\n              end\n            }.join(\", \") %>\n          </span>\n        </div>\n        <div class=\"flex justify-between\">\n          <span class=\"text-secondary\">Created:</span>\n          <span class=\"text-primary\"><%= @api_key.created_at.strftime(\"%B %d, %Y at %I:%M %p\") %></span>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"bg-warning-50 border border-warning-200 rounded-xl p-4\">\n      <div class=\"flex items-start gap-2\">\n        <%= icon(\"alert-triangle\", class: \"w-5 h-5 text-warning-600 mt-0.5\") %>\n        <div>\n          <h4 class=\"font-medium text-warning-800 text-sm\">Important Security Note</h4>\n          <p class=\"text-warning-700 text-sm mt-1\">\n            This is the only time your API key will be displayed. Make sure to copy it now and store it securely.\n            If you lose this key, you'll need to generate a new one.\n          </p>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"bg-surface-inset rounded-xl p-4\">\n      <h4 class=\"font-medium text-primary mb-3\">How to use your API key</h4>\n      <p class=\"text-secondary text-sm mb-3\">Include your API key in the X-Api-Key header when making requests:</p>\n      <div class=\"bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary\">\n        curl -H \"X-Api-Key: <%= @api_key.plain_key %>\" <%= request.base_url %>/api/v1/accounts\n      </div>\n    </div>\n\n    <div class=\"flex justify-end pt-4 border-t border-primary\">\n      <%= render DS::Link.new(\n        text: \"Continue to API Key Settings\",\n        href: settings_api_key_path,\n        variant: \"primary\"\n      ) %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/settings/api_keys/created.turbo_stream.erb",
    "content": "<%= turbo_stream.update \"main\" do %>\n  <div class=\"relative max-w-4xl mx-auto flex flex-col w-full h-full\">\n    <div class=\"grow space-y-4 overflow-y-auto -mx-1 px-1 pb-12\">\n      <h1 class=\"text-primary text-3xl md:text-xl font-medium\">\n        API Key Created\n      </h1>\n\n      <%= settings_section title: \"API Key Created Successfully\", subtitle: \"Your new API key has been generated successfully.\" do %>\n        <div class=\"space-y-4\">\n          <div class=\"p-3 shadow-border-xs bg-container rounded-lg\">\n            <div class=\"flex items-start gap-3\">\n              <%= render DS::FilledIcon.new(\n                icon: \"check-circle\",\n                rounded: true,\n                size: \"lg\",\n                variant: :success\n              ) %>\n              <div class=\"flex-1\">\n                <h3 class=\"font-medium text-primary\">API Key Created Successfully!</h3>\n                <p class=\"text-secondary text-sm mt-1\">Your new API key \"<%= @api_key.name %>\" has been created and is ready to use.</p>\n              </div>\n            </div>\n          </div>\n\n          <div class=\"bg-surface-inset rounded-xl p-4\">\n            <h4 class=\"font-medium text-primary mb-3\">Your API Key</h4>\n            <p class=\"text-secondary text-sm mb-3\">Copy and store this key securely. You'll need it to authenticate your API requests.</p>\n\n            <div class=\"bg-container rounded-lg p-3 border border-primary\" data-controller=\"clipboard\">\n              <div class=\"flex items-center justify-between gap-3\">\n                <code id=\"api-key-display\" class=\"font-mono text-sm text-primary break-all\" data-clipboard-target=\"source\"><%= @api_key.plain_key %></code>\n                <%= render DS::Button.new(\n                  text: \"Copy API Key\",\n                  variant: \"ghost\",\n                  icon: \"copy\",\n                  data: { action: \"clipboard#copy\" }\n                ) %>\n              </div>\n            </div>\n          </div>\n\n          <div class=\"bg-surface-inset rounded-xl p-4\">\n            <h4 class=\"font-medium text-primary mb-3\">Key Details</h4>\n            <div class=\"space-y-2 text-sm\">\n              <div class=\"flex justify-between\">\n                <span class=\"text-secondary\">Name:</span>\n                <span class=\"text-primary font-medium\"><%= @api_key.name %></span>\n              </div>\n              <div class=\"flex justify-between\">\n                <span class=\"text-secondary\">Permissions:</span>\n                <span class=\"text-primary\">\n                  <%= @api_key.scopes.map { |scope|\n                    case scope\n                    when \"read_accounts\" then \"View Accounts\"\n                    when \"read_transactions\" then \"View Transactions\"\n                    when \"read_balances\" then \"View Balances\"\n                    when \"write_transactions\" then \"Create Transactions\"\n                    else scope.humanize\n                    end\n                  }.join(\", \") %>\n                </span>\n              </div>\n              <div class=\"flex justify-between\">\n                <span class=\"text-secondary\">Created:</span>\n                <span class=\"text-primary\"><%= @api_key.created_at.strftime(\"%B %d, %Y at %I:%M %p\") %></span>\n              </div>\n            </div>\n          </div>\n\n          <div class=\"bg-warning-50 border border-warning-200 rounded-xl p-4\">\n            <div class=\"flex items-start gap-2\">\n              <%= icon(\"alert-triangle\", class: \"w-5 h-5 text-warning-600 mt-0.5\") %>\n              <div>\n                <h4 class=\"font-medium text-warning-800 text-sm\">Important Security Note</h4>\n                <p class=\"text-warning-700 text-sm mt-1\">\n                  This is the only time your API key will be displayed. Make sure to copy it now and store it securely.\n                  If you lose this key, you'll need to generate a new one.\n                </p>\n              </div>\n            </div>\n          </div>\n\n          <div class=\"bg-surface-inset rounded-xl p-4\">\n            <h4 class=\"font-medium text-primary mb-3\">How to use your API key</h4>\n            <p class=\"text-secondary text-sm mb-3\">Include your API key in the X-Api-Key header when making requests:</p>\n            <div class=\"bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary\">\n              curl -H \"X-Api-Key: <%= @api_key.plain_key %>\" <%= request.base_url %>/api/v1/accounts\n            </div>\n          </div>\n\n          <div class=\"flex justify-end pt-4 border-t border-primary\">\n            <%= render DS::Link.new(\n              text: \"Continue to API Key Settings\",\n              href: settings_api_key_path,\n              variant: \"primary\"\n            ) %>\n          </div>\n        </div>\n      <% end %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/settings/api_keys/new.html.erb",
    "content": "<%= content_for :page_title, \"Create New API Key\" %>\n\n<%= settings_section title: \"Create New API Key\", subtitle: \"Generate a new API key to access your Maybe data programmatically.\" do %>\n  <%= styled_form_with model: @api_key, url: settings_api_key_path, class: \"space-y-4\" do |form| %>\n    <%= form.text_field :name,\n        placeholder: \"e.g., My Budget App, Portfolio Tracker\",\n        label: \"API Key Name\",\n        help_text: \"Choose a descriptive name to help you identify this key later.\" %>\n\n    <div>\n      <%= form.label :scopes, \"Permissions\", class: \"block text-sm font-medium text-primary mb-2\" %>\n      <p class=\"text-sm text-secondary mb-3\">Select the permissions this API key should have:</p>\n\n      <div class=\"space-y-2\">\n        <% [\n          [\"read\", \"Read Only\", \"View your accounts, transactions, and balances\"],\n          [\"read_write\", \"Read/Write\", \"View your data and create new transactions\"]\n        ].each do |value, label, description| %>\n          <div class=\"bg-surface-inset rounded-lg p-3 border border-primary\">\n            <label class=\"flex items-start gap-3 cursor-pointer\">\n              <%= radio_button_tag \"api_key[scopes]\", value, (@api_key&.scopes || []).include?(value),\n                  class: \"mt-1\" %>\n              <div class=\"flex-1\">\n                <div class=\"font-medium text-primary\"><%= label %></div>\n                <div class=\"text-sm text-secondary mt-1\"><%= description %></div>\n              </div>\n            </label>\n          </div>\n        <% end %>\n      </div>\n    </div>\n\n    <div class=\"bg-warning-50 border border-warning-200 rounded-xl p-4\">\n      <div class=\"flex items-start gap-2\">\n        <%= icon(\"alert-triangle\", class: \"w-5 h-5 text-warning-600 mt-0.5\") %>\n        <div>\n          <h4 class=\"font-medium text-warning-800 text-sm\">Security Warning</h4>\n          <p class=\"text-warning-700 text-sm mt-1\">\n            Your API key will be displayed only once after creation. Make sure to copy and store it securely.\n            Anyone with access to this key can access your data according to the permissions you select.\n          </p>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"flex justify-end gap-3 pt-4 border-t border-primary\">\n      <%= render DS::Link.new(\n        text: \"Cancel\",\n        href: settings_api_key_path,\n        variant: \"ghost\"\n      ) %>\n\n      <%= render DS::Button.new(\n        text: \"Create API Key\",\n        variant: \"primary\",\n        type: \"submit\"\n      ) %>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/settings/api_keys/show.html.erb",
    "content": "<%= content_for :page_title, \"API Key\" %>\n\n<% if @newly_created && @plain_key %>\n  <%= settings_section title: \"API Key Created Successfully\", subtitle: \"Your new API key has been generated successfully.\" do %>\n    <div class=\"space-y-4\">\n      <div class=\"p-3 shadow-border-xs bg-container rounded-lg\">\n        <div class=\"flex items-start gap-3\">\n          <%= render DS::FilledIcon.new(\n            icon: \"check-circle\",\n            rounded: true,\n            size: \"lg\",\n            variant: :success\n          ) %>\n          <div class=\"flex-1\">\n            <h3 class=\"font-medium text-primary\">API Key Created Successfully!</h3>\n            <p class=\"text-secondary text-sm mt-1\">Your new API key \"<%= @current_api_key.name %>\" has been created and is ready to use.</p>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"bg-surface-inset rounded-xl p-4\">\n        <h4 class=\"font-medium text-primary mb-3\">Your API Key</h4>\n        <p class=\"text-secondary text-sm mb-3\">Copy and store this key securely. You'll need it to authenticate your API requests.</p>\n\n        <div class=\"bg-container rounded-lg p-3 border border-primary\" data-controller=\"clipboard\">\n          <div class=\"flex items-center justify-between gap-3\">\n            <code id=\"api-key-display\" class=\"font-mono text-sm text-primary break-all\" data-clipboard-target=\"source\"><%= @current_api_key.plain_key %></code>\n            <%= render DS::Button.new(\n              text: \"Copy API Key\",\n              variant: \"ghost\",\n              icon: \"copy\",\n              data: { action: \"clipboard#copy\" }\n            ) %>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"bg-surface-inset rounded-xl p-4\">\n        <h4 class=\"font-medium text-primary mb-3\">How to use your API key</h4>\n        <p class=\"text-secondary text-sm mb-3\">Include your API key in the X-Api-Key header when making requests:</p>\n        <div class=\"bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary\">\n          curl -H \"X-Api-Key: <%= @current_api_key.plain_key %>\" <%= request.base_url %>/api/v1/accounts\n        </div>\n      </div>\n\n      <div class=\"flex justify-end pt-4 border-t border-primary\">\n        <%= render DS::Link.new(\n          text: \"Continue to API Key Settings\",\n          href: settings_api_key_path,\n          variant: \"primary\"\n        ) %>\n      </div>\n    </div>\n  <% end %>\n<% elsif @current_api_key %>\n  <%= settings_section title: \"Your API Key\", subtitle: \"Manage your API key for programmatic access to your Maybe data.\" do %>\n    <div class=\"space-y-4\">\n      <div class=\"p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center\">\n        <div class=\"flex items-center gap-3\">\n          <%= render DS::FilledIcon.new(\n            icon: \"key\",\n            rounded: true,\n            size: \"lg\"\n          ) %>\n\n          <div class=\"text-sm space-y-1\">\n            <p class=\"text-primary font-medium\"><%= @current_api_key.name %></p>\n            <p class=\"text-secondary\">\n              Created <%= time_ago_in_words(@current_api_key.created_at) %> ago\n              <% if @current_api_key.last_used_at %>\n                • Last used <%= time_ago_in_words(@current_api_key.last_used_at) %> ago\n              <% else %>\n                • Never used\n              <% end %>\n            </p>\n          </div>\n        </div>\n\n        <div class=\"rounded-md bg-success px-2 py-1\">\n          <p class=\"text-success-foreground font-medium text-xs\">Active</p>\n        </div>\n      </div>\n\n      <div class=\"bg-surface-inset rounded-xl p-4\">\n        <h4 class=\"font-medium text-primary mb-3\">Permissions</h4>\n        <div class=\"flex flex-wrap gap-2\">\n          <% @current_api_key.scopes.each do |scope| %>\n            <span class=\"inline-flex items-center gap-1 px-2 py-1 bg-primary text-primary-foreground rounded-full text-xs font-medium\">\n              <%= icon(\"shield-check\", class: \"w-3 h-3\") %>\n              <%= case scope\n                  when \"read\" then \"Read Only\"\n                  when \"read_write\" then \"Read/Write\"\n                  else scope.humanize\n                  end %>\n            </span>\n          <% end %>\n        </div>\n      </div>\n\n      <div class=\"bg-surface-inset rounded-xl p-4\">\n        <h4 class=\"font-medium text-primary mb-3\">Your API Key</h4>\n        <p class=\"text-secondary text-sm mb-3\">Copy and store this key securely. You'll need it to authenticate your API requests.</p>\n\n        <div class=\"bg-container rounded-lg p-3 border border-primary\" data-controller=\"clipboard\">\n          <div class=\"flex items-center justify-between gap-3\">\n            <code id=\"api-key-display\" class=\"font-mono text-sm text-primary break-all\" data-clipboard-target=\"source\"><%= @current_api_key.plain_key %></code>\n            <%= render DS::Button.new(\n              text: \"Copy API Key\",\n              variant: \"ghost\",\n              icon: \"copy\",\n              data: { action: \"clipboard#copy\" }\n            ) %>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"bg-surface-inset rounded-xl p-4\">\n        <h4 class=\"font-medium text-primary mb-3\">How to use your API key</h4>\n        <p class=\"text-secondary text-sm mb-3\">Include your API key in the X-Api-Key header when making requests:</p>\n        <div class=\"bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary\">\n          curl -H \"X-Api-Key: <%= @current_api_key.plain_key %>\" <%= request.base_url %>/api/v1/accounts\n        </div>\n      </div>\n\n      <div class=\"flex flex-col sm:flex-row gap-3 pt-4 border-t border-primary\">\n        <%= render DS::Link.new(\n          text: \"Create New Key\",\n          href: new_settings_api_key_path(regenerate: true),\n          variant: \"secondary\"\n        ) %>\n\n        <%= render DS::Button.new(\n          text: \"Revoke Key\",\n          href: settings_api_key_path,\n          method: :delete,\n          variant: \"destructive\",\n          data: {\n            turbo_confirm: \"Are you sure you want to revoke this API key?\"\n          }\n        ) %>\n      </div>\n    </div>\n  <% end %>\n<% else %>\n  <%= settings_section title: \"Create Your API Key\", subtitle: \"Get programmatic access to your Maybe data\" do %>\n    <div class=\"space-y-4\">\n      <div class=\"p-3 shadow-border-xs bg-container rounded-lg\">\n        <div class=\"flex items-start gap-3\">\n          <%= render DS::FilledIcon.new(\n            icon: \"key\",\n            rounded: true,\n            size: \"lg\"\n          ) %>\n          <div class=\"flex-1\">\n            <h3 class=\"font-medium text-primary\">Access your account data programmatically</h3>\n            <p class=\"text-secondary text-sm mt-1\">Generate an API key to integrate with your applications and access your financial data securely.</p>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"bg-surface-inset rounded-xl p-4\">\n        <h4 class=\"font-medium text-primary mb-3\">What you can do with API keys:</h4>\n        <ul class=\"space-y-2 text-sm text-secondary\">\n          <li class=\"flex items-start gap-2\">\n            <%= icon(\"check\", class: \"w-4 h-4 text-primary mt-0.5\") %>\n            <span>Access your accounts and balances</span>\n          </li>\n          <li class=\"flex items-start gap-2\">\n            <%= icon(\"check\", class: \"w-4 h-4 text-primary mt-0.5\") %>\n            <span>View transaction history</span>\n          </li>\n          <li class=\"flex items-start gap-2\">\n            <%= icon(\"check\", class: \"w-4 h-4 text-primary mt-0.5\") %>\n            <span>Create new transactions</span>\n          </li>\n          <li class=\"flex items-start gap-2\">\n            <%= icon(\"check\", class: \"w-4 h-4 text-primary mt-0.5\") %>\n            <span>Integrate with third-party applications</span>\n          </li>\n        </ul>\n      </div>\n\n      <div class=\"flex justify-start\">\n        <%= render DS::Link.new(\n          text: \"Create API Key\",\n          href: new_settings_api_key_path,\n          variant: \"primary\"\n        ) %>\n      </div>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/settings/billings/show.html.erb",
    "content": "<%= content_for :page_title, t(\".page_title\") %>\n\n<%= settings_section title: t(\".subscription_title\"), subtitle: t(\".subscription_subtitle\") do %>\n  <div class=\"space-y-4\">\n    <div class=\"p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center\">\n      <div class=\"flex items-center gap-3\">\n        <%= render DS::FilledIcon.new(\n          icon: \"gem\",\n          rounded: true,\n          size: \"lg\"\n        ) %>\n\n        <div class=\"text-sm space-y-1\">\n          <% if @family.has_active_subscription? %>\n            <p class=\"text-primary\">\n              <span>You are currently subscribed to the <span class=\"font-medium\"><%= @family.subscription.name %></span>.</span>\n\n              <% if @family.next_billing_date %>\n                <span>Your plan renews on <span class=\"font-medium\"><%= @family.next_billing_date.strftime(\"%B %d, %Y\") %></span>.</span>\n              <% end %>\n            </p>\n          <% elsif @family.trialing? %>\n            <p class=\"text-primary\">\n              You are currently trialing Maybe\n              <span class=\"text-secondary\">\n                (<%= @family.days_left_in_trial %> days remaining)\n              </span>\n            </p>\n          <% else %>\n            <p class=\"text-primary\">You are currently <span class=\"font-medium\">not subscribed</span></p>\n            <p class=\"text-secondary\">Once you subscribe to Maybe+, you'll see your billing settings here.</p>\n          <% end %>\n        </div>\n      </div>\n\n      <% if @family.has_active_subscription? %>\n        <%= render DS::Link.new(\n          text: \"Manage\",\n          icon: \"external-link\",\n          variant: \"primary\",\n          icon_position: \"right\",\n          href: subscription_path,\n          rel: \"noopener\"\n        ) %>\n      <% else %>\n        <%= render DS::Link.new(\n          text: \"Choose plan\",\n          variant: \"primary\",\n          icon: \"plus\",\n          icon_position: \"right\",\n          href: upgrade_subscription_path(view: \"upgrade\"),\n          rel: \"noopener\") %>\n      <% end %>\n    </div>\n\n    <div class=\"flex items-center gap-2\">\n      <%= image_tag \"stripe-logo.svg\", class: \"w-5 h-5 shrink-0\" %>\n      <p class=\"text-secondary text-sm\">Billing via Stripe</p>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/settings/hostings/_danger_zone_settings.html.erb",
    "content": "<% if Current.user.admin? %>\n  <div class=\"space-y-4\">\n    <div class=\"flex flex-col md:flex-row md:items-center md:justify-between gap-4\">\n      <div class=\"w-full md:w-2/3\">\n        <h3 class=\"font-medium text-primary\"><%= t(\"settings.hostings.show.clear_cache\") %></h3>\n        <p class=\"text-secondary text-sm\"><%= t(\"settings.hostings.show.clear_cache_warning\") %></p>\n      </div>\n      <%=\n            button_to t(\"settings.hostings.show.clear_cache\"), clear_cache_settings_hosting_path, method: :delete,\n              class: \"w-full md:w-auto bg-yellow-600 fg-inverse text-sm font-medium rounded-lg px-4 py-2\",\n              data: { turbo_confirm: {\n                title: t(\"settings.hostings.show.confirm_clear_cache.title\"),\n                body: t(\"settings.hostings.show.confirm_clear_cache.body\"),\n                accept: t(\"settings.hostings.show.clear_cache\"),\n                acceptClass: \"w-full bg-yellow-600 fg-inverse rounded-xl text-center p-[10px] border mb-2\"\n              }}\n      %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/settings/hostings/_invite_code_settings.html.erb",
    "content": "<div class=\"space-y-4\">\n  <div class=\"flex items-center justify-between\">\n    <div class=\"space-y-1\">\n      <p class=\"text-sm\"><%= t(\".title\") %></p>\n      <p class=\"text-secondary text-sm\"><%= t(\".description\") %></p>\n    </div>\n\n    <%= styled_form_with model: Setting.new,\n                         url: settings_hosting_path,\n                         method: :patch,\n                         data: { controller: \"auto-submit-form\", auto_submit_form_trigger_event_value: \"change\" } do |form| %>\n      <%= form.toggle :require_invite_for_signup, { data: { auto_submit_form_target: \"auto\" } } %>\n    <% end %>\n  </div>\n\n  <div class=\"flex items-center justify-between\">\n    <div class=\"space-y-1\">\n      <p class=\"text-sm\"><%= t(\".email_confirmation_title\") %></p>\n      <p class=\"text-secondary text-sm\"><%= t(\".email_confirmation_description\") %></p>\n    </div>\n\n    <%= styled_form_with model: Setting.new,\n                         url: settings_hosting_path,\n                         method: :patch,\n                         data: { controller: \"auto-submit-form\", auto_submit_form_trigger_event_value: \"change\" } do |form| %>\n      <%= form.toggle :require_email_confirmation, { data: { auto_submit_form_target: \"auto\" } } %>\n    <% end %>\n  </div>\n\n  <% if Setting.require_invite_for_signup %>\n    <div class=\"flex items-center justify-between mb-4\">\n      <div>\n        <span class=\"text-primary text-base font-medium\"><%= t(\".generated_tokens\") %></span>\n      </div>\n      <div>\n        <%= button_to invite_codes_path,\n                        method: :post,\n                        class: \"flex gap-1 bg-gray-50 text-primary text-sm rounded-lg px-3 py-2\" do %>\n          <span><%= t(\".generate_tokens\") %></span>\n        <% end %>\n      </div>\n    </div>\n\n    <div>\n      <%= turbo_frame_tag :invite_codes, src: invite_codes_path %>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/settings/hostings/_synth_settings.html.erb",
    "content": "<div class=\"space-y-4\">\n  <div>\n    <h2 class=\"font-medium mb-1\"><%= t(\".title\") %></h2>\n    <% if ENV[\"SYNTH_API_KEY\"].present? %>\n      <p class=\"text-sm text-secondary\">You have successfully configured your Synth API key through the SYNTH_API_KEY environment variable.</p>\n    <% else %>\n      <p class=\"text-secondary text-sm mb-4\"><%= t(\".description\") %></p>\n    <% end %>\n  </div>\n\n  <%= styled_form_with model: Setting.new,\n                       url: settings_hosting_path,\n                       method: :patch,\n                       data: {\n                         controller: \"auto-submit-form\",\n                         \"auto-submit-form-trigger-event-value\": \"blur\"\n                       } do |form| %>\n    <%= form.text_field :synth_api_key,\n                        label: t(\".label\"),\n                        type: \"password\",\n                        placeholder: t(\".placeholder\"),\n                        value: ENV.fetch(\"SYNTH_API_KEY\", Setting.synth_api_key),\n                        disabled: ENV[\"SYNTH_API_KEY\"].present?,\n                        container_class: @synth_usage.present? && !@synth_usage.success? ? \"border-red-500\" : \"\",\n                        data: { \"auto-submit-form-target\": \"auto\" } %>\n  <% end %>\n\n  <% if @synth_usage.present? && @synth_usage.success? %>\n    <div class=\"space-y-4\">\n      <div class=\"space-y-2\">\n        <p class=\"text-sm text-secondary\">\n          <%= t(\".api_calls_used\",\n                used: number_with_delimiter(@synth_usage.data.used),\n                limit: number_with_delimiter(@synth_usage.data.limit),\n                percentage: number_to_percentage(@synth_usage.data.utilization, precision: 1)) %>\n        </p>\n        <div class=\"w-52 h-1.5 bg-gray-100 rounded-2xl\">\n          <div class=\"h-full bg-green-500 rounded-2xl\"\n               style=\"width: <%= [@synth_usage.data.utilization, 2].max %>%;\"></div>\n        </div>\n      </div>\n      <div class=\"bg-gray-100 rounded-md px-1.5 py-0.5 w-fit\">\n        <p class=\"text-xs font-medium text-secondary uppercase\">\n          <%= t(\".plan\", plan: @synth_usage.data.plan) %>\n        </p>\n      </div>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/settings/hostings/show.html.erb",
    "content": "<%= content_for :page_title, t(\".title\") %>\n\n<%= settings_section title: t(\".general\") do %>\n  <div class=\"space-y-6\">\n    <%= render \"settings/hostings/synth_settings\" %>\n  </div>\n<% end %>\n\n<%= settings_section title: t(\".invites\") do %>\n  <%= render \"settings/hostings/invite_code_settings\" %>\n<% end %>\n\n<%= settings_section title: t(\".danger_zone\") do %>\n  <%= render \"settings/hostings/danger_zone_settings\" %>\n<% end %>\n"
  },
  {
    "path": "app/views/settings/preferences/show.html.erb",
    "content": "<%= content_for :page_title, t(\".page_title\") %>\n\n<%= settings_section title: t(\".general_title\"), subtitle: t(\".general_subtitle\") do %>\n  <div>\n    <%= styled_form_with model: @user, class: \"space-y-4\", data: { controller: \"auto-submit-form\" } do |form| %>\n      <%= form.hidden_field :redirect_to, value: \"preferences\" %>\n\n      <%= form.fields_for :family do |family_form| %>\n        <%= family_form.select :currency,\n            Money::Currency.as_options.map { |currency| [ \"#{currency.name} (#{currency.iso_code})\", currency.iso_code ] },\n            { label: t(\".currency\") }, disabled: true %>\n\n        <%= family_form.select :locale,\n            language_options,\n            { label: t(\".language\") },\n            { data: { auto_submit_form_target: \"auto\" } } %>\n\n        <%= family_form.select :timezone,\n            timezone_options,\n            { label: t(\".timezone\") },\n            { data: { auto_submit_form_target: \"auto\" } } %>\n\n        <%= family_form.select :date_format,\n            Family::DATE_FORMATS,\n            { label: t(\".date_format\") },\n            { data: { auto_submit_form_target: \"auto\" } } %>\n\n        <%= form.select :default_period,\n            Period.all.map { |period| [ period.label, period.key ] },\n            { label: t(\".default_period\") },\n            { data: { auto_submit_form_target: \"auto\" } } %>\n\n        <%= family_form.select :country,\n            country_options,\n            { label: t(\".country\") },\n            { data: { auto_submit_form_target: \"auto\" } } %>\n\n        <p class=\"text-xs italic pl-2 text-secondary\">Please note, we are still working on translations for various languages.  Please see the <%= link_to \"I18n issue\", \"https://github.com/maybe-finance/maybe/issues/1225\", target: \"_blank\", class: \"underline\" %> for more information.</p>\n      <% end %>\n    <% end %>\n  </div>\n<% end %>\n\n<%= settings_section title: t(\".theme_title\"), subtitle: t(\".theme_subtitle\") do %>\n  <div data-controller=\"theme\" data-theme-user-preference-value=\"<%= @user.theme %>\">\n    <%= form_with model: @user, class: \"flex flex-col md:flex-row justify-between items-center gap-4\", id: \"theme_form\",\n        data: { controller: \"auto-submit-form\", auto_submit_form_trigger_event_value: \"change\" } do |form| %>\n      <%= form.hidden_field :redirect_to, value: \"preferences\" %>\n\n      <% theme_option_class = \"text-center transition-all duration-200 p-3 rounded-lg hover:bg-surface-hover cursor-pointer [&:has(input:checked)]:bg-surface-hover [&:has(input:checked)]:border [&:has(input:checked)]:border-primary [&:has(input:checked)]:shadow-xs\" %>\n\n      <% [\n        { value: \"light\", image: \"light-mode-preview.png\" },\n        { value: \"dark\", image: \"dark-mode-preview.png\" },\n        { value: \"system\", image: \"system-mode-preview.png\" }\n      ].each do |theme| %>\n        <%= form.label :\"theme_#{theme[:value]}\", class: \"group\" do %>\n          <div class=\"<%= theme_option_class %>\">\n            <%= image_tag(theme[:image], alt: \"#{theme[:value].titleize} Theme Preview\", class: \"h-44 mb-2\") %>\n            <div class=\"<%= theme[:value] == \"system\" ? \"flex items-center gap-2 justify-center\" : \"text-sm font-medium text-primary\" %>\">\n              <%= form.radio_button :theme, theme[:value], checked: @user.theme == theme[:value], class: \"sr-only\",\n                data: { auto_submit_form_target: \"auto\", autosubmit_trigger_event: \"change\", action: \"theme#updateTheme\" } %>\n              <%= t(\".theme_#{theme[:value]}\") %>\n            </div>\n          </div>\n        <% end %>\n      <% end %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/settings/profiles/show.html.erb",
    "content": "<%= content_for :page_title, t(\".page_title\") %>\n\n<%= settings_section title: t(\".profile_title\"), subtitle: t(\".profile_subtitle\") do %>\n  <%= styled_form_with model: @user, url: user_path(@user), class: \"space-y-4\" do |form| %>\n    <%= render \"settings/user_avatar_field\", form: form, user: @user %>\n\n    <div>\n      <%= form.email_field :email, placeholder: t(\".email\"), label: t(\".email\") %>\n\n      <% if @user.unconfirmed_email.present? %>\n        <p class=\"mt-2 text-sm text-gray-600\">\n          You have requested to change your email to <%= @user.unconfirmed_email %>. Please go to your email and confirm for the change to take effect.\n        </p>\n      <% end %>\n\n      <div class=\"grid grid-cols-1 md:grid-cols-2 gap-4 mt-4\">\n        <%= form.text_field :first_name, placeholder: t(\".first_name\"), label: t(\".first_name\") %>\n        <%= form.text_field :last_name, placeholder: t(\".last_name\"), label: t(\".last_name\") %>\n      </div>\n\n      <div class=\"flex justify-end mt-4\">\n        <%= render DS::Button.new(text: t(\".save\"), class: \"md:w-auto w-full justify-center\") %>\n      </div>\n    </div>\n  <% end %>\n<% end %>\n\n<%= settings_section title: t(\".household_title\"), subtitle: t(\".household_subtitle\") do %>\n  <div class=\"space-y-4\">\n    <%= styled_form_with model: Current.user, class: \"space-y-4\", data: { controller: \"auto-submit-form\" } do |form| %>\n      <%= form.fields_for :family do |family_fields| %>\n        <%= family_fields.text_field :name,\n              placeholder: t(\".household_form_input_placeholder\"),\n              label: t(\".household_form_label\"),\n              disabled: !Current.user.admin?,\n              \"data-auto-submit-form-target\": \"auto\" %>\n      <% end %>\n    <% end %>\n    <div class=\"bg-container-inset rounded-xl p-1\">\n      <div class=\"px-4 py-2\">\n        <p class=\"uppercase text-xs text-secondary font-medium\"><%= Current.family.name %> &middot; <%= Current.family.users.size %></p>\n      </div>\n      <% @users.each do |user| %>\n        <div class=\"flex gap-2 mt-2 items-center bg-container p-4 shadow-border-xs rounded-lg\">\n          <div class=\"w-9 h-9 shrink-0\">\n            <%= render \"settings/user_avatar\", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials %>\n          </div>\n          <p class=\"text-primary font-medium text-sm\"><%= user.display_name %></p>\n          <div class=\"rounded-md bg-surface px-1.5 py-0.5\">\n            <p class=\"uppercase text-secondary font-medium text-xs\"><%= user.role %></p>\n          </div>\n          <% if Current.user.admin? && user != Current.user %>\n            <div class=\"ml-auto\">\n              <%= render DS::Button.new(\n                variant: \"icon\",\n                icon: \"x\",\n                href: settings_profile_path(user_id: user),\n                method: :delete,\n                confirm: CustomConfirm.for_resource_deletion(user.display_name, high_severity: true)\n              ) %>\n            </div>\n          <% end %>\n        </div>\n      <% end %>\n      <% if @pending_invitations.any? %>\n        <% @pending_invitations.each do |invitation| %>\n          <div class=\"flex gap-2 items-center justify-between bg-container p-4 border border-alpha-black-25 rounded-lg\">\n            <div class=\"flex gap-2 items-center\">\n              <div class=\"w-9 h-9 shrink-0\">\n                <div class=\"fg-inverse w-full h-full bg-surface-inset rounded-full flex items-center justify-center text-lg uppercase\"><%= invitation.email[0] %></div>\n              </div>\n              <div class=\"flex\">\n                <p class=\"text-primary font-medium text-sm\"><%= invitation.email %></p>\n                <div class=\"rounded-md bg-surface px-1.5 py-0.5\">\n                  <p class=\"uppercase text-secondary font-medium text-xs\"><%= t(\".pending\") %></p>\n                </div>\n              </div>\n            </div>\n            <div class=\"flex items-center gap-4\">\n              <% if self_hosted? %>\n                <div class=\"flex items-center gap-2\" data-controller=\"clipboard\">\n                  <p class=\"text-secondary text-sm\"><%= t(\".invitation_link\") %></p>\n                  <span data-clipboard-target=\"source\" class=\"hidden\"><%= accept_invitation_url(invitation.token) %></span>\n                  <input type=\"text\"\n                             readonly\n                             autocomplete=\"off\"\n                             value=\"<%= accept_invitation_url(invitation.token) %>\"\n                             class=\"text-sm bg-gray-50 px-2 py-1 rounded border border-secondary w-72\">\n                  <button data-action=\"clipboard#copy\" class=\"text-secondary hover:text-gray-700\">\n                    <span data-clipboard-target=\"iconDefault\">\n                      <%= icon \"copy\" %>\n                    </span>\n                    <span class=\"hidden\" data-clipboard-target=\"iconSuccess\">\n                      <%= icon \"check\" %>\n                    </span>\n                  </button>\n                </div>\n              <% end %>\n\n              <% if Current.user.admin? %>\n                <%= render DS::Button.new(\n                  variant: \"icon\",\n                  icon: \"x\",\n                  href: invitation_path(invitation),\n                  method: :delete,\n                  confirm: CustomConfirm.for_resource_deletion(invitation.email, high_severity: true)\n                ) %>\n              <% end %>\n            </div>\n          </div>\n        <% end %>\n      <% end %>\n      <% if Current.user.admin? %>\n        <%= link_to new_invitation_path,\n                class: \"bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center\",\n                data: { turbo_frame: :modal } do %>\n          <%= icon(\"plus\") %>\n          <%= t(\".invite_member\") %>\n        <% end %>\n      <% end %>\n    </div>\n  </div>\n<% end %>\n\n<% if Current.user.admin? %>\n  <%= settings_section title: \"Data Import/Export\" do %>\n    <div class=\"space-y-4\">\n      <div class=\"flex gap-4 items-center\">\n        <%= render DS::Link.new(\n          text: \"Export data\",\n          icon: \"database\",\n          href: new_family_export_path,\n          variant: \"secondary\",\n          full_width: true,\n          data: { turbo_frame: :modal }\n        ) %>\n      </div>\n\n      <%= turbo_frame_tag \"family_exports\", src: family_exports_path, loading: :lazy do %>\n        <div class=\"mt-4 text-center text-secondary\">\n          <div class=\"animate-spin inline-block h-4 w-4 border-2 border-secondary border-t-transparent rounded-full\"></div>\n        </div>\n      <% end %>\n    </div>\n  <% end %>\n<% end %>\n\n<%= settings_section title: t(\".danger_zone_title\") do %>\n  <div class=\"space-y-4\">\n    <% if Current.user.admin? %>\n      <div class=\"flex flex-col md:flex-row md:items-center md:justify-between gap-4\">\n        <div class=\"w-full md:w-2/3\">\n          <h3 class=\"font-medium text-primary\"><%= t(\".reset_account\") %></h3>\n          <p class=\"text-secondary text-sm\"><%= t(\".reset_account_warning\") %></p>\n        </div>\n\n        <%= render DS::Button.new(\n          text: t(\".reset_account\"),\n          variant: \"destructive\",\n          href: reset_user_path(@user),\n          method: :delete,\n          confirm: CustomConfirm.new(\n            title: \"Reset account?\",\n            body: \"This will delete all data associated with your account. Your user profile will remain active.\",\n            btn_text: \"Reset account\",\n            destructive: true,\n            high_severity: true\n          )\n        ) %>\n      </div>\n    <% end %>\n    <div class=\"flex flex-col md:flex-row md:items-center md:justify-between gap-4\">\n      <div class=\"w-full md:w-2/3\">\n        <h3 class=\"font-medium text-primary\"><%= t(\".delete_account\") %></h3>\n        <p class=\"text-secondary text-sm\"><%= t(\".delete_account_warning\") %></p>\n      </div>\n\n      <%= render DS::Button.new(\n        text: t(\".delete_account\"),\n        variant: \"destructive\",\n        href: user_path(@user),\n        method: :delete,\n        confirm: CustomConfirm.for_resource_deletion(\"your account\", high_severity: true)\n      ) %>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/settings/securities/show.html.erb",
    "content": "<%= content_for :page_title, t(\".page_title\") %>\n\n<%= settings_section title: t(\".mfa_title\"), subtitle: t(\".mfa_description\") do %>\n  <div class=\"space-y-4\">\n    <div class=\"p-3 shadow-border-xs bg-container rounded-lg md:flex md:justify-between md:items-center\">\n      <div class=\"flex items-center gap-3\">\n        <div class=\"w-9 h-9 rounded-full bg-gray-25 flex justify-center items-center\">\n          <%= icon \"shield-check\" %>\n        </div>\n\n        <div class=\"text-sm space-y-1\">\n          <% if Current.user.otp_required? %>\n            <p class=\"text-primary\">Two-factor authentication is <span class=\"font-medium text-green-600\">enabled</span></p>\n            <p class=\"text-secondary\">Your account is protected with an additional layer of security.</p>\n          <% else %>\n            <p class=\"text-primary\">Two-factor authentication is <span class=\"font-medium text-red-600\">disabled</span></p>\n            <p class=\"text-secondary\">Enable 2FA to add an extra layer of security to your account.</p>\n          <% end %>\n        </div>\n      </div>\n\n      <div class=\"mt-4 md:mt-0\">\n        <% if Current.user.otp_required? %>\n          <%= render DS::Button.new(\n            text: t(\".disable_mfa\"),\n            variant: \"secondary\",\n            href: disable_mfa_path,\n            method: :delete,\n            confirm: CustomConfirm.new(\n              title: t(\".disable_mfa_confirm\"),\n              body: t(\".disable_mfa_confirm\"),\n              btn_text: t(\".disable_mfa\"),\n              destructive: true\n            )\n          ) %>\n        <% else %>\n          <%= render DS::Link.new(\n            text: t(\".enable_mfa\"),\n            variant: \"primary\",\n            href: new_mfa_path\n          ) %>\n        <% end %>\n      </div>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/shared/_app_version.html.erb",
    "content": "<div class=\"flex items-center py-0.5 px-0.5 gap-1 fixed right-1 top-1 shadow-xs border border-alpha-black-50 rounded-md bg-container\">\n  <p class=\"text-xs text-secondary pl-2\">Version: <%= Maybe.version.to_release_tag %></p>\n</div>\n"
  },
  {
    "path": "app/views/shared/_color_avatar.html.erb",
    "content": "<%# locals: (name: nil, color: \"#000\") %>\n\n<% letter = name&.first || \"?\" %>\n\n<% background_color = \"color-mix(in srgb, #{color} 5%, white)\" %>\n<% border_color = \"color-mix(in srgb, #{color} 10%, white)\" %>\n<span data-color-avatar-target=\"avatar\"\n      class=\"w-8 h-8 flex items-center justify-center rounded-full\"\n      style=\"background-color: <%= background_color %>; border-color: <%= border_color %>; color: <%= color %>\">\n  <%= letter.upcase %>\n</span>\n"
  },
  {
    "path": "app/views/shared/_form_errors.html.erb",
    "content": "<%# locals: (model:) %>\n\n<div class=\"flex items-center gap-2\">\n  <%= icon(\"alert-circle\", size: \"sm\", color: \"destructive\") %>\n  <p class=\"text-destructive text-sm\"><%= model.errors.full_messages.to_sentence %></p>\n</div>\n"
  },
  {
    "path": "app/views/shared/_logo.html.erb",
    "content": "<%= link_to image_tag(\"logomark.svg\", class: \"w-auto h-12 mx-auto\"), root_path, data: { turbo: false} %>\n"
  },
  {
    "path": "app/views/shared/_money_field.html.erb",
    "content": "<%# locals: (form:, amount_method:, currency_method:, **options) %>\n\n<% currency_value = if options[:currency_value_override].present?\n                     options[:currency_value_override]\n                   elsif form.object && form.object.respond_to?(currency_method)\n                     form.object.public_send(currency_method)\n                   end\n   currency = Money::Currency.new(currency_value || options[:default_currency] || \"USD\") %>\n\n<div class=\"form-field <%= options[:container_class] %>\" data-controller=\"money-field\">\n  <% if options[:label_tooltip] %>\n    <div class=\"form-field__header\">\n      <%= form.label options[:label] || t(\".label\"), class: \"form-field__label\" do %>\n        <%= options[:label] || t(\".label\") %>\n        <% if options[:required] %>\n          <span class=\"text-red-500 ml-0.5\">*</span>\n        <% end %>\n      <% end %>\n      <div class=\"form-field__actions\">\n        <div data-controller=\"tooltip\">\n          <%= icon \"help-circle\", size: \"sm\", color: \"default\", class: \"cursor-help\" %>\n          <div role=\"tooltip\" data-tooltip-target=\"tooltip\" class=\"tooltip bg-gray-700 text-sm p-2 rounded w-64 text-white\">\n            <%= options[:label_tooltip] %>\n          </div>\n        </div>\n      </div>\n    </div>\n  <% end %>\n\n  <div class=\"form-field__body\">\n    <% unless options[:label_tooltip] %>\n      <%= form.label options[:label] || t(\".label\"), class: \"form-field__label\" do %>\n        <%= options[:label] || t(\".label\") %>\n        <% if options[:required] %>\n          <span class=\"text-red-500 ml-0.5\">*</span>\n        <% end %>\n      <% end %>\n    <% end %>\n\n    <div class=\"flex items-center gap-1\">\n      <div class=\"flex items-center grow gap-1\">\n        <span class=\"text-subdued text-sm\" data-money-field-target=\"symbol\">\n          <%= currency.symbol %>\n        </span>\n\n        <%= form.number_field amount_method,\n          class: \"form-field__input\",\n          inline: true,\n          placeholder: \"100\",\n          value: if options[:value]\n            sprintf(\"%.#{currency.default_precision}f\", options[:value])\n          elsif form.object && form.object.respond_to?(amount_method)\n            val = form.object.public_send(amount_method)\n            sprintf(\"%.#{currency.default_precision}f\", val) if val.present?\n          end,\n          min: options[:min] || -99999999999999,\n          max: options[:max] || 99999999999999,\n          step: currency.step,\n          disabled: options[:disabled],\n          data: {\n            \"money-field-target\": \"amount\",\n            \"auto-submit-form-target\": (\"auto\" if options[:auto_submit])\n          }.compact,\n          required: options[:required] %>\n      </div>\n\n      <% unless options[:hide_currency] %>\n        <div>\n          <%= form.select currency_method,\n                        Money::Currency.as_options.map(&:iso_code),\n                        { inline: true, selected: currency.iso_code },\n                        {\n                          class: \"w-fit pr-5 disabled:text-subdued form-field__input\",\n                          disabled: options[:disable_currency],\n                          data: {\n                            \"money-field-target\": \"currency\",\n                            action: \"change->money-field#handleCurrencyChange\",\n                            \"auto-submit-form-target\": (\"auto\" if options[:auto_submit])\n                          }.compact\n                        } %>\n        </div>\n      <% end %>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/shared/_pagination.html.erb",
    "content": "<%# locals: (pagy:) %>\n\n<nav class=\"flex w-full items-center justify-between\">\n  <div class=\"flex items-center gap-1\">\n    <div>\n      <% if pagy.prev %>\n        <%= link_to pagy_url_for(pagy, pagy.prev),\n              data: { turbo_frame: :_top },\n              class: \"inline-flex items-center p-2 text-sm font-medium text-secondary bg-container-inset hover:border-secondary hover:text-secondary\" do %>\n          <%= icon(\"chevron-left\") %>\n        <% end %>\n      <% else %>\n        <div class=\"inline-flex items-center p-2 text-sm font-medium hover:border-secondary\">\n          <%= icon(\"chevron-left\") %>\n        </div>\n      <% end %>\n    </div>\n    <div class=\"rounded-xl p-1 bg-container-inset\">\n      <% pagy.series.each do |series_item| %>\n        <% if series_item.is_a?(Integer) %>\n          <%= link_to pagy_url_for(pagy, series_item),\n                data: { turbo_frame: :_top },\n                class: \"rounded-md px-2 py-1 inline-flex items-center text-sm font-medium text-secondary hover:border-secondary hover:text-secondary\" do %>\n            <%= series_item %>\n          <% end %>\n        <% elsif series_item.is_a?(String) %>\n          <%= link_to pagy_url_for(pagy, series_item),\n                data: { turbo_frame: :_top },\n                class: \"rounded-md px-2 py-1 bg-container border border-secondary shadow-xs inline-flex items-center text-sm font-medium text-primary\" do %>\n            <%= series_item %>\n          <% end %>\n        <% elsif series_item == :gap %>\n          <span class=\"inline-flex items-center px-2 py-1 text-sm font-medium text-secondary\">...</span>\n        <% end %>\n      <% end %>\n    </div>\n    <div>\n      <% if pagy.next %>\n        <%= link_to pagy_url_for(pagy, pagy.next),\n              data: { turbo_frame: :_top },\n              class: \"inline-flex items-center p-2 text-sm font-medium text-secondary hover:border-secondary hover:text-secondary\" do %>\n          <%= icon(\"chevron-right\") %>\n        <% end %>\n      <% else %>\n        <div class=\"inline-flex items-center p-2 text-sm font-medium hover:border-secondary\">\n          <%= icon(\"chevron-right\") %>\n        </div>\n      <% end %>\n    </div>\n  </div>\n  <div class=\"flex items-center gap-4\">\n    <%= select_tag :per_page,\n                   options_for_select([\"10\", \"20\", \"30\", \"50\"], pagy.limit),\n                   data: { controller: \"selectable-link\" },\n                   class: \"py-1.5 pr-8 text-sm text-primary font-medium bg-container-inset border border-secondary rounded-lg focus:border-secondary focus:ring-secondary focus-visible:ring-secondary\" %>\n  </div>\n</nav>\n"
  },
  {
    "path": "app/views/shared/_progress_circle.html.erb",
    "content": "<%# locals: (progress:, radius: 7, stroke: 2, color: nil) %>\n\n<%\n  circumference = Math::PI * 2 * radius\n  progress_percent = progress.clamp(0, 100)\n  stroke_dashoffset = ((100 - progress_percent) * circumference) / 100\n  center = radius + stroke / 2\n%>\n\n<svg width=\"<%= radius * 2 + stroke %>\" height=\"<%= radius * 2 + stroke %>\">\n  <!-- Background Circle -->\n  <circle\n    class=\"fill-transparent stroke-current text-gray-300\"\n    r=\"<%= radius %>\"\n    cx=\"<%= center %>\"\n    cy=\"<%= center %>\"\n    stroke-width=\"<%= stroke %>\"></circle>\n\n  <!-- Foreground Circle -->\n  <circle\n    class=\"fill-transparent\"\n    style=\"stroke: <%= color || \"var(--color-blue-500)\" %>\"\n    r=\"<%= radius %>\"\n    cx=\"<%= center %>\"\n    cy=\"<%= center %>\"\n    stroke-width=\"<%= stroke %>\"\n    stroke-dasharray=\"<%= circumference %>\"\n    stroke-dashoffset=\"<%= stroke_dashoffset %>\"\n    transform=\"rotate(-90, <%= center %>, <%= center %>)\"></circle>\n</svg>\n"
  },
  {
    "path": "app/views/shared/_ruler.html.erb",
    "content": "<%# locals: (classes: nil) %>\n<hr class=\"border-divider <%= classes || \"mx-4\" %>\">\n"
  },
  {
    "path": "app/views/shared/_sparkline.html.erb",
    "content": "<%# locals: (series:, id:, stroke_width: 1) %>\n\n<%= tag.div(\n  id: id,\n  class: \"h-full w-full\",\n  data: {\n    controller: \"time-series-chart\",\n    \"time-series-chart-data-value\": series.to_json,\n    \"time-series-chart-stroke-width-value\": stroke_width,\n    \"time-series-chart-use-labels-value\": false,\n    \"time-series-chart-use-tooltip-value\": false\n  }\n) %>\n"
  },
  {
    "path": "app/views/shared/_text_tooltip.erb",
    "content": "<div role=\"tooltip\" data-tooltip-target=\"tooltip\" class=\"tooltip bg-gray-700 text-sm px-1.5 py-1 rounded-md\">\n  <div class=\"fg-inverse font-normal max-w-[200px]\">\n    <%= tooltip_text %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/shared/_transaction_type_tabs.html.erb",
    "content": "<%# locals: (active_tab:, account_id: nil) %>\n\n<fieldset class=\"bg-surface-inset rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2\">\n  <%= link_to new_transaction_path(nature: \"outflow\", account_id: account_id),\n              data: { turbo_frame: :modal },\n              class: \"flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'expense' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}\" do %>\n    <%= icon \"minus-circle\" %>\n    <%= tag.span t(\"shared.transaction_tabs.expense\") %>\n  <% end %>\n\n  <%= link_to new_transaction_path(nature: \"inflow\", account_id: account_id),\n              data: { turbo_frame: :modal },\n              class: \"flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'income' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}\" do %>\n    <%= icon \"plus-circle\" %>\n    <%= tag.span t(\"shared.transaction_tabs.income\") %>\n  <% end %>\n\n  <%= link_to new_transfer_path,\n              data: { turbo_frame: :modal },\n              class: \"flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm #{active_tab == 'transfer' ? 'bg-container text-primary shadow-sm' : 'hover:bg-container text-subdued hover:text-primary hover:shadow-sm'}\" do %>\n    <%= icon \"arrow-right-left\" %>\n    <%= tag.span t(\"shared.transaction_tabs.transfer\") %>\n  <% end %>\n</fieldset>\n"
  },
  {
    "path": "app/views/shared/_trend_change.html.erb",
    "content": "<%# locals: { trend:, comparison_label: nil } %>\n\n<p class=\"text-sm\" style=\"color: <%= trend.color %>\">\n  <% if trend.direction.flat? %>\n    <%= t(\".no_change\") %><%= \" #{comparison_label}\" if defined?(comparison_label) && comparison_label.present? %>\n  <% else %>\n    <span class=\"font-mono\">\n      <%= trend.value.is_a?(Money) ? format_money(trend.value) : trend.value.round(2) %>\n    </span>\n    <% unless trend.percent.infinite? %>\n      <span class=\"font-mono\">(<%= icon(trend.icon, size: \"sm\", color: \"current\", class: \"mb-0.5 inline\") %><%= trend.percent_formatted %>)</span>\n    <% end %>\n    <span class=\"text-secondary\">\n      <%= \" #{comparison_label}\" if defined?(comparison_label) && comparison_label.present? %>\n    </span>\n  <% end %>\n</p>\n"
  },
  {
    "path": "app/views/shared/notifications/_alert.html.erb",
    "content": "<%# locals: (message:) %>\n\n<%= tag.div class: \"flex gap-3 rounded-lg bg-container p-4 group w-full md:max-w-80 shadow-border-lg\",\n            data: { controller: \"element-removal\" } do %>\n  <div class=\"h-5 w-5 shrink-0 p-px text-primary\">\n    <div class=\"flex h-full items-center justify-center rounded-full bg-destructive\">\n      <%= icon \"x\", size: \"xs\", color: \"white\" %>\n    </div>\n  </div>\n\n  <%= tag.p message, class: \"text-primary text-sm font-medium\" %>\n\n  <div class=\"ml-auto\">\n    <%= icon \"x\", data: { action: \"click->element-removal#remove\" }, class: \"cursor-pointer\" %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/shared/notifications/_cta.html.erb",
    "content": "<%# locals: (message:, description:) %>\n\n<div id=\"cta\">\n  <%= tag.div class: \"relative flex gap-3 rounded-lg bg-container p-4 group w-full md:max-w-80 shadow-border-xs\", data: { controller: \"element-removal\" } do %>\n    <div class=\"h-5 w-5 shrink-0 p-px text-primary\">\n      <div class=\"flex h-full items-center justify-center rounded-full bg-success fg-inverse\">\n        <%= icon \"check\", size: \"xs\", color: \"current\" %>\n      </div>\n    </div>\n\n    <div class=\"space-y-4\">\n      <div class=\"space-y-1\">\n        <%= tag.p message, class: \"text-primary text-sm font-medium\" %>\n        <%= tag.p description, class: \"text-secondary text-sm\" %>\n      </div>\n\n      <%= yield %>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/shared/notifications/_notice.html.erb",
    "content": "<%# locals: (message:, description: nil) %>\n\n<%= tag.div class: \"relative flex gap-3 rounded-lg bg-container p-4 group w-full md:max-w-80 shadow-border-xs\",\n            data: {\n              controller: \"element-removal\",\n              action: \"animationend->element-removal#remove\"\n            } do %>\n  <div class=\"h-5 w-5 shrink-0 p-px text-primary\">\n    <div class=\"flex h-full items-center justify-center rounded-full bg-success\">\n      <%= icon \"check\", size: \"xs\", color: \"white\" %>\n    </div>\n  </div>\n\n  <div class=\"space-y-4\">\n    <div class=\"space-y-1\">\n      <%= tag.p message, class: \"text-primary text-sm font-medium\" %>\n\n      <% if description %>\n        <%= tag.p description, class: \"text-secondary text-sm\" %>\n      <% end %>\n    </div>\n  </div>\n\n  <div class=\"ml-auto\">\n    <div class=\"h-5 shrink-0\">\n      <svg width=\"20\" height=\"20\" viewBox=\"0 0 20 20\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" class=\"shrink-0\">\n        <path d=\"M18 10C18 14.4183 14.4183 18 10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2C14.4183 2 18 5.58172 18 10ZM3.6 10C3.6 13.5346 6.46538 16.4 10 16.4C13.5346 16.4 16.4 13.5346 16.4 10C16.4 6.46538 13.5346 3.6 10 3.6C6.46538 3.6 3.6 6.46538 3.6 10Z\" fill=\"#E5E5E5\" />\n        <circle class=\"origin-center -rotate-90 animate-stroke-fill\" stroke=\"#141414\" stroke-opacity=\"0.4\" r=\"7.2\" cx=\"10\" cy=\"10\" stroke-dasharray=\"43.9822971503\" stroke-dashoffset=\"43.9822971503\" />\n      </svg>\n    </div>\n  </div>\n\n  <div class=\"absolute -top-2 -right-2\">\n    <%= icon \"x\", class: \"p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-container text-subdued cursor-pointer\", data: { action: \"click->element-removal#remove\" } %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/subscriptions/_plan_choice.html.erb",
    "content": "<%# locals: (plan:, form:, checked: false) %>\n\n<% price = plan == \"annual\" ? 90 : 9 %>\n<% frequency = plan == \"annual\" ? \"/year\" : \"/month\" %>\n\n<div class=\"relative\">\n  <%= form.radio_button :plan, plan, class: \"peer sr-only\", checked: checked %>\n  <%= form.label \"plan_#{plan}\", class: class_names(\n    \"flex flex-col gap-1 p-4 cursor-pointer rounded-lg border border-primary hover:bg-container\",\n    \"peer-checked:bg-container peer-checked:rounded-2xl peer-checked:border-solid peer-checked:ring-4 peer-checked:ring-shadow\",\n    \"transition-all duration-300\"\n  ) do %>\n    <h3 class=\"text-sm text-secondary\"><%= plan.titleize %></h3>\n\n    <div class=\"mt-auto flex items-end gap-1\">\n      <p class=\"font-display text-xl lg:text-3xl font-medium text-primary\">$<%= price %><%= frequency %></p>\n\n      <% if plan == \"annual\" %>\n        <span class=\"text-sm text-secondary mb-1\">or <%= Money.new(price.to_f / 52).format %>/week</span>\n      <% end %>\n    </div>\n\n    <p class=\"text-sm text-secondary\">\n      <% if plan == \"annual\" %>\n        Billed annually, 2 months free\n      <% else %>\n        Billed monthly\n      <% end %>\n    </p>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/subscriptions/upgrade.html.erb",
    "content": "<div class=\"flex flex-col h-full justify-between bg-surface\">\n  <nav class=\"p-4\">\n    <h1 class=\"sr-only\">Upgrade</h1>\n\n    <div class=\"flex justify-end gap-2\">\n      <%= render DS::Link.new(\n        text: \"Account Settings\",\n        icon: \"settings\",\n        variant: \"ghost\",\n        href: settings_profile_path,\n      ) %>\n\n      <%= render DS::Button.new(\n        text: \"Sign out\",\n        icon: \"log-out\",\n        icon_position: :right,\n        variant: \"ghost\",\n        href: session_path(Current.session),\n        method: :delete\n      ) %>\n    </div>\n  </nav>\n\n  <div class=\"grow flex flex-col items-center justify-center\">\n    <%= image_tag \"logo-color.png\", class: \"w-16 mb-6\" %>\n\n    <% if Current.family.trialing? %>\n      <p class=\"text-xl lg:text-3xl text-primary font-display font-medium\">Your trial has <%= Current.family.days_left_in_trial %> days remaining</p>\n    <% else %>\n      <p class=\"text-xl lg:text-3xl text-primary font-display font-medium\">Your trial is over</p>\n    <% end %>\n\n    <h2 class=\"text-xl lg:text-3xl font-display font-medium mb-2\">\n      <span class=\"text-secondary\">Unlock</span>\n      <span class=\"bg-gradient-to-r from-[#EABE7F] to-[#957049] bg-clip-text text-transparent\">Maybe</span>\n      <span class=\"text-secondary\">today</span>\n    </h2>\n\n    <p class=\"text-sm text-secondary mb-8\">To continue using Maybe pick a plan below.</p>\n\n    <%= form_with url: new_subscription_path, method: :get, class: \"max-w-xs\", data: { turbo: false } do |form| %>\n      <div class=\"space-y-4 mb-6\">\n        <%= render \"subscriptions/plan_choice\", form: form, plan: \"annual\", checked: @plan == \"annual\" %>\n        <%= render \"subscriptions/plan_choice\", form: form, plan: \"monthly\", checked: @plan == \"monthly\" %>\n      </div>\n\n      <div class=\"text-center space-y-2\">\n        <%= render DS::Button.new(\n          text: \"Subscribe and unlock Maybe\",\n          variant: \"primary\",\n          full_width: true\n        ) %>\n\n        <p class=\"text-xs text-secondary\">\n          In the next step, you'll be redirected to Stripe which handles our billing.\n        </p>\n      </div>\n    <% end %>\n  </div>\n\n  <%= render \"layouts/shared/footer\" %>\n</div>\n"
  },
  {
    "path": "app/views/tag/deletions/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".delete_tag\"), subtitle: t(\".explanation\", tag_name: @tag.name)) %>\n\n  <% dialog.with_body do %>\n    <%= styled_form_with url: tag_deletions_path(@tag),\n                       data: {\n                         turbo: false,\n                         controller: \"deletion\",\n                         deletion_submit_text_when_not_replacing_value: t(\".delete_and_leave_uncategorized\", tag_name: @tag.name),\n                         deletion_submit_text_when_replacing_value: t(\".delete_and_recategorize\", tag_name: @tag.name) } do |f| %>\n      <%= f.collection_select :replacement_tag_id,\n                            Current.family.tags.alphabetically.without(@tag),\n                            :id, :name,\n                            { prompt: t(\".replacement_tag_prompt\"), label: t(\".tag\"), container_class: \"mb-4\" },\n                            data: { deletion_target: \"replacementField\", action: \"deletion#chooseSubmitButton\" } %>\n\n      <%= render DS::Button.new(\n        variant: \"destructive\",\n        text: t(\".delete_and_leave_uncategorized\", tag_name: @tag.name),\n        full_width: true,\n        data: { deletion_target: \"destructiveSubmitButton\" }\n      ) %>\n\n      <%= render DS::Button.new(\n        text: \"Delete and reassign\",\n        data: { deletion_target: \"safeSubmitButton\" },\n        hidden: true,\n        full_width: true\n      ) %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/tags/_badge.html.erb",
    "content": "<%# locals: (tag:) %>\n<% tag ||= null_category %>\n\n<span class=\"border text-sm font-medium px-2.5 py-1 rounded-full content-center\"\n      style=\"\n      background-color: color-mix(in srgb, <%= tag.color %> 5%, white);\n        border-color: color-mix(in srgb, <%= tag.color %> 10%, white);\n        color: <%= tag.color %>;\">\n  <%= tag.name %>\n</span>\n"
  },
  {
    "path": "app/views/tags/_form.html.erb",
    "content": "<div data-controller=\"color-avatar\">\n  <%= styled_form_with model: tag, class: \"space-y-4\", data: { turbo_frame: :_top } do |f| %>\n    <section class=\"space-y-4\">\n      <div class=\"w-fit m-auto\">\n        <%= render partial: \"shared/color_avatar\", locals: { name: tag.name, color: tag.color } %>\n      </div>\n      <div class=\"flex gap-2 items-center justify-center\">\n        <% Tag::COLORS.each do |color| %>\n          <label class=\"relative\">\n            <%= f.radio_button :color, color, class: \"sr-only peer\", data: { action: \"change->color-avatar#handleColorChange\" } %>\n            <div class=\"w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500\" style=\"background-color: <%= color %>\"></div>\n          </label>\n        <% end %>\n      </div>\n      <div class=\"relative flex items-center border border-secondary rounded-lg text-subdued\">\n        <%= f.text_field :name, placeholder: t(\".placeholder\"), autofocus: true, required: true, data: { color_avatar_target: \"name\" } %>\n      </div>\n    </section>\n\n    <section>\n      <%= hidden_field_tag :tag_id, params[:tag_id] %>\n      <%= f.submit %>\n    </section>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/tags/_tag.html.erb",
    "content": "<%# locals: (tag:) %>\n\n<div id=\"<%= dom_id(tag) %>\" class=\"flex justify-between items-center p-4 bg-container\">\n  <div class=\"flex w-full items-center gap-2.5\">\n    <%= render partial: \"shared/color_avatar\", locals: { name: tag.name, color: tag.color } %>\n    <p class=\"text-primary text-sm truncate\">\n      <%= tag.name %>\n    </p>\n  </div>\n  <div class=\"justify-self-end\">\n    <%= render DS::Menu.new do |menu| %>\n      <% menu.with_item(variant: \"link\", text: t(\".edit\"), href: edit_tag_path(tag), icon: \"pencil\", data: { turbo_frame: \"modal\" }) %>\n\n      <% if tag.transactions.any? %>\n        <% menu.with_item(\n          variant: \"link\",\n          text: t(\".delete\"),\n          href: new_tag_deletion_path(tag),\n          icon: \"trash-2\",\n          frame: :modal\n        ) %>\n      <% else %>\n        <% menu.with_item(\n          variant: \"button\",\n          text: t(\".delete\"),\n          href: tag_path(tag),\n          icon: \"trash-2\",\n          method: :delete,\n          confirm: CustomConfirm.for_resource_deletion(tag.name)\n        ) %>\n      <% end %>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/tags/edit.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".edit\")) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", tag: @tag %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/tags/index.html.erb",
    "content": "<header class=\"flex items-center justify-between\">\n  <h1 class=\"text-primary text-xl font-medium\"><%= t(\".tags\") %></h1>\n\n<div class=\"flex items-center gap-2\">\n  <%= render DS::Menu.new do |menu| %>\n    <% menu.with_item(\n          variant: \"button\",\n          text: \"Delete all\",\n          href: destroy_all_tags_path,\n          method: :delete,\n          icon: \"trash-2\",\n          confirm: CustomConfirm.for_resource_deletion(\"all tags\", high_severity: true)) %>\n  <% end %>\n\n    <%= render DS::Link.new(\n      text: t(\".new\"),\n      variant: \"primary\",\n      href: new_tag_path,\n      icon: \"plus\",\n      frame: :modal\n    ) %>\n\n</div>\n\n</header>\n\n<div class=\"bg-container rounded-xl shadow-border-xs p-4\">\n  <% if @tags.any? %>\n    <div class=\"rounded-xl bg-container-inset space-y-1 p-1\">\n      <div class=\"flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase\">\n        <p><%= t(\".tags\") %></p>\n        <span class=\"text-subdued\">&middot;</span>\n        <p><%= @tags.count %></p>\n      </div>\n\n      <div class=\"bg-container rounded-lg shadow-border-xs\">\n        <div class=\"overflow-hidden rounded-lg\">\n          <%= render partial: @tags, spacer_template: \"shared/ruler\" %>\n        </div>\n      </div>\n    </div>\n  <% else %>\n    <div class=\"flex justify-center items-center py-20\">\n      <div class=\"text-center flex flex-col items-center max-w-[300px]\">\n        <p class=\"text-primary mb-1 font-medium text-sm\"><%= t(\".empty\") %></p>\n\n        <%= render DS::Link.new(\n          text: t(\".new\"),\n          icon: \"plus\",\n          href: new_tag_path,\n          frame: :modal\n        ) %>\n      </div>\n    </div>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/tags/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".new\")) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", tag: @tag %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/trades/_form.html.erb",
    "content": "<%# locals: (model:, account:) %>\n\n<% type = params[:type] || \"buy\" %>\n\n<%= styled_form_with url: trades_path(account_id: account&.id), scope: :model, data: { controller: \"trade-form\" } do |form| %>\n  <div class=\"space-y-4\">\n    <% if model.errors.any? %>\n      <%= render \"shared/form_errors\", model: model %>\n    <% end %>\n\n    <div class=\"space-y-2\">\n      <%= form.select :type, [\n          [\"Buy\", \"buy\"],\n          [\"Sell\", \"sell\"],\n          [\"Deposit\", \"deposit\"],\n          [\"Withdrawal\", \"withdrawal\"],\n          [\"Interest\", \"interest\"]\n      ],\n      { label: t(\".type\"), selected: type },\n      { data: {\n          action: \"trade-form#changeType\",\n          trade_form_url_param: new_trade_path(account_id: account&.id),\n          trade_form_key_param: \"type\",\n        }} %>\n\n      <% if %w[buy sell].include?(type) %>\n        <% if Security.provider.present? %>\n          <div class=\"form-field combobox\">\n            <%= form.combobox :ticker,\n                            securities_path(country_code: Current.family.country),\n                            name_when_new: \"entry[manual_ticker]\",\n                            label: t(\".holding\"),\n                            placeholder: t(\".ticker_placeholder\"),\n                            required: true %>\n          </div>\n        <% else %>\n          <%= form.text_field :manual_ticker, label: \"Ticker symbol\", placeholder: \"AAPL\", required: true %>\n        <% end %>\n      <% end %>\n\n      <%= form.date_field :date, label: true, value: model.date || Date.current, required: true %>\n\n      <% unless %w[buy sell].include?(type) %>\n        <%= form.money_field :amount, label: t(\".amount\"), value: model.amount, required: true %>\n      <% end %>\n\n      <% if %w[deposit withdrawal].include?(type) %>\n        <%= form.collection_select :transfer_account_id, Current.family.accounts.visible.alphabetically, :id, :name, { prompt: t(\".account_prompt\"), label: t(\".account\") } %>\n      <% end %>\n\n      <% if %w[buy sell].include?(type) %>\n        <%= form.number_field :qty, label: t(\".qty\"), placeholder: \"10\", min: 0.000000000000000001, step: \"any\", required: true %>\n        <%= form.money_field :price, label: t(\".price\"), required: true %>\n      <% end %>\n    </div>\n\n    <%= form.submit t(\".submit\") %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/trades/_header.html.erb",
    "content": "<%# locals: (entry:) %>\n\n<div id=\"<%= dom_id(entry, :header) %>\">\n  <%= tag.header class: \"mb-4 space-y-1\" do %>\n    <span class=\"text-secondary text-sm\">\n      <%= entry.amount.negative? ? t(\".sell\") : t(\".buy\") %>\n    </span>\n\n    <div class=\"flex items-center gap-4\">\n      <h3 class=\"font-medium\">\n        <span class=\"text-2xl\">\n          <%= format_money entry.amount_money %>\n        </span>\n\n        <span class=\"text-lg text-secondary\">\n          <%= entry.currency %>\n        </span>\n      </h3>\n\n      <% if entry.linked? %>\n        <span title=\"Linked with Plaid\">\n          <%= icon(\"refresh-ccw\", size: \"sm\") %>\n        </span>\n      <% end %>\n    </div>\n\n    <span class=\"text-sm text-secondary\">\n      <%= I18n.l(entry.date, format: :long) %>\n    </span>\n  <% end %>\n\n  <% trade = entry.trade %>\n\n  <div class=\"mb-2\">\n    <%= render DS::Disclosure.new(title: t(\".overview\"), open: true) do %>\n      <div class=\"pb-4\">\n        <dl class=\"space-y-3 px-3 py-2\">\n          <div class=\"flex items-center justify-between text-sm\">\n            <dt class=\"text-secondary\"><%= t(\".symbol_label\") %></dt>\n            <dd class=\"text-primary\"><%= trade.security.ticker %></dd>\n          </div>\n\n          <% if trade.qty.positive? %>\n            <div class=\"flex items-center justify-between text-sm\">\n              <dt class=\"text-secondary\"><%= t(\".purchase_qty_label\") %></dt>\n              <dd class=\"text-primary\"><%= trade.qty.abs %></dd>\n            </div>\n\n            <div class=\"flex items-center justify-between text-sm\">\n              <dt class=\"text-secondary\"><%= t(\".purchase_price_label\") %></dt>\n              <dd class=\"text-primary\"><%= format_money trade.price_money %></dd>\n            </div>\n          <% end %>\n\n          <% if trade.security.current_price.present? %>\n            <div class=\"flex items-center justify-between text-sm\">\n              <dt class=\"text-secondary\"><%= t(\".current_market_price_label\") %></dt>\n              <dd class=\"text-primary\"><%= format_money trade.security.current_price %></dd>\n            </div>\n          <% end %>\n\n          <% if trade.qty.positive? && trade.unrealized_gain_loss.present? %>\n            <div class=\"flex items-center justify-between text-sm\">\n              <dt class=\"text-secondary\"><%= t(\".total_return_label\") %></dt>\n              <dd style=\"color: <%= trade.unrealized_gain_loss.color %>;\">\n                <%= render \"shared/trend_change\", trend: trade.unrealized_gain_loss %>\n              </dd>\n            </div>\n          <% end %>\n        </dl>\n      </div>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/trades/_trade.html.erb",
    "content": "<%# locals: (entry:, balance_trend: nil, **) %>\n\n<% trade = entry.entryable %>\n\n<%= turbo_frame_tag dom_id(entry) do %>\n  <%= turbo_frame_tag dom_id(trade) do %>\n    <div class=\"grid grid-cols-12 items-center <%= entry.excluded ? \"text-gray-400 bg-gray-25\" : \"text-primary\" %> text-sm font-medium p-4\">\n      <div class=\"col-span-8 flex items-center gap-4\">\n        <%= check_box_tag dom_id(entry, \"selection\"),\n                        class: \"checkbox checkbox--light\",\n                        data: { id: entry.id, \"bulk-select-target\": \"row\", action: \"bulk-select#toggleRowSelection\" } %>\n\n        <div class=\"max-w-full\">\n          <%= tag.div class: [\"flex items-center gap-2\"] do %>\n            <%= render DS::FilledIcon.new(\n              variant: :text,\n              text: entry.name,\n              size: \"sm\",\n              rounded: true\n            ) %>\n\n            <div class=\"truncate\">\n              <%= link_to entry.name,\n                        entry_path(entry),\n                        data: { turbo_frame: \"drawer\", turbo_prefetch: false },\n                        class: \"hover:underline\" %>\n            </div>\n          <% end %>\n        </div>\n      </div>\n\n      <div class=\"col-span-2 flex items-center\">\n        <%= render \"categories/badge\", category: trade_category %>\n      </div>\n\n      <div class=\"col-span-2 justify-self-end font-medium text-sm\">\n        <%= content_tag :p,\n                    format_money(-entry.amount_money),\n                    class: [\"text-green-600\": entry.amount.negative?] %>\n      </div>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/trades/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".title\")) %>\n  <% dialog.with_body do %>\n    <%= render \"trades/form\", model: @model, account: @account %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/trades/show.html.erb",
    "content": "<%= render DS::Dialog.new(variant: \"drawer\") do |dialog| %>\n  <% dialog.with_header do %>\n    <%= render \"trades/header\", entry: @entry %>\n  <% end %>\n\n  <% trade = @entry.trade %>\n\n  <% dialog.with_body do %>\n    <% dialog.with_section(title: t(\".details\"), open: true) do %>\n      <div class=\"pb-4\">\n        <%= styled_form_with model: @entry,\n              url: trade_path(@entry),\n              class: \"space-y-2\",\n              data: { controller: \"auto-submit-form\" } do |f| %>\n          <%= f.date_field :date,\n                label: t(\".date_label\"),\n                max: Date.current,\n                disabled: @entry.linked?,\n                \"data-auto-submit-form-target\": \"auto\" %>\n\n          <div class=\"flex items-center gap-2\">\n            <%= f.select :nature,\n                          [[\"Buy\", \"outflow\"], [\"Sell\", \"inflow\"]],\n                          { container_class: \"w-1/3\", label: \"Type\", selected: @entry.amount.negative? ? \"inflow\" : \"outflow\" },\n                          { data: { \"auto-submit-form-target\": \"auto\" }, disabled: @entry.linked? } %>\n\n            <%= f.fields_for :entryable do |ef| %>\n              <%= ef.number_field :qty,\n                  label: t(\".quantity_label\"),\n                  step: \"any\",\n                  value: trade.qty.abs,\n                  \"data-auto-submit-form-target\": \"auto\",\n                  disabled: @entry.linked? %>\n            <% end %>\n          </div>\n\n          <%= f.fields_for :entryable do |ef| %>\n            <%= ef.money_field :price,\n                  label: t(\".cost_per_share_label\"),\n                  disable_currency: true,\n                  auto_submit: true,\n                  min: 0,\n                  disabled: @entry.linked? %>\n          <% end %>\n        <% end %>\n      </div>\n    <% end %>\n\n    <% dialog.with_section(title: t(\".additional\")) do %>\n      <div class=\"pb-4\">\n        <%= styled_form_with model: @entry,\n              url: trade_path(@entry),\n              class: \"space-y-2\",\n              data: { controller: \"auto-submit-form\" } do |f| %>\n          <%= f.text_area :notes,\n                label: t(\".note_label\"),\n                placeholder: t(\".note_placeholder\"),\n                rows: 5,\n                \"data-auto-submit-form-target\": \"auto\" %>\n        <% end %>\n      </div>\n    <% end %>\n\n    <% dialog.with_section(title: t(\".settings\")) do %>\n      <div class=\"pb-4\">\n        <!-- Exclude Trade Form -->\n        <%= styled_form_with model: @entry,\n              url: trade_path(@entry),\n              class: \"p-3\",\n              data: { controller: \"auto-submit-form\" } do |f| %>\n          <div class=\"flex cursor-pointer items-center gap-2 justify-between\">\n            <div class=\"text-sm space-y-1\">\n              <h4 class=\"text-primary\"><%= t(\".exclude_title\") %></h4>\n              <p class=\"text-secondary\"><%= t(\".exclude_subtitle\") %></p>\n            </div>\n\n            <%= f.toggle :excluded, { data: { auto_submit_form_target: \"auto\" } } %>\n          </div>\n        <% end %>\n\n        <!-- Delete Trade Form -->\n        <div class=\"flex items-center justify-between gap-2 p-3\">\n          <div class=\"text-sm space-y-1\">\n            <h4 class=\"text-primary\"><%= t(\".delete_title\") %></h4>\n            <p class=\"text-secondary\"><%= t(\".delete_subtitle\") %></p>\n          </div>\n\n          <%= button_to t(\".delete\"),\n                entry_path(@entry),\n                method: :delete,\n                class:  \"rounded-lg px-3 py-2 text-red-500 text-sm\n                         font-medium border border-secondary\",\n                data:   { turbo_confirm: true } %>\n        </div>\n      </div>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/transactions/_form.html.erb",
    "content": "<%# locals: (entry:, income_categories:, expense_categories:) %>\n\n<%= styled_form_with model: entry, url: transactions_path, class: \"space-y-4\" do |f| %>\n  <% if entry.errors.any? %>\n    <%= render \"shared/form_errors\", model: entry %>\n  <% end %>\n\n  <section>\n    <%= render \"shared/transaction_type_tabs\", active_tab: params[:nature] == \"inflow\" ? \"income\" : \"expense\", account_id: params[:account_id] %>\n\n    <%= f.hidden_field :nature, value: params[:nature] || \"outflow\" %>\n    <%= f.hidden_field :entryable_type, value: \"Transaction\" %>\n  </section>\n\n  <section class=\"space-y-2\">\n    <%= f.text_field :name, label: t(\".description\"), placeholder: t(\".description_placeholder\"), required: true %>\n\n    <% if @entry.account_id %>\n      <%= f.hidden_field :account_id %>\n    <% else %>\n      <%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :id, :name, { prompt: t(\".account_prompt\"), label: t(\".account\") }, required: true, class: \"form-field__input text-ellipsis\" %>\n    <% end %>\n\n    <%= f.money_field :amount, label: t(\".amount\"), required: true %>\n    <%= f.fields_for :entryable do |ef| %>\n      <% categories = params[:nature] == \"inflow\" ? income_categories : expense_categories %>\n      <%= ef.collection_select :category_id, categories, :id, :name, { prompt: t(\".category_prompt\"), label: t(\".category\") } %>\n    <% end %>\n    <%= f.date_field :date, label: t(\".date\"), required: true, min: Entry.min_supported_date, max: Date.current, value: Date.current %>\n  </section>\n\n  <%= render DS::Disclosure.new(title: t(\".details\")) do %>\n    <%= f.fields_for :entryable do |ef| %>\n      <%= ef.select :tag_ids,\n                    Current.family.tags.alphabetically.pluck(:name, :id),\n                    {\n                      include_blank: t(\".none\"),\n                      multiple: true,\n                      label:    t(\".tags_label\"),\n                      container_class: \"h-40\"\n                    } %>\n    <% end %>\n    <%= f.text_area :notes,\n                    label: t(\".note_label\"),\n                    placeholder: t(\".note_placeholder\"),\n                    rows: 5,\n                    \"data-auto-submit-form-target\": \"auto\" %>\n  <% end %>\n\n  <section>\n    <%= f.submit t(\".submit\") %>\n  </section>\n<% end %>\n"
  },
  {
    "path": "app/views/transactions/_header.html.erb",
    "content": "<%# locals: (entry:) %>\n\n<%= tag.header class: \"mb-4 space-y-1\", id: dom_id(entry, :header) do %>\n  <div class=\"flex items-center gap-4\">\n    <h3 class=\"font-medium\">\n      <span class=\"text-2xl\">\n        <%= format_money -entry.amount_money %>\n      </span>\n\n      <span class=\"text-lg text-secondary\">\n        <%= entry.currency %>\n      </span>\n    </h3>\n\n    <% if entry.transaction.transfer? %>\n      <%= icon \"arrow-left-right\", class: \"mt-1\" %>\n    <% end %>\n\n    <% if entry.linked? %>\n      <span title=\"Linked with Plaid\">\n        <%= icon(\"refresh-ccw\", size: \"sm\") %>\n      </span>\n    <% end %>\n  </div>\n\n  <span class=\"text-sm text-secondary\">\n    <%= I18n.l(entry.date, format: :long) %>\n  </span>\n<% end %>\n"
  },
  {
    "path": "app/views/transactions/_selection_bar.html.erb",
    "content": "<div class=\"fixed bottom-30 md:bottom-6 z-10 flex items-center justify-between rounded-xl bg-gray-900 px-4 text-sm text-white md:w-[420px] w-[90%] py-1.5\">\n  <div class=\"flex items-center gap-2\">\n    <%= check_box_tag \"entry_selection\", 1, true, class: \"checkbox checkbox--dark\", data: { action: \"bulk-select#deselectAll\" } %>\n\n    <p data-bulk-select-target=\"selectionBarText\"></p>\n  </div>\n\n  <div class=\"flex items-center gap-1 text-secondary\">\n    <%= turbo_frame_tag \"bulk_transaction_edit_drawer\" %>\n    <%= link_to new_transactions_bulk_update_path,\n                class: \"p-1.5 group hover:bg-inverse flex items-center justify-center rounded-md\",\n                title: \"Edit\",\n                data: { turbo_frame: \"bulk_transaction_edit_drawer\" } do %>\n      <%= icon \"pencil-line\", class: \"group-hover:text-inverse\" %>\n    <% end %>\n\n    <%= form_with url: transactions_bulk_deletion_path, data: { turbo_confirm: true, turbo_frame: \"_top\" } do %>\n      <button type=\"button\" data-bulk-select-scope-param=\"bulk_delete\" data-action=\"bulk-select#submitBulkRequest\" class=\"p-1.5 group hover:bg-inverse flex items-center justify-center rounded-md\" title=\"Delete\">\n        <%= icon \"trash-2\", class: \"group-hover:text-inverse\" %>\n      </button>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/transactions/_summary.html.erb",
    "content": "<%# locals: (totals:) %>\n<div class=\"grid grid-cols-1 md:grid-cols-3 bg-container rounded-xl shadow-border-xs md:divide-x divide-y md:divide-y-0 divide-alpha-black-100 theme-dark:divide-alpha-white-200\">\n  <div class=\"p-4 space-y-2\">\n    <p class=\"text-sm text-secondary\">Total transactions</p>\n    <p class=\"text-primary font-medium text-xl\" id=\"total-transactions\"><%= totals.count.round(0) %></p>\n  </div>\n  <div class=\"p-4 space-y-2\">\n    <p class=\"text-sm text-secondary\">Income</p>\n    <p class=\"text-primary font-medium text-xl\" id=\"total-income\">\n      <%= totals.income_money.format %>\n    </p>\n  </div>\n  <div class=\"p-4 space-y-2\">\n    <p class=\"text-sm text-secondary\">Expenses</p>\n    <p class=\"text-primary font-medium text-xl\" id=\"total-expense\">\n      <%= totals.expense_money.format %>\n    </p>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/transactions/_transaction.html.erb",
    "content": "<%# locals: (entry:, balance_trend: nil, view_ctx: \"global\") %>\n\n<% transaction = entry.entryable %>\n\n<%= turbo_frame_tag dom_id(entry) do %>\n  <%= turbo_frame_tag dom_id(transaction) do %>\n    <div class=\"grid grid-cols-12 items-center text-primary text-sm font-medium p-4 lg:p-4 <%= entry.excluded ? \"opacity-50 text-gray-400\" : \"\" %>\">\n\n      <div class=\"pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8\">\n        <%= check_box_tag dom_id(entry, \"selection\"),\n            disabled: transaction.transfer.present?,\n            class: \"checkbox checkbox--light\",\n            data: {\n              id: entry.id,\n              \"bulk-select-target\": \"row\",\n              action: \"bulk-select#toggleRowSelection\"\n            } %>\n\n        <div class=\"max-w-full\">\n          <%= content_tag :div, class: [\"flex items-center gap-2\"] do %>\n            <% if transaction.merchant&.logo_url.present? %>\n              <%= image_tag transaction.merchant.logo_url,\n                  class: \"w-6 h-6 rounded-full\",\n                  loading: \"lazy\" %>\n            <% else %>\n              <%= render DS::FilledIcon.new(\n                variant: :text,\n                text: entry.name,\n                size: \"sm\",\n                rounded: true\n              ) %>\n            <% end %>\n\n            <div class=\"truncate\">\n              <div class=\"space-y-0.5\">\n                <div class=\"flex items-center gap-1 min-w-0\">\n                  <div class=\"truncate flex-shrink\">\n                    <% if transaction.transfer? %>\n                      <%= link_to(\n                            entry.name,\n                            transaction.transfer.present? ? transfer_path(transaction.transfer) : entry_path(entry),\n                            data: {\n                              turbo_frame: \"drawer\",\n                              turbo_prefetch: false\n                            },\n                            class: \"hover:underline\"\n                          ) %>\n                    <% else %>\n                      <%= link_to(\n                            entry.name,\n                            entry_path(entry),\n                            data: {\n                              turbo_frame: \"drawer\",\n                              turbo_prefetch: false\n                            },\n                            class: \"hover:underline\"\n                          ) %>\n                    <% end %>\n                  </div>\n\n                  <div class=\"flex items-center gap-1 flex-shrink-0\">\n                    <% if transaction.one_time? %>\n                      <span class=\"text-orange-500\" title=\"One-time <%= entry.amount.negative? ? \"income\" : \"expense\" %> (excluded from averages)\">\n                        <%= icon \"asterisk\", size: \"sm\", color: \"current\" %>\n                      </span>\n                    <% end %>\n\n                    <% if transaction.transfer.present? %>\n                      <%= render \"transactions/transfer_match\", transaction: transaction %>\n                    <% end %>\n                  </div>\n                </div>\n\n                <div class=\"text-secondary text-xs font-normal hidden lg:block\">\n                  <% if transaction.transfer? %>\n                    <span class=\"text-secondary\">\n                      <%= transaction.loan_payment? ? \"Loan Payment\" : \"Transfer\" %> • <%= entry.account.name %>\n                    </span>\n                  <% else %>\n                    <%= link_to entry.account.name,\n                        account_path(entry.account, tab: \"transactions\"),\n                        data: { turbo_frame: \"_top\" },\n                        class: \"hover:underline\" %>\n                  <% end %>\n                </div>\n              </div>\n            </div>\n          <% end %>\n        </div>\n      </div>\n\n      <div class=\"hidden lg:flex items-center gap-1 col-span-2\">\n        <%= render \"transactions/transaction_category\", transaction: transaction %>\n      </div>\n\n      <div class=\"col-span-2 ml-auto text-right\">\n        <%= content_tag :p,\n            transaction.transfer? && view_ctx == \"global\" ? \"+/- #{format_money(entry.amount_money.abs)}\" : format_money(-entry.amount_money),\n            class: [\"text-green-600\": entry.amount.negative?] %>\n      </div>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/transactions/_transaction_category.html.erb",
    "content": "<%# locals: (transaction:) %>\n\n<div id=\"<%= dom_id(transaction, \"category_menu\") %>\">\n  <% if transaction.transfer&.categorizable? || transaction.transfer.nil? %>\n    <%= render \"categories/menu\", transaction: transaction %>\n  <% else %>\n    <%= render \"categories/badge\", category: transaction.transfer&.payment? ? payment_category :  transfer_category %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/transactions/_transfer_match.html.erb",
    "content": "<%# locals: (transaction:) %>\n\n<div id=\"<%= dom_id(transaction, \"transfer_match\") %>\" class=\"flex items-center gap-1\">\n  <% if transaction.transfer.confirmed? %>\n    <span title=\"<%= transaction.transfer.payment? ? \"Payment\" : \"Transfer\" %> is confirmed\">\n      <%= icon \"link-2\", size: \"sm\", class: \"text-secondary\" %>\n    </span>\n  <% elsif transaction.transfer.pending? %>\n    <span class=\"inline-flex items-center rounded-full bg-surface-inset px-2 py-0.5 text-xs font-medium text-secondary\">\n      Auto-matched\n    </span>\n\n    <%= button_to transfer_path(transaction.transfer, transfer: { status: \"confirmed\" }),\n                    method: :patch,\n                    class: \"text-secondary flex items-center justify-center cursor-pointer\",\n                    title: \"Confirm match\" do %>\n      <%= icon \"check\", size: \"sm\", class: \"text-secondary hover:text-primary\" %>\n    <% end %>\n\n    <%= button_to transfer_path(transaction.transfer, transfer: { status: \"rejected\" }),\n                    method: :patch,\n                    data: { turbo: false },\n                    class: \"text-secondary hover:text-primary flex items-center justify-center cursor-pointer\",\n                    title: \"Reject match\" do %>\n      <%= icon \"x\", size: \"sm\", class: \"text-subdued hover:text-primary\" %>\n    <% end %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/transactions/bulk_updates/new.html.erb",
    "content": "<%= render DS::Dialog.new(variant: \"drawer\", frame: \"bulk_transaction_edit_drawer\") do |dialog| %>\n  <% dialog.with_header(title: \"Edit transactions\", data: { bulk_select_target: \"bulkEditDrawerHeader\" }) %>\n\n  <% dialog.with_body do %>\n    <%= styled_form_with url: transactions_bulk_update_path, scope: \"bulk_update\", class: \"h-full flex flex-col justify-between gap-4\", data: { turbo_frame: \"_top\" } do |form| %>\n      <div class=\"space-y-4\">\n        <%= render DS::Disclosure.new(title: \"Overview\", open: true) do %>\n          <div class=\"pb-6 space-y-2\">\n            <%= form.date_field :date, label: \"Date\", max: Date.current %>\n          </div>\n        <% end %>\n\n        <%= render DS::Disclosure.new(title: \"Transactions\", open: true) do %>\n          <div class=\"space-y-2\">\n            <%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: \"Select a category\", label: \"Category\", class: \"text-subdued\" } %>\n            <%= form.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: \"Select a merchant\", label: \"Merchant\", class: \"text-subdued\" } %>\n            <%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: \"None\", multiple: true, label: \"Tags\", container_class: \"h-40\" } %>\n            <%= form.text_area :notes, label: \"Notes\", placeholder: \"Enter a note that will be applied to selected transactions\", rows: 5 %>\n          </div>\n        <% end %>\n      </div>\n\n      <div class=\"flex justify-end gap-2 mt-auto\">\n        <%= render DS::Button.new(text: \"Cancel\", variant: \"ghost\", data: { action: \"click->dialog#close\" }) %>\n        <%= render DS::Button.new(text: \"Save\", data: { bulk_select_scope_param: \"bulk_update\", action: \"bulk-select#submitBulkRequest\" }) %>\n      </div>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/transactions/index.html.erb",
    "content": "<div class=\"space-y-4 pb-20 flex flex-col\">\n  <header class=\"flex justify-between items-center text-primary font-medium\">\n    <h1 class=\"text-xl\">Transactions</h1>\n    <div class=\"flex items-center gap-5\">\n      <div class=\"flex items-center gap-2\">\n        <%= render DS::Menu.new do |menu| %>\n          <% menu.with_item(variant: \"link\", text: \"New rule\", href: new_rule_path(resource_type: \"transaction\"), icon: \"plus\", data: { turbo_frame: :modal }) %>\n          <% menu.with_item(variant: \"link\", text: \"Edit rules\", href: rules_path, icon: \"git-branch\", data: { turbo_frame: :_top }) %>\n          <% menu.with_item(variant: \"link\", text: \"Edit categories\", href: categories_path, icon: \"shapes\", data: { turbo_frame: :_top }) %>\n          <% menu.with_item(variant: \"link\", text: \"Edit tags\", href: tags_path, icon: \"tags\", data: { turbo_frame: :_top }) %>\n          <% menu.with_item(variant: \"link\", text: \"Edit merchants\", href: family_merchants_path, icon: \"store\", data: { turbo_frame: :_top }) %>\n          <% menu.with_item(variant: \"link\", text: \"Edit imports\", href: imports_path, icon: \"hard-drive-upload\", data: { turbo_frame: :_top }) %>\n          <% menu.with_item(variant: \"link\", text: \"Import\", href: new_import_path, icon: \"download\", data: { turbo_frame: \"modal\", class_name: \"md:!hidden\" }) %>\n        <% end %>\n\n        <div class=\"hidden md:flex\">\n          <%= render DS::Link.new(\n            text: t(\".import\"),\n            icon: \"download\",\n            variant: \"outline\",\n            href: new_import_path,\n            frame: :modal,\n          ) %>\n        </div>\n\n        <%= render DS::Link.new(\n            text: \"New transaction\",\n            icon: \"plus\",\n            variant: \"primary\",\n            href: new_transaction_path,\n            frame: :modal,\n            class: \"hidden md:inline-flex\"\n          ) %>\n\n        <%= render DS::Link.new(\n            icon: \"plus\",\n            variant: \"icon-inverse\",\n            href: new_transaction_path,\n            frame: :modal,\n            class: \"rounded-full md:hidden\"\n          ) %>\n      </div>\n    </div>\n  </header>\n\n  <%= render \"summary\", totals: @search.totals %>\n\n  <div id=\"transactions\"\n       data-controller=\"bulk-select\"\n       data-bulk-select-singular-label-value=\"<%= t(\".transaction\") %>\"\n       data-bulk-select-plural-label-value=\"<%= t(\".transactions\") %>\"\n       class=\"flex flex-col bg-container rounded-xl shadow-border-xs p-4\">\n    <%= render \"transactions/searches/search\" %>\n\n    <div id=\"entry-selection-bar\" data-bulk-select-target=\"selectionBar\" class=\"flex justify-center hidden\">\n      <%= render \"transactions/selection_bar\" %>\n    </div>\n\n    <% if @pagy.count > 0 %>\n      <div class=\"grow overflow-y-auto\">\n        <div class=\"grid-cols-12 bg-container-inset rounded-xl px-5 py-3 text-xs uppercase font-medium text-secondary items-center mb-4 hidden md:grid\">\n          <div class=\"pl-0.5 col-span-8 flex items-center gap-4\">\n            <%= check_box_tag \"selection_entry\",\n                              class: \"checkbox checkbox--light\",\n                              data: { action: \"bulk-select#togglePageSelection\" } %>\n            <p>transaction</p>\n          </div>\n\n          <p class=\"col-span-2\">category</p>\n          <p class=\"col-span-2 justify-self-end\">amount</p>\n        </div>\n\n        <% if @transactions.any? %>\n          <div class=\"md:hidden text-xs uppercase font-medium text-secondary mb-2 px-2\">\n            <%= check_box_tag \"selection_entry\",\n                              class: \"checkbox checkbox--light mr-2 ml-1\",\n                              data: { action: \"bulk-select#togglePageSelection\" } %>\n            <span>TRANSACTION</span>\n          </div>\n        <% end %>\n\n        <div class=\"space-y-6\">\n          <%= entries_by_date(@transactions.map(&:entry), totals: true) do |entries| %>\n            <%= render entries %>\n          <% end %>\n        </div>\n      </div>\n    <% else %>\n      <%= render \"entries/empty\" %>\n    <% end %>\n\n    <div class=\"pt-4\">\n      <%= render \"shared/pagination\", pagy: @pagy %>\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/transactions/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: \"New transaction\") %>\n  <% dialog.with_body do %>\n    <%= render \"form\", entry: @entry, income_categories: @income_categories, expense_categories: @expense_categories %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/transactions/searches/_form.html.erb",
    "content": "<%= form_with url: transactions_path,\n              id: \"transactions-search\",\n              scope: :q,\n              method: :get,\n              data: { controller: \"auto-submit-form\" } do |form| %>\n  <%= hidden_field_tag :per_page, params[:per_page] %>\n\n  <div class=\"flex gap-2 mb-4\">\n    <div class=\"grow\">\n      <div class=\"flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-secondary focus-within:border-secondary\">\n        <%= icon(\"search\") %>\n        <%= form.text_field :search,\n                            placeholder: \"Search transactions ...\",\n                            value: @q[:search],\n                            class: \"form-field__input placeholder:text-sm placeholder:text-secondary\",\n                            \"data-auto-submit-form-target\": \"auto\" %>\n      </div>\n    </div>\n\n    <%= render DS::Menu.new(variant: \"button\", no_padding: true) do |menu| %>\n      <% menu.with_button(\n        id: \"transaction-filters-button\",\n        type: \"button\",\n        text: \"Filter\",\n        variant: \"outline\",\n        icon: \"list-filter\"\n      ) %>\n\n      <% menu.with_custom_content do %>\n        <%= render \"transactions/searches/menu\", form: form %>\n      <% end %>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/transactions/searches/_menu.html.erb",
    "content": "<%# locals: (form:) %>\n\n<%= render DS::Tabs.new(\n  variant: :unstyled,\n  active_tab: get_default_transaction_search_filter[:key],\n  active_btn_classes: \"bg-surface text-primary\",\n  inactive_btn_classes: \"text-secondary hover:bg-container-inset\"\n) do |tabs| %>\n  <div id=\"transaction-filters-menu\" class=\"flex flex-col md:flex-row h-[50vh] lg:max-h-auto z-10 md:h-80 w-full md:w-[540px] top-12 right-0 overflow-hidden\">\n    <%= tabs.with_nav(classes: \"shrink-0 flex w-full md:w-44 flex-row md:flex-col items-start p-3 text-sm font-medium text-secondary border-b md:border-b-0 md:border-r border-secondary overflow-x-auto md:overflow-x-visible\") do |nav| %>\n      <% transaction_search_filters.each do |filter| %>\n        <%= nav.with_btn(id: filter[:key], label: filter[:label], classes: \"w-full px-3 py-2 flex gap-2 items-center rounded-md\") do %>\n          <%= icon(filter[:icon]) %>\n          <%= tag.span(filter[:label], class: \"text-sm font-medium\") %>\n        <% end %>\n      <% end %>\n    <% end %>\n\n    <div class=\"flex flex-col grow overflow-y-auto\">\n      <div class=\"grow p-3 border-b border-secondary overflow-y-auto\">\n        <% transaction_search_filters.each do |filter| %>\n          <%= tabs.with_panel(tab_id: filter[:key]) do %>\n            <%= render partial: get_transaction_search_filter_partial_path(filter), locals: { form: form } %>\n          <% end %>\n        <% end %>\n      </div>\n\n      <div class=\"flex justify-between items-center gap-2 bg-container p-3 shrink-0\">\n        <div>\n          <% if @q.present? %>\n            <%= render DS::Link.new(\n            text: t(\".clear_filters\"),\n            variant: \"ghost\",\n            href: transactions_path(clear_filters: true),\n          ) %>\n          <% end %>\n        </div>\n\n        <div>\n          <%= render DS::Button.new(text: t(\".cancel\"), type: \"button\", variant: \"ghost\", data: { action: \"DS--menu#close\" }) %>\n          <%= render DS::Button.new(text: t(\".apply\")) %>\n        </div>\n      </div>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/transactions/searches/_search.html.erb",
    "content": "<%= render \"transactions/searches/form\" %>\n\n<ul id=\"transaction-search-filters\" class=\"flex items-center flex-wrap gap-2 mb-4\">\n  <% @q.reject { |key| key == \"amount_operator\" }.each do |param_key, param_value| %>\n    <% unless param_value.blank? %>\n      <% if param_key == \"amount\" %>\n        <% amount_operator = case @q[:amount_operator]\n                             when \"equal\"\n                               t(\".equal_to\")\n                             when \"greater\"\n                               t(\".greater_than\")\n                             when \"less\"\n                               t(\".less_than\")\n                             else\n                               t(@q[:amount_operator])\n                             end %>\n        <%= render partial: \"transactions/searches/filters/badge\", locals: { param_key: \"amount\", param_value: \"#{amount_operator} #{param_value}\" } %>\n      <% else %>\n        <% Array(param_value).each do |value| %>\n          <%= render partial: \"transactions/searches/filters/badge\", locals: { param_key: param_key, param_value: value } %>\n        <% end %>\n      <% end %>\n    <% end %>\n  <% end %>\n</ul>\n"
  },
  {
    "path": "app/views/transactions/searches/filters/_account_filter.html.erb",
    "content": "<%# locals: (form:) %>\n<div data-controller=\"list-filter\">\n  <div class=\"relative\">\n    <input type=\"search\" autocomplete=\"off\" placeholder=\"Filter accounts\" data-list-filter-target=\"input\" data-action=\"input->list-filter#filter\" class=\"block w-full border border-secondary rounded-md py-2 pl-10 pr-3 bg-container focus:ring-gray-500 sm:text-sm\">\n    <%= icon(\"search\", class: \"absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2\") %>\n  </div>\n  <div class=\"my-2\" id=\"list\" data-list-filter-target=\"list\">\n    <% Current.family.accounts.alphabetically.each do |account| %>\n      <div class=\"filterable-item flex items-center gap-2 p-2\" data-filter-name=\"<%= account.name %>\">\n        <%= form.check_box :accounts,\n                           {\n                             multiple: true,\n                             checked: @q[:accounts]&.include?(account.name),\n                             class: \"checkbox checkbox--light\"\n                           },\n                           account.name,\n                           nil %>\n        <%= form.label :accounts, account.name, value: account.name, class: \"text-sm text-primary\" %>\n      </div>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/transactions/searches/filters/_amount_filter.html.erb",
    "content": "<%# locals: (form:) %>\n\n<div class=\"space-y-2\">\n  <div class=\"form-field\">\n    <%= form.select :amount_operator, options_for_select([\n      [t(\".equal_to\"), \"equal\"],\n      [t(\".greater_than\"), \"greater\"],\n      [t(\".less_than\"), \"less\"]\n    ], @q[:amount_operator] || \"equal\"), {}, class: \"form-field__input\" %>\n  </div>\n\n  <div class=\"form-field\">\n    <%= form.number_field :amount, step: 0.01, class: \"form-field__input\", placeholder: t(\".placeholder\"), value: @q[:amount] %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/transactions/searches/filters/_badge.html.erb",
    "content": "<%# locals: (param_key:, param_value:) %>\n<li class=\"flex items-center gap-1 text-sm border border-secondary rounded-3xl p-1.5\">\n  <% if param_key == \"start_date\" || param_key == \"end_date\" %>\n    <div class=\"flex items-center gap-2\">\n      <%= icon \"calendar\" %>\n      <p>\n        <% if param_key == \"start_date\" %>\n          <%= t(\".on_or_after\", date: param_value) %>\n        <% else %>\n          <%= t(\".on_or_before\", date: param_value) %>\n        <% end %>\n      </p>\n    </div>\n  <% elsif param_key == \"search\" %>\n    <div class=\"flex items-center gap-2\">\n      <%= icon \"text\" %>\n      <p><%= \"\\\"#{param_value}\\\"\".truncate(20) %></p>\n    </div>\n  <% elsif param_key == \"accounts\" %>\n    <div class=\"flex items-center gap-2\">\n      <div class=\"w-5 h-5 bg-blue-600/10 text-xs flex items-center justify-center rounded-full\"><%= param_value[0].upcase %></div>\n      <p><%= param_value %></p>\n    </div>\n  <% elsif param_key == \"amount\" %>\n    <div class=\"flex items-center gap-2\">\n      <%= icon \"hash\" %>\n      <p><%= param_value %></p>\n    </div>\n  <% elsif param_key == \"types\" %>\n    <div class=\"flex items-center gap-2 px-1\">\n      <div class=\"w-1 h-3 rounded-full <%= case param_value.downcase\n                                          when \"income\" then \"bg-green-500\"\n                                          when \"expense\" then \"bg-red-500\"\n                                          when \"transfer\" then \"bg-blue-500\"\n                                          end %>\"></div>\n      <p><%= t(\".#{param_value.downcase}\") %></p>\n    </div>\n  <% else %>\n    <div class=\"flex items-center gap-2\">\n      <p><%= param_value %></p>\n    </div>\n  <% end %>\n\n  <%= button_to clear_filter_transactions_path(param_key: param_key, param_value: param_value, **request.query_parameters),\n        method: :delete,\n        data: { turbo: false },\n        class: \"flex items-center cursor-pointer\" do %>\n    <%= icon \"x\", size: \"sm\", class: \"hover:text-primary\" %>\n  <% end %>\n</li>\n"
  },
  {
    "path": "app/views/transactions/searches/filters/_category_filter.html.erb",
    "content": "<%# locals: (form:) %>\n<div data-controller=\"list-filter\">\n  <div class=\"relative\">\n    <input type=\"search\" autocomplete=\"off\" placeholder=\"Filter category\" data-list-filter-target=\"input\" data-action=\"input->list-filter#filter\" class=\"block w-full bg-container border border-secondary rounded-md py-2 pl-10 pr-3 focus:ring-gray-500 sm:text-sm\">\n    <%= icon(\"search\", class: \"absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2\") %>\n  </div>\n  <div class=\"my-2\" id=\"list\" data-list-filter-target=\"list\">\n    <% family_categories.each do |category| %>\n      <div class=\"filterable-item flex items-center gap-2 p-2\" data-filter-name=\"<%= category.name %>\">\n        <%= form.check_box :categories,\n                           {\n                             multiple: true,\n                             checked: @q[:categories]&.include?(category.name),\n                             class: \"checkbox checkbox--light\"\n                           },\n                           category.name,\n                           nil %>\n        <%= form.label :categories, category.name, value: category.name, class: \"text-sm text-primary cursor-pointer\" do %>\n          <%= render partial: \"categories/badge\", locals: { category: category } %>\n        <% end %>\n      </div>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/transactions/searches/filters/_date_filter.html.erb",
    "content": "<%# locals: (form:) %>\n<div class=\"p-3\">\n  <%= form.date_field :start_date,\n                      placeholder: \"Start date\",\n                      value: @q[:start_date],\n                      class: \"block w-full border border-secondary rounded-md bg-container py-2 pl-3 pr-3  focus:ring-gray-500 sm:text-sm\" %>\n  <%= form.date_field :end_date,\n                      placeholder: \"End date\",\n                      value: @q[:end_date],\n                      class: \"block w-full border border-secondary rounded-md bg-container py-2 pl-3 pr-3  focus:ring-gray-500 sm:text-sm mt-2\" %>\n</div>\n"
  },
  {
    "path": "app/views/transactions/searches/filters/_merchant_filter.html.erb",
    "content": "<%# locals: (form:) %>\n<div data-controller=\"list-filter\">\n  <div class=\"relative\">\n    <input type=\"search\" autocomplete=\"off\" placeholder=\"Filter merchants\" data-list-filter-target=\"input\" data-action=\"input->list-filter#filter\" class=\"block w-full bg-container border border-secondary rounded-md py-2 pl-10 pr-3 focus:ring-gray-500 sm:text-sm\">\n    <%= icon(\"search\", class: \"absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2\") %>\n  </div>\n  <div class=\"my-2\" id=\"list\" data-list-filter-target=\"list\">\n    <% Current.family.assigned_merchants.alphabetically.each do |merchant| %>\n      <div class=\"filterable-item flex items-center gap-2 p-2\" data-filter-name=\"<%= merchant.name %>\">\n        <%= form.check_box :merchants,\n                           {\n                             multiple: true,\n                             checked: @q[:merchants]&.include?(merchant.name),\n                             class: \"checkbox checkbox--light\"\n                           },\n                           merchant.name,\n                           nil %>\n        <%= form.label :merchants, value: merchant.name, class: \"text-sm text-primary flex items-center gap-2\" do %>\n          <%= render DS::FilledIcon.new(\n            variant: :text,\n            hex_color: merchant.color,\n            text: merchant.name,\n            size: \"sm\",\n            rounded: true\n          ) %>\n\n          <%= merchant.name %>\n        <% end %>\n      </div>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/transactions/searches/filters/_tag_filter.html.erb",
    "content": "<%# locals: (form:) %>\n<div data-controller=\"list-filter\">\n  <div class=\"relative\">\n    <input type=\"search\" autocomplete=\"off\" placeholder=\"Filter tags\" data-list-filter-target=\"input\" data-action=\"input->list-filter#filter\" class=\"block w-full bg-container border border-secondary rounded-md py-2 pl-10 pr-3 focus:ring-gray-500 sm:text-sm\">\n    <%= icon(\"search\", class: \"absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2\") %>\n  </div>\n  <div class=\"my-2\" id=\"list\" data-list-filter-target=\"list\">\n    <% Current.family.tags.alphabetically.each do |tag| %>\n      <div class=\"filterable-item flex items-center gap-2 p-2\" data-filter-name=\"<%= tag.name %>\">\n        <%= form.check_box :tags,\n                           {\n                             multiple: true,\n                             checked: @q[:tags]&.include?(tag.name),\n                             class: \"checkbox checkbox--light\"\n                           },\n                           tag.name,\n                           nil %>\n        <%= form.label :tags, value: tag.name, class: \"text-sm text-primary flex items-center gap-2\" do %>\n          <%= render DS::FilledIcon.new(\n            variant: :text,\n            hex_color: tag.color || Tag::UNCATEGORIZED_COLOR,\n            text: tag.name,\n            size: \"sm\",\n            rounded: true\n          ) %>\n\n          <%= tag.name %>\n        <% end %>\n      </div>\n    <% end %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/transactions/searches/filters/_type_filter.html.erb",
    "content": "<%# locals: (form:) %>\n\n<div class=\"p-2 space-y-3\">\n  <div class=\"flex items-center gap-3\" data-filter-name=\"income\">\n    <%= form.check_box :types,\n                           {\n                             multiple: true,\n                             checked: @q[:types]&.include?(\"income\"),\n                             class: \"checkbox checkbox--light\"\n                           },\n                           \"income\",\n                           nil %>\n    <%= form.label :types, t(\".income\"), value: \"income\", class: \"text-sm text-primary\" %>\n  </div>\n  <div class=\"flex items-center gap-3\" data-filter-name=\"expense\">\n    <%= form.check_box :types,\n                           {\n                             multiple: true,\n                             checked: @q[:types]&.include?(\"expense\"),\n                             class: \"checkbox checkbox--light\"\n                           },\n                           \"expense\",\n                           nil %>\n    <%= form.label :types, t(\".expense\"), value: \"expense\", class: \"text-sm text-primary\" %>\n  </div>\n  <div class=\"flex items-center gap-3\" data-filter-name=\"transfer\">\n    <%= form.check_box :types,\n                           {\n                             multiple: true,\n                             checked: @q[:types]&.include?(\"transfer\"),\n                             class: \"checkbox checkbox--light\"\n                           },\n                           \"transfer\",\n                           nil %>\n    <%= form.label :types, t(\".transfer\"), value: \"transfer\", class: \"text-sm text-primary\" %>\n  </div>\n</div>\n"
  },
  {
    "path": "app/views/transactions/show.html.erb",
    "content": "<%= render DS::Dialog.new(variant: \"drawer\") do |dialog| %>\n  <% dialog.with_header do %>\n    <%= render \"transactions/header\", entry: @entry %>\n  <% end %>\n\n  <% dialog.with_body do %>\n    <% dialog.with_section(title: t(\".overview\"), open: true) do %>\n      <div class=\"pb-4\">\n        <%= styled_form_with model: @entry,\n              url: transaction_path(@entry),\n              class: \"space-y-2\",\n              data: { controller: \"auto-submit-form\" } do |f| %>\n\n          <%= f.text_field :name,\n                label: t(\".name_label\"),\n                \"data-auto-submit-form-target\": \"auto\" %>\n\n          <%= f.date_field :date,\n                label: t(\".date_label\"),\n                max: Date.current,\n                disabled: @entry.linked?,\n                \"data-auto-submit-form-target\": \"auto\" %>\n\n          <% unless @entry.transaction.transfer? %>\n            <div class=\"flex items-center gap-2\">\n              <%= f.select :nature,\n                    [[\"Expense\", \"outflow\"], [\"Income\", \"inflow\"]],\n                    { container_class: \"w-1/3\", label: t(\".nature\"), selected: @entry.amount.negative? ? \"inflow\" : \"outflow\" },\n                    { data: { \"auto-submit-form-target\": \"auto\" }, disabled: @entry.linked? } %>\n\n              <%= f.money_field :amount, label: t(\".amount\"),\n                    container_class: \"w-2/3\",\n                    auto_submit: true,\n                    min: 0,\n                    value: @entry.amount.abs,\n                    disabled: @entry.linked?,\n                    disable_currency: @entry.linked? %>\n            </div>\n\n            <%= f.fields_for :entryable do |ef| %>\n              <%= ef.collection_select :category_id,\n                      Current.family.categories.alphabetically,\n                    :id, :name,\n                    { label: t(\".category_label\"),\n                      class: \"text-subdued\", include_blank: t(\".uncategorized\") },\n                    \"data-auto-submit-form-target\": \"auto\" %>\n            <% end %>\n          <% end %>\n\n        <% end %>\n      </div>\n    <% end %>\n\n    <% dialog.with_section(title: t(\".details\")) do %>\n      <div class=\"pb-4\">\n        <%= styled_form_with model: @entry,\n              url: transaction_path(@entry),\n              class: \"space-y-2\",\n              data: { controller: \"auto-submit-form\" } do |f| %>\n          <% unless @entry.transaction.transfer? %>\n            <%= f.select :account,\n                options_for_select(\n                  Current.family.accounts.alphabetically.pluck(:name, :id),\n                  @entry.account_id\n                ),\n                { label: t(\".account_label\") },\n                { disabled: true } %>\n\n            <%= f.fields_for :entryable do |ef| %>\n\n              <%= ef.collection_select :merchant_id,\n                    [@entry.transaction.merchant, *Current.family.merchants.alphabetically].compact,\n                    :id, :name,\n                    { include_blank: t(\".none\"),\n                      label: t(\".merchant_label\"),\n                      class: \"text-subdued\" },\n                    \"data-auto-submit-form-target\": \"auto\" %>\n\n              <%= ef.select :tag_ids,\n                    Current.family.tags.alphabetically.pluck(:name, :id),\n                    {\n                      include_blank: t(\".none\"),\n                      multiple: true,\n                      label:    t(\".tags_label\"),\n                      container_class: \"h-40\"\n                    },\n                    { \"data-auto-submit-form-target\": \"auto\" } %>\n            <% end %>\n          <% end %>\n\n          <%= f.text_area :notes,\n                  label: t(\".note_label\"),\n                  placeholder: t(\".note_placeholder\"),\n                  rows: 5,\n                  \"data-auto-submit-form-target\": \"auto\" %>\n\n        <% end %>\n      </div>\n    <% end %>\n\n    <% dialog.with_section(title: t(\".settings\")) do %>\n      <div class=\"pb-4\">\n        <%= styled_form_with model: @entry,\n              url: transaction_path(@entry),\n              class: \"p-3\",\n              data: { controller: \"auto-submit-form\" } do |f| %>\n          <div class=\"flex cursor-pointer items-center gap-4 justify-between\">\n            <div class=\"text-sm space-y-1\">\n              <h4 class=\"text-primary\">Exclude</h4>\n              <p class=\"text-secondary\">Excluded transactions will be removed from budgeting calculations and reports.</p>\n            </div>\n\n            <%= f.toggle :excluded, { data: { auto_submit_form_target: \"auto\" } } %>\n          </div>\n        <% end %>\n      </div>\n\n      <div class=\"pb-4\">\n        <%= styled_form_with model: @entry,\n              url: transaction_path(@entry),\n              class: \"p-3\",\n              data: { controller: \"auto-submit-form\" } do |f| %>\n          <%= f.fields_for :entryable do |ef| %>\n            <div class=\"flex cursor-pointer items-center gap-4 justify-between\">\n              <div class=\"text-sm space-y-1\">\n                <h4 class=\"text-primary\">One-time <%= @entry.amount.negative? ? \"Income\" : \"Expense\" %></h4>\n                <p class=\"text-secondary\">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>\n              </div>\n\n              <%= ef.toggle :kind, {\n                    checked: @entry.transaction.one_time?,\n                    data: { auto_submit_form_target: \"auto\" }\n                  }, \"one_time\", \"standard\" %>\n            </div>\n          <% end %>\n        <% end %>\n\n        <div class=\"flex items-center justify-between gap-4 p-3\">\n          <div class=\"text-sm space-y-1\">\n            <h4 class=\"text-primary\">Transfer or Debt Payment?</h4>\n            <p class=\"text-secondary\">Transfers and payments are special types of transactions that indicate money movement between 2 accounts.</p>\n          </div>\n\n          <%= render DS::Link.new(\n            text: \"Open matcher\",\n            icon: \"arrow-left-right\",\n            variant: \"outline\",\n            href: new_transaction_transfer_match_path(@entry),\n            frame: :modal\n          ) %>\n        </div>\n\n        <!-- Delete Transaction Form -->\n        <div class=\"flex items-center justify-between gap-2 p-3\">\n          <div class=\"text-sm space-y-1\">\n            <h4 class=\"text-primary\"><%= t(\".delete_title\") %></h4>\n            <p class=\"text-secondary\"><%= t(\".delete_subtitle\") %></p>\n          </div>\n\n          <%= render DS::Button.new(\n            text: t(\".delete\"),\n            variant: \"outline-destructive\",\n            href: entry_path(@entry),\n            method: :delete,\n            confirm: CustomConfirm.for_resource_deletion(\"transaction\"),\n            frame: \"_top\"\n          ) %>\n        </div>\n      </div>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/transfer_matches/_matching_fields.html.erb",
    "content": "<%# locals: (form:, entry:, candidates:, accounts:) %>\n\n<% if candidates.any? %>\n  <div data-controller=\"transfer-match\" class=\"space-y-2\">\n    <p class=\"text-sm text-secondary\">\n      Select a method for matching your transactions.\n    </p>\n\n    <%= form.select :method,\n              [\n                [\"Match existing transaction (recommended)\", \"existing\"],\n                [\"Create new transaction\", \"new\"]\n              ],\n              { selected: \"existing\", label: \"Matching method\" },\n              data: { action: \"change->transfer-match#update\" } %>\n\n    <div data-transfer-match-target=\"existingSelect\">\n      <%= form.select :matched_entry_id,\n                candidates.map { |entry|\n                  [entry_name_detailed(entry), entry.id]\n                },\n                { label: \"Matching transaction\" } %>\n    </div>\n\n    <div data-transfer-match-target=\"newSelect\" class=\"hidden\">\n      <%= form.select :target_account_id,\n                accounts.map { |account| [account.name, account.id] },\n                { label: \"Target account\" } %>\n    </div>\n  </div>\n<% else %>\n  <p class=\"text-sm text-secondary\">\n    We couldn't find any transactions to match from your other accounts.\n    Please select an account and we will create a new inflow transaction for you.\n  </p>\n\n  <%= form.hidden_field :method, value: \"new\" %>\n\n  <div>\n    <%= form.select :target_account_id,\n                accounts.map { |account| [account.name, account.id] },\n                { label: \"Target account\" } %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/transfer_matches/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: \"Match transfer or payment\") %>\n  <% dialog.with_body do %>\n    <%= styled_form_with(\n          url: transaction_transfer_match_path(@entry),\n          scope: :transfer_match,\n          class: \"space-y-8\",\n          data: { turbo_frame: :_top }\n        ) do |f| %>\n      <section class=\"space-y-4\">\n        <div class=\"space-y-2\">\n          <h2 class=\"text-sm font-medium text-gray-700\">\n            <%= @entry.amount.positive? ? \"From account: #{@entry.account.name}\" : \"From account\" %>\n          </h2>\n\n          <% if @entry.amount.positive? %>\n            <%= f.select(\n                  :entry_id,\n                  [[entry_name_detailed(@entry), @entry.id]],\n                  {\n                    label: \"Outflow transaction\",\n                    selected: @entry.id,\n                  },\n                  disabled: true\n                ) %>\n          <% else %>\n            <%= render \"transfer_matches/matching_fields\",\n                        form: f, entry: @entry, candidates: @transfer_match_candidates.map { |pm| pm.outflow_transaction.entry }, accounts: @accounts %>\n          <% end %>\n        </div>\n      </section>\n\n      <div class=\"flex justify-center py-2\">\n        <%= icon \"arrow-down\" %>\n      </div>\n\n      <section class=\"space-y-4\">\n        <div class=\"space-y-2\">\n          <h2 class=\"text-sm font-medium text-gray-700\">\n            <%= @entry.amount.negative? ? \"To account: #{@entry.account.name}\" : \"To account\" %>\n          </h2>\n\n          <% if @entry.amount.negative? %>\n            <%= f.select(\n                  :entry_id,\n                  [[entry_name_detailed(@entry), @entry.id]],\n                  {\n                    label: \"Inflow transaction\",\n                    selected: @entry.id,\n                  },\n                  disabled: true\n                ) %>\n          <% else %>\n            <%= render \"transfer_matches/matching_fields\",\n                        form: f, entry: @entry, candidates: @transfer_match_candidates.map { |pm| pm.inflow_transaction.entry }, accounts: @accounts %>\n          <% end %>\n        </div>\n      </section>\n\n      <%= f.submit \"Create transfer match\" %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/transfers/_account_links.html.erb",
    "content": "<%# locals: (transfer:, is_inflow: false) %>\n<div class=\"flex items-center gap-1\">\n  <% first_account, second_account = is_inflow ? [transfer.to_account, transfer.from_account] : [transfer.from_account, transfer.to_account] %>\n\n  <%# Check if first_account exists before creating link %>\n  <% if first_account %>\n    <%= link_to first_account.name, account_path(first_account, tab: \"activity\"), class: \"hover:underline\", data: { turbo_frame: \"_top\" } %>\n  <% else %>\n    <span class=\"text-warning text-xs italic\" title=\"Transfer ID: <%= transfer.id %>\">\n      Data Error: Missing account\n    </span>\n  <% end %>\n\n  <%# Use icon helper per conventions %>\n  <%= icon(is_inflow ? \"arrow-left\" : \"arrow-right\", size: \"sm\") %>\n\n  <%# Check if second_account exists before creating link %>\n  <% if second_account %>\n    <%= link_to second_account.name, account_path(second_account, tab: \"activity\"), class: \"hover:underline\", data: { turbo_frame: \"_top\" } %>\n  <% else %>\n    <span class=\"text-warning text-xs italic\" title=\"Transfer ID: <%= transfer.id %>\">\n      Data Error: Missing account\n    </span>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/transfers/_form.html.erb",
    "content": "<%= styled_form_with model: transfer, class: \"space-y-4\", data: { turbo_frame: \"_top\", controller: \"transfer-form\" } do |f| %>\n  <% if transfer.errors.present? %>\n    <div class=\"text-destructive flex items-center gap-2\">\n      <%= icon \"circle-alert\", size: \"sm\" %>\n      <p class=\"text-sm\"><%= @transfer.errors.full_messages.to_sentence %></p>\n    </div>\n  <% end %>\n\n  <section>\n    <%= render \"shared/transaction_type_tabs\", active_tab: \"transfer\" %>\n  </section>\n\n  <section class=\"space-y-2\">\n    <%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(\".select_account\"), label: t(\".from\") }, required: true %>\n    <%= f.collection_select :to_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(\".select_account\"), label: t(\".to\") }, required: true %>\n    <%= f.number_field :amount, label: t(\".amount\"), required: true, min: 0, placeholder: \"100\", step: 0.00000001 %>\n    <%= f.date_field :date, value: transfer.inflow_transaction&.entry&.date || Date.current, label: t(\".date\"), required: true, max: Date.current %>\n  </section>\n\n  <section>\n    <%= f.submit t(\".submit\") %>\n  </section>\n<% end %>\n"
  },
  {
    "path": "app/views/transfers/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".title\")) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", transfer: @transfer %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/transfers/show.html.erb",
    "content": "<%= render DS::Dialog.new(variant: \"drawer\") do |dialog| %>\n  <% dialog.with_header do %>\n    <div class=\"space-y-1\">\n      <div class=\"flex items-center gap-4\">\n        <h3 class=\"font-medium\">\n          <span class=\"text-2xl\">\n            <%= format_money @transfer.amount_abs %>\n          </span>\n\n          <span class=\"text-lg text-secondary\">\n            <%= @transfer.amount_abs.currency.iso_code %>\n          </span>\n        </h3>\n\n        <%= icon \"arrow-left-right\", size: \"sm\" %>\n      </div>\n\n      <span class=\"text-sm text-secondary\">\n        <%= @transfer.name %>\n      </span>\n    </div>\n  <% end %>\n\n  <% dialog.with_body do %>\n    <% dialog.with_section(title: t(\".overview\"), open: true) do %>\n      <div class=\"pb-4 px-3 pt-2 text-sm space-y-3 text-primary\">\n        <div class=\"space-y-3\">\n          <dl class=\"flex items-center gap-2 justify-between\">\n            <dt class=\"text-secondary\">From</dt>\n            <dd class=\"flex items-center gap-2 font-medium\">\n              <%= render \"accounts/logo\", account: @transfer.from_account, size: \"sm\" %>\n              <%= link_to @transfer.from_account.name, account_path(@transfer.from_account), data: { turbo_frame: \"_top\" } %>\n            </dd>\n          </dl>\n\n          <dl class=\"flex items-center gap-2 justify-between\">\n            <dt class=\"text-secondary\">Date</dt>\n            <dd class=\"font-medium\"><%= l(@transfer.outflow_transaction.entry.date, format: :long) %></dd>\n          </dl>\n\n          <dl class=\"flex items-center gap-2 justify-between\">\n            <dt class=\"text-secondary\">Amount</dt>\n            <dd class=\"font-medium text-red-500\"><%= format_money @transfer.outflow_transaction.entry.amount_money * -1 %></dd>\n          </dl>\n        </div>\n\n        <%= render \"shared/ruler\", classes: \"my-2\" %>\n\n        <div class=\"space-y-3\">\n          <dl class=\"flex items-center gap-2 justify-between\">\n            <dt class=\"text-secondary\">To</dt>\n            <dd class=\"flex items-center gap-2 font-medium\">\n              <%= render \"accounts/logo\", account: @transfer.to_account, size: \"sm\" %>\n              <%= link_to @transfer.to_account.name, account_path(@transfer.to_account), data: { turbo_frame: \"_top\" } %>\n            </dd>\n          </dl>\n\n          <dl class=\"flex items-center gap-2 justify-between\">\n            <dt class=\"text-secondary\">Date</dt>\n            <dd class=\"font-medium\"><%= l(@transfer.inflow_transaction.entry.date, format: :long) %></dd>\n          </dl>\n\n          <dl class=\"flex items-center gap-2 justify-between\">\n            <dt class=\"text-secondary\">Amount</dt>\n            <dd class=\"font-medium text-green-500\">+<%= format_money @transfer.inflow_transaction.entry.amount_money * -1 %></dd>\n          </dl>\n        </div>\n      </div>\n    <% end %>\n\n    <% dialog.with_section(title: t(\".details\")) do %>\n      <%= styled_form_with model: @transfer,\n              data: { controller: \"auto-submit-form\" }, class: \"space-y-2\" do |f| %>\n        <% if @transfer.categorizable? %>\n          <%= f.collection_select :category_id, @categories.alphabetically, :id, :name, { label: \"Category\", include_blank: \"Uncategorized\", selected: @transfer.outflow_transaction.category&.id }, \"data-auto-submit-form-target\": \"auto\" %>\n        <% end %>\n\n        <%= f.text_area :notes,\n                  label: t(\".note_label\"),\n                  placeholder: t(\".note_placeholder\"),\n                  rows: 5,\n                  \"data-auto-submit-form-target\": \"auto\" %>\n      <% end %>\n    <% end %>\n\n    <% dialog.with_section(title: t(\".settings\")) do %>\n      <div class=\"pb-4\">\n        <div class=\"flex items-center justify-between gap-2 p-3\">\n          <div class=\"text-sm space-y-1\">\n            <h4 class=\"text-primary\"><%= t(\".delete_title\") %></h4>\n            <p class=\"text-secondary\"><%= t(\".delete_subtitle\") %></p>\n          </div>\n\n          <%= button_to t(\".delete\"),\n                transfer_path(@transfer),\n                method: :delete,\n                class:  \"rounded-lg px-3 py-2 whitespace-nowrap text-red-500 text-sm\n                          font-medium border border-secondary\",\n                data:   { turbo_confirm: true, turbo_frame: \"_top\" } %>\n        </div>\n      </div>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/transfers/update.turbo_stream.erb",
    "content": "<% unless @transfer.destroyed? %>\n  <%= turbo_stream.replace @transfer.inflow_transaction.entry %>\n  <%= turbo_stream.replace @transfer.outflow_transaction.entry %>\n\n  <%= turbo_stream.replace dom_id(@transfer.inflow_transaction, \"category_menu\"),\n                          partial: \"transactions/transaction_category\",\n                          locals: { transaction: @transfer.inflow_transaction } %>\n\n  <%= turbo_stream.replace dom_id(@transfer.outflow_transaction, \"category_menu\"),\n                          partial: \"transactions/transaction_category\",\n                          locals: { transaction: @transfer.outflow_transaction } %>\n\n  <%= turbo_stream.replace dom_id(@transfer.inflow_transaction, \"transfer_match\"),\n                          partial: \"transactions/transfer_match\",\n                          locals: { transaction: @transfer.inflow_transaction } %>\n\n  <%= turbo_stream.replace dom_id(@transfer.outflow_transaction, \"transfer_match\"),\n                          partial: \"transactions/transfer_match\",\n                          locals: { transaction: @transfer.outflow_transaction } %>\n<% end %>\n"
  },
  {
    "path": "app/views/user_messages/_user_message.html.erb",
    "content": "<%# locals: (user_message:) %>\n\n<div id=\"<%= dom_id(user_message) %>\" class=\"bg-surface-inset px-3 py-2 rounded-lg max-w-[85%] w-fit ml-auto mb-6\">\n  <div class=\"prose prose--ai-chat\"><%= markdown(user_message.content) %></div>\n</div>\n"
  },
  {
    "path": "app/views/users/_user_menu.html.erb",
    "content": "<%# locals: (user:, placement: \"right-start\", offset: 16) %>\n\n<div data-testid=\"user-menu\">\n  <%= render DS::Menu.new(variant: \"avatar\", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials, placement: placement, offset: offset) do |menu| %>\n    <%= menu.with_header do %>\n      <div class=\"px-4 py-3 flex items-center gap-3\">\n        <div class=\"w-9 h-9 shrink-0\">\n          <%= render \"settings/user_avatar\", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials, lazy: true %>\n        </div>\n\n        <div class=\"overflow-hidden text-ellipsis text-sm\">\n          <p class=\"font-medium\"><%= user.display_name %></p>\n          <% if user.display_name != user.email %>\n            <p class=\"text-secondary\"><%= user.email %></p>\n          <% end %>\n        </div>\n      </div>\n\n      <% if self_hosted? %>\n        <div class=\"px-4 py-3 border-t border-tertiary\">\n          <p class=\"text-sm\">\n            <span class=\"font-medium text-primary\">Version:</span>\n            <%= link_to Maybe.version.to_release_tag, \"https://github.com/maybe-finance/maybe/releases/tag/#{Maybe.version.to_release_tag}\", target: \"_blank\", class: \"hover:underline\" %>\n\n            <% if Maybe.commit_sha.present? %>\n              (<%= link_to Maybe.commit_sha.first(7), \"https://github.com/maybe-finance/maybe/commit/#{Maybe.commit_sha}\", target: \"_blank\", class: \"hover:underline\" %>)\n            <% end %>\n          </p>\n        </div>\n      <% end %>\n    <% end %>\n\n    <% menu.with_item(variant: \"link\", text: \"Settings\", icon: \"settings\", href: settings_profile_path(return_to: request.fullpath)) %>\n    <% menu.with_item(variant: \"link\", text: \"Changelog\", icon: \"box\", href: changelog_path) %>\n\n    <% if self_hosted? %>\n      <% menu.with_item(variant: \"link\", text: \"Feedback\", icon: \"megaphone\", href: feedback_path) %>\n      <% menu.with_item(variant: \"link\", text: \"Contact\", icon: \"message-square-more\", href: \"https://link.maybe.co/discord\") %>\n    <% else %>\n      <% menu.with_item(variant: \"button\", text: \"Contact\", icon: \"message-square-more\", data: { action: \"intercom#show\" }) %>\n    <% end %>\n\n    <% menu.with_item(variant: \"divider\") %>\n\n    <% menu.with_item(variant: \"button\", text: \"Log out\", icon: \"log-out\", href: session_path(Current.session), method: :delete) %>\n  <% end %>\n</div>\n"
  },
  {
    "path": "app/views/valuations/_confirmation_contents.html.erb",
    "content": "<%# locals: (account:, entry:, reconciliation_dry_run:, is_update:, action_verb:) %>\n\n<div class=\"space-y-4 text-sm text-secondary\">\n  <% if account.investment? %>\n    <% brokerage_cash = reconciliation_dry_run.new_cash_balance || 0 %>\n    <% holdings_value = reconciliation_dry_run.new_balance - brokerage_cash %>\n\n    <p>This will <%= action_verb %> the account value on <span class=\"font-medium text-primary\"><%= entry.date.strftime(\"%B %d, %Y\") %></span> to:</p>\n\n    <div class=\"bg-container rounded-lg p-4 space-y-2 border border-primary\">\n      <div class=\"flex justify-between\">\n        <span>Total account value</span>\n        <span class=\"font-medium text-primary\"><%= Money.new(reconciliation_dry_run.new_balance, account.currency).format %></span>\n      </div>\n      <div class=\"flex justify-between text-xs\">\n        <span>Holdings value</span>\n        <span><%= Money.new(holdings_value, account.currency).format %></span>\n      </div>\n      <div class=\"flex justify-between text-xs\">\n        <span>Brokerage cash</span>\n        <span class=\"<%= brokerage_cash.negative? ? \"text-red-500\" : \"text-green-500\" %>\"><%= Money.new(brokerage_cash, account.currency).format %></span>\n      </div>\n    </div>\n  <% else %>\n    <p><%= action_verb.capitalize %>\n      <% if account.depository? %>\n        account balance\n      <% elsif account.credit_card? %>\n        credit card balance\n      <% elsif account.loan? %>\n        loan balance\n      <% elsif account.property? %>\n        property value\n      <% elsif account.vehicle? %>\n        vehicle value\n      <% elsif account.crypto? %>\n        crypto balance\n      <% elsif account.other_asset? %>\n        asset value\n      <% elsif account.other_liability? %>\n        liability balance\n      <% else %>\n        balance\n      <% end %>\n      on <span class=\"font-medium text-primary\"><%= entry.date.strftime(\"%B %d, %Y\") %></span> to\n      <span class=\"font-medium text-primary\"><%= entry.amount_money.format %></span>.\n    </p>\n  <% end %>\n\n  <p>All future transactions and balances will be recalculated based on this <%= is_update ? \"change\" : \"update\" %>.</p>\n</div>\n"
  },
  {
    "path": "app/views/valuations/_header.html.erb",
    "content": "<%# locals: (entry:) %>\n\n<%= tag.header class: \"mb-4 space-y-1\", id: dom_id(entry, :header) do %>\n  <span class=\"text-secondary text-sm\">\n    <%= entry.name %>\n  </span>\n\n  <div class=\"flex items-center gap-4\">\n    <h3 class=\"font-medium\">\n      <span class=\"text-2xl\">\n        <%= format_money entry.amount_money %>\n      </span>\n    </h3>\n  </div>\n\n  <span class=\"text-sm text-secondary\">\n    <%= I18n.l(entry.date, format: :long) %>\n  </span>\n<% end %>\n"
  },
  {
    "path": "app/views/valuations/_valuation.html.erb",
    "content": "<%# locals: (entry:, **) %>\n\n<% valuation = entry.entryable %>\n\n<% color = valuation.opening_anchor? ? \"#D444F1\" : \"var(--color-gray)\" %>\n<% icon = valuation.opening_anchor? ? \"plus\" : \"minus\" %>\n\n<%= turbo_frame_tag dom_id(entry) do %>\n  <%= turbo_frame_tag dom_id(valuation) do %>\n    <div class=\"p-4 grid grid-cols-12 items-center text-primary text-sm font-medium\">\n      <div class=\"col-span-8 flex items-center gap-4\">\n        <%= check_box_tag dom_id(entry, \"selection\"),\n                        class: \"checkbox checkbox--light\",\n                        data: { id: entry.id, \"bulk-select-target\": \"row\", action: \"bulk-select#toggleRowSelection\" } %>\n\n        <div class=\"flex items-center gap-3\">\n          <%= render DS::FilledIcon.new(icon: icon, size: \"sm\", hex_color: color, rounded: true) %>\n\n          <div class=\"truncate text-primary\">\n            <%= link_to entry.name,\n                        entry_path(entry),\n                        data: { turbo_frame: \"drawer\", turbo_prefetch: false },\n                        class: \"hover:underline\" %>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"col-span-4 justify-self-end\">\n        <%= tag.p format_money(entry.amount_money), class: \"font-bold text-sm text-primary\" %>\n      </div>\n    </div>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/valuations/confirm_create.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: \"Confirm new balance\") %>\n  <% dialog.with_body do %>\n    <%= styled_form_with model: @entry, url: valuations_path, class: \"space-y-4\" do |form| %>\n      <%= form.hidden_field :account_id %>\n      <%= form.hidden_field :date %>\n      <%= form.hidden_field :amount %>\n\n      <%= render \"confirmation_contents\",\n          reconciliation_dry_run: @reconciliation_dry_run,\n          account: @account,\n          entry: @entry,\n          action_verb: \"set\",\n          is_update: false %>\n\n      <%= form.submit \"Confirm\" %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/valuations/confirm_update.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: \"Update balance\") %>\n  <% dialog.with_body do %>\n    <%= styled_form_with model: @entry, url: valuation_path(@entry), method: :patch, class: \"space-y-4\", data: { turbo_frame: :_top } do |form| %>\n      <%= form.hidden_field :date %>\n      <%= form.hidden_field :amount %>\n\n      <%= render \"confirmation_contents\",\n          reconciliation_dry_run: @reconciliation_dry_run,\n          account: @account,\n          entry: @entry,\n          action_verb: \"update\",\n          is_update: true %>\n\n      <%= form.submit \"Update\" %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/valuations/index.html.erb",
    "content": "<%= turbo_frame_tag dom_id(@account, \"valuations\") do %>\n  <div class=\"bg-container space-y-4 p-5 shadow-border-xs rounded-xl\">\n    <div class=\"flex items-center justify-between\">\n      <%= tag.h2 t(\".valuations\"), class: \"font-medium text-lg\" %>\n      <%= link_to new_valuation_path(@account),\n                  data:  { turbo_frame: dom_id(@account.entries.valuations.new) },\n                  class: \"flex gap-1 font-medium items-center bg-gray-50 text-primary p-2 rounded-lg\" do %>\n        <span class=\"text-primary\">\n          <%= icon(\"plus\", color: \"current\") %>\n        </span>\n        <%= tag.span t(\".new_entry\"), class: \"text-sm\" %>\n      <% end %>\n    </div>\n\n    <div class=\"rounded-xl bg-container-inset p-1\">\n      <div class=\"grid grid-cols-10 items-center uppercase text-xs font-medium text-secondary px-4 py-2\">\n        <%= tag.p t(\".date\"), class: \"col-span-5\" %>\n        <%= tag.p t(\".value\"), class: \"col-span-2 justify-self-end\" %>\n        <%= tag.p t(\".change\"), class: \"col-span-2 justify-self-end\" %>\n        <%= tag.div class: \"col-span-1\" %>\n      </div>\n\n      <div class=\"rounded-lg bg-container shadow-border-xs\">\n        <%= turbo_frame_tag dom_id(@account.entries.valuations.new) %>\n\n        <% if @entries.any? %>\n          <%= render partial: \"valuations/valuation\",\n                     collection:      @entries,\n                     as:              :entry,\n                     spacer_template: \"shared/ruler\" %>\n        <% else %>\n          <p class=\"text-secondary text-sm p-4\"><%= t(\".no_valuations\") %></p>\n        <% end %>\n      </div>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/valuations/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".title\")) %>\n  <% dialog.with_body do %>\n    <%= styled_form_with model: @entry, url: confirm_create_valuations_path, class: \"space-y-4\" do |form| %>\n      <%= form.hidden_field :account_id %>\n\n      <% if @error_message.present? %>\n        <%= render DS::Alert.new(message: @error_message, variant: :error) %>\n      <% end %>\n\n      <div class=\"space-y-3\">\n        <%= form.date_field :date, label: true, required: true, value: Date.current, min: Entry.min_supported_date, max: Date.current %>\n        <%= form.money_field :amount, label: t(\".amount\"), required: true, disable_currency: true %>\n      </div>\n\n      <%= form.submit t(\".submit\") %>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/valuations/show.html.erb",
    "content": "<% entry, account = @entry, @entry.account %>\n\n<%= render DS::Dialog.new(variant: \"drawer\") do |dialog| %>\n  <% dialog.with_header do %>\n    <%= render \"valuations/header\", entry: @entry %>\n  <% end %>\n\n  <% dialog.with_body do %>\n    <% if @error_message.present? %>\n      <div class=\"mb-4\">\n        <%= render DS::Alert.new(message: @error_message, variant: :error) %>\n      </div>\n    <% end %>\n\n    <% dialog.with_section(title: t(\".overview\"), open: true) do %>\n      <div class=\"pb-4\">\n        <%= styled_form_with model: entry,\n              url: confirm_update_valuation_path(entry),\n              method: :post,\n              data: { turbo_frame: :modal },\n              class: \"space-y-4\" do |f| %>\n          <%= f.date_field :date,\n                label: t(\".date_label\"),\n                max: Date.current %>\n\n          <%= f.money_field :amount,\n                label: \"Account value on date\",\n                disable_currency: true %>\n\n          <div class=\"flex justify-end\">\n            <%= render DS::Button.new(\n              text: \"Update value\",\n              variant: :primary,\n              type: \"submit\"\n            ) %>\n          </div>\n        <% end %>\n      </div>\n    <% end %>\n\n    <% dialog.with_section(title: t(\".details\")) do %>\n      <div class=\"pb-4\">\n        <%= styled_form_with model: entry,\n              url: valuation_path(entry),\n              method: :patch,\n              class: \"space-y-2\",\n              data: { controller: \"auto-submit-form\", auto_submit_form_trigger_event_value: \"blur\" } do |f| %>\n          <%= f.text_area :notes,\n                label: t(\".note_label\"),\n                placeholder: t(\".note_placeholder\"),\n                rows: 5,\n                \"data-auto-submit-form-target\": \"auto\" %>\n        <% end %>\n      </div>\n    <% end %>\n\n    <% dialog.with_section(title: t(\".settings\")) do %>\n      <div class=\"pb-4\">\n        <!-- Delete Valuation Form -->\n        <div class=\"flex items-center justify-between gap-2 p-3\">\n          <div class=\"text-sm space-y-1\">\n            <h4 class=\"text-primary\"><%= t(\".delete_title\") %></h4>\n            <p class=\"text-secondary\"><%= t(\".delete_subtitle\") %></p>\n          </div>\n\n          <%= button_to t(\".delete\"),\n                entry_path(entry),\n                method: :delete,\n                class: \"rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary\",\n                data: { turbo_confirm: CustomConfirm.for_resource_deletion(\"value update\").to_data_attribute, turbo_frame: \"_top\" } %>\n        </div>\n      </div>\n    <% end %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/vehicles/_form.html.erb",
    "content": "<%# locals: (account:, url:) %>\n\n<%= render \"accounts/form\", account: account, url: url do |form| %>\n  <%= render \"shared/ruler\", classes: \"my-4\" %>\n\n  <div class=\"space-y-2\">\n    <%= form.fields_for :accountable do |vehicle_form| %>\n      <div class=\"flex items-center gap-2\">\n        <%= vehicle_form.text_field :make,\n                                  label: t(\"vehicles.form.make\"),\n                                  placeholder: t(\"vehicles.form.make_placeholder\") %>\n        <%= vehicle_form.text_field :model,\n                                  label: t(\"vehicles.form.model\"),\n                                  placeholder: t(\"vehicles.form.model_placeholder\") %>\n      </div>\n\n      <div class=\"flex items-center gap-2\">\n        <%= vehicle_form.number_field :year,\n                                    label: t(\"vehicles.form.year\"),\n                                    placeholder: t(\"vehicles.form.year_placeholder\"),\n                                    min: 1900,\n                                    max: Time.current.year + 1 %>\n        <%= vehicle_form.number_field :mileage_value,\n                                    label: t(\"vehicles.form.mileage\"),\n                                    placeholder: t(\"vehicles.form.mileage_placeholder\"),\n                                    min: 0 %>\n        <%= vehicle_form.select :mileage_unit,\n                              [[\"Miles\", \"mi\"], [\"Kilometers\", \"km\"]],\n                              { label: t(\"vehicles.form.mileage_unit\") } %>\n      </div>\n    <% end %>\n  </div>\n<% end %>\n"
  },
  {
    "path": "app/views/vehicles/edit.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".edit\", account: @account.name)) %>\n  <% dialog.with_body do %>\n    <%= render \"form\", account: @account, url: vehicle_path(@account) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/vehicles/new.html.erb",
    "content": "<%= render DS::Dialog.new do |dialog| %>\n  <% dialog.with_header(title: t(\".title\")) %>\n  <% dialog.with_body do %>\n    <%= render \"vehicles/form\", account: @account, url: vehicles_path(return_to: params[:return_to]) %>\n  <% end %>\n<% end %>\n"
  },
  {
    "path": "app/views/vehicles/tabs/_overview.html.erb",
    "content": "<%# locals: (account:) %>\n\n<div class=\"grid grid-cols-3 gap-2\">\n  <%= summary_card title: t(\".make_model\") do %>\n    <%= [account.vehicle.make, account.vehicle.model].compact.join(\" \").presence || t(\".unknown\") %>\n  <% end %>\n\n  <%= summary_card title: t(\".year\") do %>\n    <%= account.vehicle.year || t(\".unknown\") %>\n  <% end %>\n\n  <%= summary_card title: t(\".mileage\") do %>\n    <%= account.vehicle.mileage || t(\".unknown\") %>\n  <% end %>\n\n  <%= summary_card title: t(\".purchase_price\") do %>\n    <%= format_money account.vehicle.purchase_price %>\n  <% end %>\n\n  <%= summary_card title: t(\".current_price\") do %>\n    <%= format_money account.balance_money %>\n  <% end %>\n\n  <%= summary_card title: t(\".trend\") do %>\n    <div class=\"flex items-center gap-1\" style=\"color: <%= account.vehicle.trend.color %>\">\n      <p class=\"text-xl font-medium\">\n        <%= account.vehicle.trend.value %>\n      </p>\n\n      <p>(<%= account.vehicle.trend.percent %>%)</p>\n    </div>\n  <% end %>\n</div>\n\n<div class=\"flex justify-center py-8\">\n  <%= render DS::Link.new(\n    text: \"Edit account details\",\n    variant: \"ghost\",\n    href: edit_vehicle_path(account),\n    frame: :modal\n  ) %>\n</div>\n"
  },
  {
    "path": "bin/brakeman",
    "content": "#!/usr/bin/env ruby\nrequire \"rubygems\"\nrequire \"bundler/setup\"\n\nARGV.unshift(\"--ensure-latest\")\n\nload Gem.bin_path(\"brakeman\", \"brakeman\")\n"
  },
  {
    "path": "bin/bundle",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\n#\n# This file was generated by Bundler.\n#\n# The application 'bundle' is installed as part of a gem, and\n# this file is here to facilitate running it.\n#\n\nrequire \"rubygems\"\n\nm = Module.new do\n  module_function\n\n  def invoked_as_script?\n    File.expand_path($0) == File.expand_path(__FILE__)\n  end\n\n  def env_var_version\n    ENV[\"BUNDLER_VERSION\"]\n  end\n\n  def cli_arg_version\n    return unless invoked_as_script? # don't want to hijack other binstubs\n    return unless \"update\".start_with?(ARGV.first || \" \") # must be running `bundle update`\n    bundler_version = nil\n    update_index = nil\n    ARGV.each_with_index do |a, i|\n      if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN)\n        bundler_version = a\n      end\n      next unless a =~ /\\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\\z/\n      bundler_version = $1\n      update_index = i\n    end\n    bundler_version\n  end\n\n  def gemfile\n    gemfile = ENV[\"BUNDLE_GEMFILE\"]\n    return gemfile if gemfile && !gemfile.empty?\n\n    File.expand_path(\"../Gemfile\", __dir__)\n  end\n\n  def lockfile\n    lockfile =\n      case File.basename(gemfile)\n      when \"gems.rb\" then gemfile.sub(/\\.rb$/, \".locked\")\n      else \"#{gemfile}.lock\"\n      end\n    File.expand_path(lockfile)\n  end\n\n  def lockfile_version\n    return unless File.file?(lockfile)\n    lockfile_contents = File.read(lockfile)\n    return unless lockfile_contents =~ /\\n\\nBUNDLED WITH\\n\\s{2,}(#{Gem::Version::VERSION_PATTERN})\\n/\n    Regexp.last_match(1)\n  end\n\n  def bundler_requirement\n    @bundler_requirement ||=\n      env_var_version ||\n      cli_arg_version ||\n      bundler_requirement_for(lockfile_version)\n  end\n\n  def bundler_requirement_for(version)\n    return \"#{Gem::Requirement.default}.a\" unless version\n\n    bundler_gem_version = Gem::Version.new(version)\n\n    bundler_gem_version.approximate_recommendation\n  end\n\n  def load_bundler!\n    ENV[\"BUNDLE_GEMFILE\"] ||= gemfile\n\n    activate_bundler\n  end\n\n  def activate_bundler\n    gem_error = activation_error_handling do\n      gem \"bundler\", bundler_requirement\n    end\n    return if gem_error.nil?\n    require_error = activation_error_handling do\n      require \"bundler/version\"\n    end\n    return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))\n    warn \"Activating bundler (#{bundler_requirement}) failed:\\n#{gem_error.message}\\n\\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`\"\n    exit 42\n  end\n\n  def activation_error_handling\n    yield\n    nil\n  rescue StandardError, LoadError => e\n    e\n  end\nend\n\nm.load_bundler!\n\nif m.invoked_as_script?\n  load Gem.bin_path(\"bundler\", \"bundle\")\nend\n"
  },
  {
    "path": "bin/dev",
    "content": "#!/usr/bin/env sh\n\nif ! gem list foreman -i --silent; then\n  echo \"Installing foreman...\"\n  gem install foreman\n  \n  # Add rehash for rbenv users\n  if command -v rbenv > /dev/null; then\n    echo \"Running rbenv rehash...\"\n    rbenv rehash\n  fi\nfi\n\n# Default to port 3000 if not specified\nexport PORT=\"${PORT:-3000}\"\n\n# Let the debug gem allow remote connections,\n# but avoid loading until `debugger` is called\nexport RUBY_DEBUG_OPEN=\"true\"\nexport RUBY_DEBUG_LAZY=\"true\"\n\nexec bundle exec foreman start -f Procfile.dev \"$@\"\n"
  },
  {
    "path": "bin/docker-entrypoint",
    "content": "#!/bin/bash -e\n\n# If running the rails server then create or migrate existing database\nif [ \"${1}\" == \"./bin/rails\" ] && [ \"${2}\" == \"server\" ]; then\n  ./bin/rails db:prepare\nfi\n\nexec \"${@}\"\n"
  },
  {
    "path": "bin/importmap",
    "content": "#!/usr/bin/env ruby\n\nrequire_relative \"../config/application\"\nrequire \"importmap/commands\"\n"
  },
  {
    "path": "bin/rails",
    "content": "#!/usr/bin/env ruby\nAPP_PATH = File.expand_path(\"../config/application\", __dir__)\nrequire_relative \"../config/boot\"\nrequire \"rails/commands\"\n"
  },
  {
    "path": "bin/rake",
    "content": "#!/usr/bin/env ruby\nrequire_relative \"../config/boot\"\nrequire \"rake\"\nRake.application.run\n"
  },
  {
    "path": "bin/render-build.sh",
    "content": "#!/usr/bin/env bash\nset -o errexit\n\necho \"Installing gems...\"\nbundle install\n\necho \"Clobbering old assets...\"\nbundle exec rails assets:clobber\n\necho \"Precompiling assets for production...\"\nbundle exec rails assets:precompile\n\necho \"✅ Build complete\""
  },
  {
    "path": "bin/rubocop",
    "content": "#!/usr/bin/env ruby\nrequire \"rubygems\"\nrequire \"bundler/setup\"\n\n# explicit rubocop config increases performance slightly while avoiding config confusion.\nARGV.unshift(\"--config\", File.expand_path(\"../.rubocop.yml\", __dir__))\n\nload Gem.bin_path(\"rubocop\", \"rubocop\")\n"
  },
  {
    "path": "bin/setup",
    "content": "#!/usr/bin/env ruby\nrequire \"fileutils\"\n\n# path to your application root.\nAPP_ROOT = File.expand_path(\"..\", __dir__)\n\ndef system!(*args)\n  system(*args, exception: true)\nend\n\nFileUtils.chdir APP_ROOT do\n  # This script is a way to set up or update your development environment automatically.\n  # This script is idempotent, so that you can run it at any time and get an expectable outcome.\n  # Add necessary setup steps to this file.\n\n  puts \"== Installing dependencies ==\"\n  system! \"gem install bundler --conservative\"\n  system(\"bundle check\") || system!(\"bundle install\")\n\n  # puts \"\\n== Copying sample files ==\"\n  # unless File.exist?(\"config/database.yml\")\n  #   FileUtils.cp \"config/database.yml.sample\", \"config/database.yml\"\n  # end\n\n  puts \"\\n== Preparing database ==\"\n  system! \"bin/rails db:prepare\"\n\n  puts \"\\n== Removing old logs and tempfiles ==\"\n  system! \"bin/rails log:clear tmp:clear\"\n\n  puts \"\\n== Restarting application server ==\"\n  system! \"bin/rails restart\"\nend\n"
  },
  {
    "path": "bin/update_structure.sh",
    "content": "#!/bin/bash\n# save to .scripts/update_structure.sh\n# best way to use is with tree: `brew install tree`\n\n# Create the output file with header\necho \"---\" > .cursor/rules/structure.mdc\necho \"description: Project structure\" >> .cursor/rules/structure.mdc\necho \"globs: *\" >> .cursor/rules/structure.mdc\necho \"alwaysApply: true\" >> .cursor/structure/structure.mdc\necho \"---\" >> .cursor/rules/structure.mdc\necho \"\" >> .cursor/rules/structure.mdc\necho \"# Project Structure\" > .cursor/rules/structure.mdc\necho \"\" >> .cursor/rules/structure.mdc\necho \"\\`\\`\\`\" >> .cursor/rules/structure.mdc\n\n# Check if tree command is available\nif command -v tree &> /dev/null; then\n  # Use tree command for better visualization\n  git ls-files --others --exclude-standard --cached | tree --fromfile -a >> .cursor/rules/structure.mdc\n  echo \"Using tree command for structure visualization.\"\nelse\n  # Fallback to the alternative approach if tree is not available\n  echo \"Tree command not found. Using fallback approach.\"\n\n  # Get all files from git (respecting .gitignore)\n  git ls-files --others --exclude-standard --cached | sort > /tmp/files_list.txt\n\n  # Create a simple tree structure\n  echo \".\" > /tmp/tree_items.txt\n\n  # Process each file to build the tree\n  while read -r file; do\n    # Skip directories\n    if [[ -d \"$file\" ]]; then continue; fi\n\n    # Add the file to the tree\n    echo \"$file\" >> /tmp/tree_items.txt\n\n    # Add all parent directories\n    dir=\"$file\"\n    while [[ \"$dir\" != \".\" ]]; do\n      dir=$(dirname \"$dir\")\n      echo \"$dir\" >> /tmp/tree_items.txt\n    done\n  done < /tmp/files_list.txt\n\n  # Sort and remove duplicates\n  sort -u /tmp/tree_items.txt > /tmp/tree_sorted.txt\n  mv /tmp/tree_sorted.txt /tmp/tree_items.txt\n\n  # Simple tree drawing approach\n  prev_dirs=()\n\n  while read -r item; do\n    # Skip the root\n    if [[ \"$item\" == \".\" ]]; then\n      continue\n    fi\n\n    # Determine if it's a file or directory\n    if [[ -f \"$item\" ]]; then\n      is_dir=0\n      name=$(basename \"$item\")\n    else\n      is_dir=1\n      name=\"$(basename \"$item\")/\"\n    fi\n\n    # Split path into components\n    IFS='/' read -ra path_parts <<< \"$item\"\n\n    # Calculate depth (number of path components minus 1)\n    depth=$((${#path_parts[@]} - 1))\n\n    # Find common prefix with previous path\n    common=0\n    if [[ ${#prev_dirs[@]} -gt 0 ]]; then\n      for ((i=0; i<depth && i<${#prev_dirs[@]}; i++)); do\n        if [[ \"${path_parts[$i]}\" == \"${prev_dirs[$i]}\" ]]; then\n          ((common++))\n        else\n          break\n        fi\n      done\n    fi\n\n    # Build the prefix\n    prefix=\"\"\n    for ((i=0; i<depth; i++)); do\n      if [[ $i -lt $common ]]; then\n        # Check if this component has more siblings\n        has_more=0\n        for next in $(grep \"^$(dirname \"$item\")/\" /tmp/tree_items.txt); do\n          if [[ \"$next\" > \"$item\" ]]; then\n            has_more=1\n            break\n          fi\n        done\n\n        if [[ $has_more -eq 1 ]]; then\n          prefix=\"${prefix}│   \"\n        else\n          prefix=\"${prefix}    \"\n        fi\n      else\n        prefix=\"${prefix}    \"\n      fi\n    done\n\n    # Determine if this is the last item in its directory\n    is_last=1\n    dir=$(dirname \"$item\")\n    for next in $(grep \"^$dir/\" /tmp/tree_items.txt); do\n      if [[ \"$next\" > \"$item\" ]]; then\n        is_last=0\n        break\n      fi\n    done\n\n    # Choose the connector\n    if [[ $is_last -eq 1 ]]; then\n      connector=\"└── \"\n    else\n      connector=\"├── \"\n    fi\n\n    # Output the item\n    echo \"${prefix}${connector}${name}\" >> .cursor/rules/structure.mdc\n\n    # Save current path for next iteration\n    prev_dirs=(\"${path_parts[@]}\")\n\n  done < /tmp/tree_items.txt\n\n  # Clean up\n  rm -f /tmp/files_list.txt /tmp/tree_items.txt\nfi\n\n# Close the code block\necho \"\\`\\`\\`\" >> .cursor/rules/structure.mdc\n\necho \"Project structure has been updated in .cursor/rules/structure.mdc\""
  },
  {
    "path": "biome.json",
    "content": "{\n  \"$schema\": \"https://biomejs.dev/schemas/1.9.3/schema.json\",\n  \"vcs\": {\n    \"enabled\": false,\n    \"clientKind\": \"git\",\n    \"useIgnoreFile\": true\n  },\n  \"files\": {\n    \"ignoreUnknown\": false,\n    \"ignore\": [],\n    \"include\": [\"./app/javascript/**/*.js\"]\n  },\n  \"formatter\": {\n    \"enabled\": true,\n    \"useEditorconfig\": true\n  },\n  \"organizeImports\": {\n    \"enabled\": true\n  },\n  \"linter\": {\n    \"enabled\": true,\n    \"rules\": {\n      \"recommended\": true,\n      \"complexity\": {\n        \"noForEach\": \"off\"\n      }\n    }\n  },\n  \"javascript\": {\n    \"formatter\": {\n      \"quoteStyle\": \"double\"\n    }\n  }\n}\n"
  },
  {
    "path": "compose.example.yml",
    "content": "# ===========================================================================\n# Example Docker Compose file\n# ===========================================================================\n#\n# Purpose:\n# --------\n#\n# This file is an example Docker Compose configuration for self hosting\n# Maybe on your local machine or on a cloud VPS.\n#\n# The configuration below is a \"standard\" setup that works out of the box,\n# but if you're running this outside of a local network, it is recommended\n# to set the environment variables for extra security.\n#\n# Setup:\n# ------\n#\n# To run this, you should read the setup guide:\n#\n# https://github.com/maybe-finance/maybe/blob/main/docs/hosting/docker.md\n#\n# Troubleshooting:\n# ----------------\n#\n# If you run into problems, you should open a Discussion here:\n#\n# https://github.com/maybe-finance/maybe/discussions/categories/general\n#\n\nx-db-env: &db_env\n  POSTGRES_USER: ${POSTGRES_USER:-maybe_user}\n  POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-maybe_password}\n  POSTGRES_DB: ${POSTGRES_DB:-maybe_production}\n\nx-rails-env: &rails_env\n  <<: *db_env\n  SECRET_KEY_BASE: ${SECRET_KEY_BASE:-a7523c3d0ae56415046ad8abae168d71074a79534a7062258f8d1d51ac2f76d3c3bc86d86b6b0b307df30d9a6a90a2066a3fa9e67c5e6f374dbd7dd4e0778e13}\n  SELF_HOSTED: \"true\"\n  RAILS_FORCE_SSL: \"false\"\n  RAILS_ASSUME_SSL: \"false\"\n  DB_HOST: db\n  DB_PORT: 5432\n  REDIS_URL: redis://redis:6379/1\n# NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules).  Make sure you have set appropriate spend limits on your account before adding this.\n  OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN}\n\nservices:\n  web:\n    image: ghcr.io/maybe-finance/maybe:latest\n    volumes:\n      - app-storage:/rails/storage\n    ports:\n      - 3000:3000\n    restart: unless-stopped\n    environment:\n      <<: *rails_env\n    depends_on:\n      db:\n        condition: service_healthy\n      redis:\n        condition: service_healthy\n    networks:\n      - maybe_net\n\n  worker:\n    image: ghcr.io/maybe-finance/maybe:latest\n    command: bundle exec sidekiq\n    restart: unless-stopped\n    depends_on:\n      redis:\n        condition: service_healthy\n    environment:\n      <<: *rails_env\n    networks:\n      - maybe_net\n\n  db:\n    image: postgres:16\n    restart: unless-stopped\n    volumes:\n      - postgres-data:/var/lib/postgresql/data\n    environment:\n      <<: *db_env\n    healthcheck:\n      test: [ \"CMD-SHELL\", \"pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB\" ]\n      interval: 5s\n      timeout: 5s\n      retries: 5\n    networks:\n      - maybe_net\n\n  redis:\n    image: redis:latest\n    restart: unless-stopped\n    volumes:\n      - redis-data:/data\n    healthcheck:\n      test: [ \"CMD\", \"redis-cli\", \"ping\" ]\n      interval: 5s\n      timeout: 5s\n      retries: 5\n    networks:\n      - maybe_net\n\nvolumes:\n  app-storage:\n  postgres-data:\n  redis-data:\n\nnetworks:\n  maybe_net:\n    driver: bridge\n"
  },
  {
    "path": "config/application.rb",
    "content": "require_relative \"boot\"\n\nrequire \"rails/all\"\n\n# Require the gems listed in Gemfile, including any gems\n# you've limited to :test, :development, or :production.\nBundler.require(*Rails.groups)\n\nmodule Maybe\n  class Application < Rails::Application\n    # Initialize configuration defaults for originally generated Rails version.\n    config.load_defaults 7.2\n\n    # Please, add to the `ignore` list any other `lib` subdirectories that do\n    # not contain `.rb` files, or that should not be reloaded or eager loaded.\n    # Common ones are `templates`, `generators`, or `middleware`, for example.\n    config.autoload_lib(ignore: %w[assets tasks])\n\n    # Configuration for the application, engines, and railties goes here.\n    #\n    # These settings can be overridden in specific environments using the files\n    # in config/environments, which are processed later.\n    #\n    # config.time_zone = \"Central Time (US & Canada)\"\n    # config.eager_load_paths << Rails.root.join(\"extras\")\n\n    # TODO: This is here for incremental adoption of localization.  This can be removed when all translations are implemented.\n    config.i18n.fallbacks = true\n\n    config.app_mode = (ENV[\"SELF_HOSTED\"] == \"true\" || ENV[\"SELF_HOSTING_ENABLED\"] == \"true\" ? \"self_hosted\" : \"managed\").inquiry\n\n    # Self hosters can optionally set their own encryption keys if they want to use ActiveRecord encryption.\n    if Rails.application.credentials.active_record_encryption.present?\n      config.active_record.encryption = Rails.application.credentials.active_record_encryption\n    end\n\n    config.view_component.preview_controller = \"LookbooksController\"\n    config.lookbook.preview_display_options = {\n      theme: [ \"light\", \"dark\" ] # available in view as params[:theme]\n    }\n\n    # Enable Rack::Attack middleware for API rate limiting\n    config.middleware.use Rack::Attack\n  end\nend\n"
  },
  {
    "path": "config/boot.rb",
    "content": "ENV[\"BUNDLE_GEMFILE\"] ||= File.expand_path(\"../Gemfile\", __dir__)\n\nrequire \"bundler/setup\" # Set up gems listed in the Gemfile.\nrequire \"bootsnap/setup\" # Speed up boot time by caching expensive operations.\n"
  },
  {
    "path": "config/brakeman.ignore",
    "content": "{\n  \"ignored_warnings\": [\n    {\n      \"warning_type\": \"Redirect\",\n      \"warning_code\": 18,\n      \"fingerprint\": \"723b1970ca6bf16ea0c2c1afa0c00d3c54854a16568d6cb933e497947565d9ab\",\n      \"check_name\": \"Redirect\",\n      \"message\": \"Possible unprotected redirect\",\n      \"file\": \"app/controllers/family_exports_controller.rb\",\n      \"line\": 30,\n      \"link\": \"https://brakemanscanner.org/docs/warning_types/redirect/\",\n      \"code\": \"redirect_to(Current.family.family_exports.find(params[:id]).export_file, :allow_other_host => true)\",\n      \"render_path\": null,\n      \"location\": {\n        \"type\": \"method\",\n        \"class\": \"FamilyExportsController\",\n        \"method\": \"download\"\n      },\n      \"user_input\": \"Current.family.family_exports.find(params[:id]).export_file\",\n      \"confidence\": \"Weak\",\n      \"cwe_id\": [\n        601\n      ],\n      \"note\": \"\"\n    },\n    {\n      \"warning_type\": \"Mass Assignment\",\n      \"warning_code\": 105,\n      \"fingerprint\": \"85e2c11853dd6c69b1953a6ec3ad661cd0ce3df55e4e5beff92365b6ed601171\",\n      \"check_name\": \"PermitAttributes\",\n      \"message\": \"Potentially dangerous key allowed for mass assignment\",\n      \"file\": \"app/controllers/api/v1/transactions_controller.rb\",\n      \"line\": 255,\n      \"link\": \"https://brakemanscanner.org/docs/warning_types/mass_assignment/\",\n      \"code\": \"params.require(:transaction).permit(:account_id, :date, :amount, :name, :description, :notes, :currency, :category_id, :merchant_id, :nature, :tag_ids => ([]))\",\n      \"render_path\": null,\n      \"location\": {\n        \"type\": \"method\",\n        \"class\": \"Api::V1::TransactionsController\",\n        \"method\": \"transaction_params\"\n      },\n      \"user_input\": \":account_id\",\n      \"confidence\": \"High\",\n      \"cwe_id\": [\n        915\n      ],\n      \"note\": \"account_id is properly validated in create action - line 79 ensures account belongs to user's family: family.accounts.find(transaction_params[:account_id])\"\n    },\n    {\n      \"warning_type\": \"Mass Assignment\",\n      \"warning_code\": 105,\n      \"fingerprint\": \"aaccd8db0be34afdc88e5af08d91ae2e8b7765dfea2f3fc6e1c37db0adc7b991\",\n      \"check_name\": \"PermitAttributes\",\n      \"message\": \"Potentially dangerous key allowed for mass assignment\",\n      \"file\": \"app/controllers/invitations_controller.rb\",\n      \"line\": 58,\n      \"link\": \"https://brakemanscanner.org/docs/warning_types/mass_assignment/\",\n      \"code\": \"params.require(:invitation).permit(:email, :role)\",\n      \"render_path\": null,\n      \"location\": {\n        \"type\": \"method\",\n        \"class\": \"InvitationsController\",\n        \"method\": \"invitation_params\"\n      },\n      \"user_input\": \":role\",\n      \"confidence\": \"Medium\",\n      \"cwe_id\": [\n        915\n      ],\n      \"note\": \"\"\n    },\n    {\n      \"warning_type\": \"Dangerous Eval\",\n      \"warning_code\": 13,\n      \"fingerprint\": \"c154514a0f86341473e4abf35e77721495b326c7855e4967d284b4942371819c\",\n      \"check_name\": \"Evaluation\",\n      \"message\": \"Dynamic string evaluated as code\",\n      \"file\": \"app/helpers/styled_form_builder.rb\",\n      \"line\": 5,\n      \"link\": \"https://brakemanscanner.org/docs/warning_types/dangerous_eval/\",\n      \"code\": \"class_eval(\\\"      def #{selector}(method, options = {})\\\\n        form_options = options.slice(:label, :label_tooltip, :inline, :container_class, :required)\\\\n        html_options = options.except(:label, :label_tooltip, :inline, :container_class)\\\\n\\\\n        build_field(method, form_options, html_options) do |merged_options|\\\\n          super(method, merged_options)\\\\n        end\\\\n      end\\\\n\\\", \\\"app/helpers/styled_form_builder.rb\\\", (5 + 1))\",\n      \"render_path\": null,\n      \"location\": {\n        \"type\": \"method\",\n        \"class\": \"StyledFormBuilder\",\n        \"method\": null\n      },\n      \"user_input\": null,\n      \"confidence\": \"Weak\",\n      \"cwe_id\": [\n        913,\n        95\n      ],\n      \"note\": \"Uses similar pattern to Rails internal form builder\"\n    },\n    {\n      \"warning_type\": \"Dynamic Render Path\",\n      \"warning_code\": 15,\n      \"fingerprint\": \"fb6f7abeabc405d6882ffd41dbe8016403ef39307a5c6b4cd7b18adfaf0c24bf\",\n      \"check_name\": \"Render\",\n      \"message\": \"Render path contains parameter value\",\n      \"file\": \"app/views/import/configurations/show.html.erb\",\n      \"line\": 34,\n      \"link\": \"https://brakemanscanner.org/docs/warning_types/dynamic_render_path/\",\n      \"code\": \"render(partial => permitted_import_configuration_path(Current.family.imports.find(params[:import_id])), { :locals => ({ :import => Current.family.imports.find(params[:import_id]) }) })\",\n      \"render_path\": [\n        {\n          \"type\": \"controller\",\n          \"class\": \"Import::ConfigurationsController\",\n          \"method\": \"show\",\n          \"line\": 7,\n          \"file\": \"app/controllers/import/configurations_controller.rb\",\n          \"rendered\": {\n            \"name\": \"import/configurations/show\",\n            \"file\": \"app/views/import/configurations/show.html.erb\"\n          }\n        }\n      ],\n      \"location\": {\n        \"type\": \"template\",\n        \"template\": \"import/configurations/show\"\n      },\n      \"user_input\": \"params[:import_id]\",\n      \"confidence\": \"Weak\",\n      \"cwe_id\": [\n        22\n      ],\n      \"note\": \"\"\n    }\n  ],\n  \"brakeman_version\": \"7.1.0\"\n}\n"
  },
  {
    "path": "config/cable.yml",
    "content": "development:\n  adapter: async\n\ntest:\n  adapter: test\n\nproduction:\n  adapter: redis\n  url: <%= ENV.fetch(\"REDIS_URL\") { \"redis://localhost:6379/1\" } %>\n  channel_prefix: maybe_production\n"
  },
  {
    "path": "config/credentials.yml.enc",
    "content": "Be5nAlhacgJFHZJBgO8noswyX/VOrmkMem7wS3YQhoogzG0MCSVxCAVMbFyYFYUwqZrSPkAqUTpgH5OJJ1FB1gZfL9IYYWnEdTzMxM7IvhdDwYllYcM6smbvZEbOiqxLs9VdfC/qFS+1iFtsezBaqxfGdANJsJt3TxoRWl/ZbQ4Od1s0BNkMis1CDZt5RMEQlTz813cE5sXBlxhqEr9/2CaktwPIe5S/Oxrwo8vPFBvrNdox8BysiK9WDik8jJFSVwPSCvg43/MaIJUT0cOILdSxqrATXV143/h6ghNYtrJgoUNFT7wuu0FTU/ovTgtTqQEKG+7PDO1WLFn606bVknjPwfNMGBa9hX3LbRErDDIXNq69um9fPZ8Yq5f9jP++dPbAqbWBEg+JYsZmDgzr7LmtXVzQgAcuMkHaBbL8uxod8S1B6qhXhLNc8Dd1oeHVu0kcLFO2zaqdYRFNEY30JSjjXlG3GExXQE6aEluXvdF2gj9Hjhp7tEXZEJbIx+ZFy+6Xbrd1E2BE8AZUbalExAfudkPSYlAZ+z3fWc2RlNIuBzTYDOWH9Ai8mqsdyGNVEyizXQ==--j/6QtlLtP4mYXIFw--c+AKfDPo9stantWni+u+4Q=="
  },
  {
    "path": "config/currencies.yml",
    "content": "usd:\n  name: United States Dollar\n  priority: 1\n  iso_code: USD\n  iso_numeric: \"840\"\n  html_code: \"&dollar;\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\neur:\n  name: Euro\n  priority: 2\n  iso_code: EUR\n  iso_numeric: \"978\"\n  html_code: \"&euro;\"\n  symbol: \"€\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 2\ngbp:\n  name: British Pound\n  priority: 3\n  iso_code: GBP\n  iso_numeric: \"826\"\n  html_code: \"&pound;\"\n  symbol: \"£\"\n  minor_unit: Penny\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\naud:\n  name: Australian Dollar\n  priority: 4\n  iso_code: AUD\n  iso_numeric: \"036\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ncad:\n  name: Canadian Dollar\n  priority: 5\n  iso_code: CAD\n  iso_numeric: \"124\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\njpy:\n  name: Japanese Yen\n  priority: 6\n  iso_code: JPY\n  iso_numeric: \"392\"\n  html_code: \"&yen;\"\n  symbol: \"¥\"\n  minor_unit:\n  minor_unit_conversion: 1\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 0\nbyr:\n  name: Belarusian Ruble\n  priority: 50\n  iso_code: BYR\n  iso_numeric: \"974\"\n  html_code: \"\"\n  symbol: Br\n  minor_unit:\n  minor_unit_conversion: 1\n  smallest_denomination: 100\n  separator: \",\"\n  delimiter: \" \"\n  default_format: \"%n %u\"\n  default_precision: 0\nsar:\n  name: Saudi Riyal\n  priority: 100\n  iso_code: SAR\n  iso_numeric: \"682\"\n  html_code: \"&#xFDFC;\"\n  symbol: ر.س\n  minor_unit: Hallallah\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nsbd:\n  name: Solomon Islands Dollar\n  priority: 100\n  iso_code: SBD\n  iso_numeric: \"090\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 10\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nscr:\n  name: Seychellois Rupee\n  priority: 100\n  iso_code: SCR\n  iso_numeric: \"690\"\n  html_code: \"&#x20A8;\"\n  symbol: \"₨\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nsdg:\n  name: Sudanese Pound\n  priority: 100\n  iso_code: SDG\n  iso_numeric: \"938\"\n  html_code: \"\"\n  symbol: \"£\"\n  minor_unit: Piastre\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nsek:\n  name: Swedish Krona\n  priority: 100\n  iso_code: SEK\n  iso_numeric: \"752\"\n  html_code: \"\"\n  symbol: kr\n  minor_unit: Öre\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \",\"\n  delimiter: \" \"\n  default_format: \"%n %u\"\n  default_precision: 2\nsgd:\n  name: Singapore Dollar\n  priority: 100\n  iso_code: SGD\n  iso_numeric: \"702\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nshp:\n  name: Saint Helenian Pound\n  priority: 100\n  iso_code: SHP\n  iso_numeric: \"654\"\n  html_code: \"&#x00A3;\"\n  symbol: \"£\"\n  minor_unit: Penny\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nskk:\n  name: Slovak Koruna\n  priority: 100\n  iso_code: SKK\n  iso_numeric: \"703\"\n  html_code: \"\"\n  symbol: Sk\n  minor_unit: Halier\n  minor_unit_conversion: 100\n  smallest_denomination: 50\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nsle:\n  name: New Leone\n  priority: 100\n  iso_code: SLE\n  iso_numeric: \"925\"\n  html_code: \"\"\n  symbol: Le\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1000\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nsll:\n  name: Sierra Leonean Leone\n  priority: 100\n  iso_code: SLL\n  iso_numeric: \"694\"\n  html_code: \"\"\n  symbol: Le\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1000\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nsos:\n  name: Somali Shilling\n  priority: 100\n  iso_code: SOS\n  iso_numeric: \"706\"\n  html_code: \"\"\n  symbol: Sh\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nsrd:\n  name: Surinamese Dollar\n  priority: 100\n  iso_code: SRD\n  iso_numeric: \"968\"\n  html_code: \"\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nssp:\n  name: South Sudanese Pound\n  priority: 100\n  iso_code: SSP\n  iso_numeric: \"728\"\n  html_code: \"&#x00A3;\"\n  symbol: \"£\"\n  minor_unit: piaster\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nstd:\n  name: São Tomé and Príncipe Dobra\n  priority: 100\n  iso_code: STD\n  iso_numeric: \"678\"\n  html_code: \"\"\n  symbol: Db\n  minor_unit: Cêntimo\n  minor_unit_conversion: 100\n  smallest_denomination: 10000\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nstn:\n  name: São Tomé and Príncipe Second Dobra\n  priority: 100\n  iso_code: STN\n  iso_numeric: \"930\"\n  html_code: \"\"\n  symbol: Db\n  minor_unit: Cêntimo\n  minor_unit_conversion: 100\n  smallest_denomination: 10\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nsvc:\n  name: Salvadoran Colón\n  priority: 100\n  iso_code: SVC\n  iso_numeric: \"222\"\n  html_code: \"&#x20A1;\"\n  symbol: \"₡\"\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nsyp:\n  name: Syrian Pound\n  priority: 100\n  iso_code: SYP\n  iso_numeric: \"760\"\n  html_code: \"&#x00A3;\"\n  symbol: \"£S\"\n  minor_unit: Piastre\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nszl:\n  name: Swazi Lilangeni\n  priority: 100\n  iso_code: SZL\n  iso_numeric: \"748\"\n  html_code: \"\"\n  symbol: E\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nxbc:\n  name: European Unit of Account 9\n  priority: 100\n  iso_code: XBC\n  iso_numeric: \"957\"\n  html_code: \"\"\n  symbol: \"\"\n  minor_unit: \"\"\n  minor_unit_conversion: 1\n  smallest_denomination:\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nmvr:\n  name: Maldivian Rufiyaa\n  priority: 100\n  iso_code: MVR\n  iso_numeric: \"462\"\n  html_code: \"\"\n  symbol: MVR\n  minor_unit: Laari\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nmwk:\n  name: Malawian Kwacha\n  priority: 100\n  iso_code: MWK\n  iso_numeric: \"454\"\n  html_code: \"\"\n  symbol: MK\n  minor_unit: Tambala\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nmxn:\n  name: Mexican Peso\n  priority: 100\n  iso_code: MXN\n  iso_numeric: \"484\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nmyr:\n  name: Malaysian Ringgit\n  priority: 100\n  iso_code: MYR\n  iso_numeric: \"458\"\n  html_code: \"\"\n  symbol: RM\n  minor_unit: Sen\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nmzn:\n  name: Mozambican Metical\n  priority: 100\n  iso_code: MZN\n  iso_numeric: \"943\"\n  html_code: \"\"\n  symbol: MTn\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 2\nnad:\n  name: Namibian Dollar\n  priority: 100\n  iso_code: NAD\n  iso_numeric: \"516\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nngn:\n  name: Nigerian Naira\n  priority: 100\n  iso_code: NGN\n  iso_numeric: \"566\"\n  html_code: \"&#x20A6;\"\n  symbol: \"₦\"\n  minor_unit: Kobo\n  minor_unit_conversion: 100\n  smallest_denomination: 50\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nnio:\n  name: Nicaraguan Córdoba\n  priority: 100\n  iso_code: NIO\n  iso_numeric: \"558\"\n  html_code: \"\"\n  symbol: C$\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nnok:\n  name: Norwegian Krone\n  priority: 100\n  iso_code: NOK\n  iso_numeric: \"578\"\n  html_code: kr\n  symbol: kr\n  minor_unit: Øre\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%n %u\"\n  default_precision: 2\nnpr:\n  name: Nepalese Rupee\n  priority: 100\n  iso_code: NPR\n  iso_numeric: \"524\"\n  html_code: \"&#x20A8;\"\n  symbol: Rs.\n  minor_unit: Paisa\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nnzd:\n  name: New Zealand Dollar\n  priority: 100\n  iso_code: NZD\n  iso_numeric: \"554\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 10\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nomr:\n  name: Omani Rial\n  priority: 100\n  iso_code: OMR\n  iso_numeric: \"512\"\n  html_code: \"&#xFDFC;\"\n  symbol: ر.ع.\n  minor_unit: Baisa\n  minor_unit_conversion: 1000\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 3\npab:\n  name: Panamanian Balboa\n  priority: 100\n  iso_code: PAB\n  iso_numeric: \"590\"\n  html_code: \"\"\n  symbol: B/.\n  minor_unit: Centésimo\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\npen:\n  name: Peruvian Sol\n  priority: 100\n  iso_code: PEN\n  iso_numeric: \"604\"\n  html_code: S/\n  symbol: S/\n  minor_unit: Céntimo\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 2\npgk:\n  name: Papua New Guinean Kina\n  priority: 100\n  iso_code: PGK\n  iso_numeric: \"598\"\n  html_code: \"\"\n  symbol: K\n  minor_unit: Toea\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nphp:\n  name: Philippine Peso\n  priority: 100\n  iso_code: PHP\n  iso_numeric: \"608\"\n  html_code: \"&#x20B1;\"\n  symbol: \"₱\"\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\npkr:\n  name: Pakistani Rupee\n  priority: 100\n  iso_code: PKR\n  iso_numeric: \"586\"\n  html_code: \"&#x20A8;\"\n  symbol: \"₨\"\n  minor_unit: Paisa\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\npln:\n  name: Polish Złoty\n  priority: 100\n  iso_code: PLN\n  iso_numeric: \"985\"\n  html_code: z&#322;\n  symbol: zł\n  minor_unit: Grosz\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \" \"\n  default_format: \"%n %u\"\n  default_precision: 2\npyg:\n  name: Paraguayan Guaraní\n  priority: 100\n  iso_code: PYG\n  iso_numeric: \"600\"\n  html_code: \"&#x20B2;\"\n  symbol: \"₲\"\n  minor_unit: Céntimo\n  minor_unit_conversion: 1\n  smallest_denomination: 5000\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 0\nqar:\n  name: Qatari Riyal\n  priority: 100\n  iso_code: QAR\n  iso_numeric: \"634\"\n  html_code: \"&#xFDFC;\"\n  symbol: ر.ق\n  minor_unit: Dirham\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nron:\n  name: Romanian Leu\n  priority: 100\n  iso_code: RON\n  iso_numeric: \"946\"\n  html_code: \"\"\n  symbol: Lei\n  minor_unit: Bani\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%n %u\"\n  default_precision: 2\nrsd:\n  name: Serbian Dinar\n  priority: 100\n  iso_code: RSD\n  iso_numeric: \"941\"\n  html_code: \"\"\n  symbol: РСД\n  minor_unit: Para\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nrub:\n  name: Russian Ruble\n  priority: 100\n  iso_code: RUB\n  iso_numeric: \"643\"\n  html_code: \"&#x20BD;\"\n  symbol: \"₽\"\n  minor_unit: Kopeck\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%n %u\"\n  default_precision: 2\nrwf:\n  name: Rwandan Franc\n  priority: 100\n  iso_code: RWF\n  iso_numeric: \"646\"\n  html_code: \"\"\n  symbol: FRw\n  minor_unit: Centime\n  minor_unit_conversion: 1\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nxbd:\n  name: European Unit of Account 17\n  priority: 100\n  iso_code: XBD\n  iso_numeric: \"958\"\n  html_code: \"\"\n  symbol: \"\"\n  minor_unit: \"\"\n  minor_unit_conversion: 1\n  smallest_denomination:\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nxcd:\n  name: East Caribbean Dollar\n  priority: 100\n  iso_code: XCD\n  iso_numeric: \"951\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nxdr:\n  name: Special Drawing Rights\n  priority: 100\n  iso_code: XDR\n  iso_numeric: \"960\"\n  html_code: \"$\"\n  symbol: SDR\n  minor_unit: \"\"\n  minor_unit_conversion: 1\n  smallest_denomination:\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nxof:\n  name: West African Cfa Franc\n  priority: 100\n  iso_code: XOF\n  iso_numeric: \"952\"\n  html_code: \"\"\n  symbol: Fr\n  minor_unit: Centime\n  minor_unit_conversion: 1\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nxpd:\n  name: Palladium\n  priority: 100\n  iso_code: XPD\n  iso_numeric: \"964\"\n  html_code: \"\"\n  symbol: oz t\n  minor_unit: oz\n  minor_unit_conversion: 1\n  smallest_denomination:\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nxpf:\n  name: Cfp Franc\n  priority: 100\n  iso_code: XPF\n  iso_numeric: \"953\"\n  html_code: \"\"\n  symbol: Fr\n  minor_unit: Centime\n  minor_unit_conversion: 1\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nxpt:\n  name: Platinum\n  priority: 100\n  iso_code: XPT\n  iso_numeric: \"962\"\n  html_code: \"\"\n  symbol: oz t\n  minor_unit: \"\"\n  minor_unit_conversion: 1\n  smallest_denomination: \"\"\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nxts:\n  name: Codes specifically reserved for testing purposes\n  priority: 100\n  iso_code: XTS\n  iso_numeric: \"963\"\n  html_code: \"\"\n  symbol: \"\"\n  minor_unit: \"\"\n  minor_unit_conversion: 1\n  smallest_denomination: \"\"\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nyer:\n  name: Yemeni Rial\n  priority: 100\n  iso_code: YER\n  iso_numeric: \"886\"\n  html_code: \"&#xFDFC;\"\n  symbol: \"﷼\"\n  minor_unit: Fils\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nzar:\n  name: South African Rand\n  priority: 100\n  iso_code: ZAR\n  iso_numeric: \"710\"\n  html_code: \"&#x0052;\"\n  symbol: R\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 10\n  separator: \",\"\n  delimiter: \" \"\n  default_format: \"%u%n\"\n  default_precision: 2\nzmk:\n  name: Zambian Kwacha\n  priority: 100\n  iso_code: ZMK\n  iso_numeric: \"894\"\n  html_code: \"\"\n  symbol: ZK\n  minor_unit: Ngwee\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nzmw:\n  name: Zambian Kwacha\n  priority: 100\n  iso_code: ZMW\n  iso_numeric: \"967\"\n  html_code: \"\"\n  symbol: K\n  minor_unit: Ngwee\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nbtc:\n  name: Bitcoin\n  priority: 100\n  iso_code: BTC\n  iso_numeric: \"\"\n  html_code: \"&#x20bf;\"\n  symbol: \"₿\"\n  minor_unit: Satoshi\n  minor_unit_conversion: 100000000\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 8\njep:\n  name: Jersey Pound\n  priority: 100\n  iso_code: JEP\n  iso_numeric: \"\"\n  html_code: \"&#x00A3;\"\n  symbol: \"£\"\n  minor_unit: Penny\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nggp:\n  name: Guernsey Pound\n  priority: 100\n  iso_code: GGP\n  iso_numeric: \"\"\n  html_code: \"&#x00A3;\"\n  symbol: \"£\"\n  minor_unit: Penny\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nimp:\n  name: Isle of Man Pound\n  priority: 100\n  iso_code: IMP\n  iso_numeric: \"\"\n  html_code: \"&#x00A3;\"\n  symbol: \"£\"\n  minor_unit: Penny\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nxfu:\n  name: UIC Franc\n  priority: 100\n  iso_code: XFU\n  iso_numeric: \"\"\n  html_code: \"\"\n  symbol: \"\"\n  minor_unit: \"\"\n  minor_unit_conversion: 100\n  smallest_denomination: \"\"\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ngbx:\n  name: British Penny\n  priority: 100\n  iso_code: GBX\n  iso_numeric: \"\"\n  html_code: \"\"\n  symbol: \"\"\n  minor_unit: \"\"\n  minor_unit_conversion: 1\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 0\ncnh:\n  name: Chinese Renminbi Yuan Offshore\n  priority: 100\n  iso_code: CNH\n  iso_numeric: \"\"\n  html_code: \"￥\"\n  symbol: \"¥\"\n  minor_unit: Fen\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nusdc:\n  name: USD Coin\n  priority: 100\n  iso_code: USDC\n  iso_numeric: \"\"\n  html_code: \"$\"\n  symbol: USDC\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nthb:\n  name: Thai Baht\n  priority: 100\n  iso_code: THB\n  iso_numeric: \"764\"\n  html_code: \"&#x0E3F;\"\n  symbol: \"฿\"\n  minor_unit: Satang\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ntjs:\n  name: Tajikistani Somoni\n  priority: 100\n  iso_code: TJS\n  iso_numeric: \"972\"\n  html_code: \"\"\n  symbol: ЅМ\n  minor_unit: Diram\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\ntmt:\n  name: Turkmenistani Manat\n  priority: 100\n  iso_code: TMT\n  iso_numeric: \"934\"\n  html_code: \"\"\n  symbol: T\n  minor_unit: Tenge\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\ntnd:\n  name: Tunisian Dinar\n  priority: 100\n  iso_code: TND\n  iso_numeric: \"788\"\n  html_code: \"\"\n  symbol: د.ت\n  minor_unit: Millime\n  minor_unit_conversion: 1000\n  smallest_denomination: 10\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 3\ntop:\n  name: Tongan Paʻanga\n  priority: 100\n  iso_code: TOP\n  iso_numeric: \"776\"\n  html_code: \"\"\n  symbol: T$\n  minor_unit: Seniti\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ntry:\n  name: Turkish Lira\n  priority: 100\n  iso_code: TRY\n  iso_numeric: \"949\"\n  html_code: \"&#8378;\"\n  symbol: \"₺\"\n  minor_unit: kuruş\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 2\nttd:\n  name: Trinidad and Tobago Dollar\n  priority: 100\n  iso_code: TTD\n  iso_numeric: \"780\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\ntwd:\n  name: New Taiwan Dollar\n  priority: 100\n  iso_code: TWD\n  iso_numeric: \"901\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 50\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ntzs:\n  name: Tanzanian Shilling\n  priority: 100\n  iso_code: TZS\n  iso_numeric: \"834\"\n  html_code: \"\"\n  symbol: Sh\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 5000\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nuah:\n  name: Ukrainian Hryvnia\n  priority: 100\n  iso_code: UAH\n  iso_numeric: \"980\"\n  html_code: \"&#x20B4;\"\n  symbol: \"₴\"\n  minor_unit: Kopiyka\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nugx:\n  name: Ugandan Shilling\n  priority: 100\n  iso_code: UGX\n  iso_numeric: \"800\"\n  html_code: \"\"\n  symbol: USh\n  minor_unit: Cent\n  minor_unit_conversion: 1\n  smallest_denomination: 1000\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nuyu:\n  name: Uruguayan Peso\n  priority: 100\n  iso_code: UYU\n  iso_numeric: \"858\"\n  html_code: \"$U\"\n  symbol: \"$U\"\n  minor_unit: Centésimo\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 2\nuzs:\n  name: Uzbekistan Som\n  priority: 100\n  iso_code: UZS\n  iso_numeric: \"860\"\n  html_code: \"\"\n  symbol: so'm\n  minor_unit: Tiyin\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nves:\n  name: Venezuelan Bolívar Soberano\n  priority: 100\n  iso_code: VES\n  iso_numeric: \"928\"\n  html_code: \"\"\n  symbol: Bs\n  minor_unit: Céntimo\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 2\nvnd:\n  name: Vietnamese Đồng\n  priority: 100\n  iso_code: VND\n  iso_numeric: \"704\"\n  html_code: \"&#x20AB;\"\n  symbol: \"₫\"\n  minor_unit: Hào\n  minor_unit_conversion: 1\n  smallest_denomination: 100\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%n %u\"\n  default_precision: 0\nvuv:\n  name: Vanuatu Vatu\n  priority: 100\n  iso_code: VUV\n  iso_numeric: \"548\"\n  html_code: \"\"\n  symbol: Vt\n  minor_unit:\n  minor_unit_conversion: 1\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 0\nwst:\n  name: Samoan Tala\n  priority: 100\n  iso_code: WST\n  iso_numeric: \"882\"\n  html_code: \"\"\n  symbol: T\n  minor_unit: Sene\n  minor_unit_conversion: 100\n  smallest_denomination: 10\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nxaf:\n  name: Central African Cfa Franc\n  priority: 100\n  iso_code: XAF\n  iso_numeric: \"950\"\n  html_code: \"\"\n  symbol: CFA\n  minor_unit: Centime\n  minor_unit_conversion: 1\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nxag:\n  name: Silver (Troy Ounce)\n  priority: 100\n  iso_code: XAG\n  iso_numeric: \"961\"\n  html_code: \"\"\n  symbol: oz t\n  minor_unit: oz\n  minor_unit_conversion: 1\n  smallest_denomination:\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nxau:\n  name: Gold (Troy Ounce)\n  priority: 100\n  iso_code: XAU\n  iso_numeric: \"959\"\n  html_code: \"\"\n  symbol: oz t\n  minor_unit: oz\n  minor_unit_conversion: 1\n  smallest_denomination:\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nxba:\n  name: European Composite Unit\n  priority: 100\n  iso_code: XBA\n  iso_numeric: \"955\"\n  html_code: \"\"\n  symbol: \"\"\n  minor_unit: \"\"\n  minor_unit_conversion: 1\n  smallest_denomination:\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nxbb:\n  name: European Monetary Unit\n  priority: 100\n  iso_code: XBB\n  iso_numeric: \"956\"\n  html_code: \"\"\n  symbol: \"\"\n  minor_unit: \"\"\n  minor_unit_conversion: 1\n  smallest_denomination:\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nbyn:\n  name: Belarusian Ruble\n  priority: 100\n  iso_code: BYN\n  iso_numeric: \"933\"\n  html_code: \"\"\n  symbol: Br\n  minor_unit: Kapeyka\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \" \"\n  default_format: \"%n %u\"\n  default_precision: 2\nbzd:\n  name: Belize Dollar\n  priority: 100\n  iso_code: BZD\n  iso_numeric: \"084\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ncdf:\n  name: Congolese Franc\n  priority: 100\n  iso_code: CDF\n  iso_numeric: \"976\"\n  html_code: \"\"\n  symbol: Fr\n  minor_unit: Centime\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nchf:\n  name: Swiss Franc\n  priority: 100\n  iso_code: CHF\n  iso_numeric: \"756\"\n  html_code: \"\"\n  symbol: CHF\n  minor_unit: Rappen\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u %n\"\n  default_precision: 2\nclf:\n  name: Unidad de Fomento\n  priority: 100\n  iso_code: CLF\n  iso_numeric: \"990\"\n  html_code: \"&#x20B1;\"\n  symbol: UF\n  minor_unit: Peso\n  minor_unit_conversion: 10000\n  smallest_denomination:\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 4\nclp:\n  name: Chilean Peso\n  priority: 100\n  iso_code: CLP\n  iso_numeric: \"152\"\n  html_code: \"&#36;\"\n  symbol: \"$\"\n  minor_unit: Peso\n  minor_unit_conversion: 1\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 0\ncny:\n  name: Chinese Renminbi Yuan\n  priority: 100\n  iso_code: CNY\n  iso_numeric: \"156\"\n  html_code: \"￥\"\n  symbol: \"¥\"\n  minor_unit: Fen\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ncop:\n  name: Colombian Peso\n  priority: 100\n  iso_code: COP\n  iso_numeric: \"170\"\n  html_code: \"&#36;\"\n  symbol: \"$\"\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 20\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 2\ncrc:\n  name: Costa Rican Colón\n  priority: 100\n  iso_code: CRC\n  iso_numeric: \"188\"\n  html_code: \"&#x20A1;\"\n  symbol: \"₡\"\n  minor_unit: Céntimo\n  minor_unit_conversion: 100\n  smallest_denomination: 500\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 2\ncuc:\n  name: Cuban Convertible Peso\n  priority: 100\n  iso_code: CUC\n  iso_numeric: \"931\"\n  html_code: \"\"\n  symbol: \"$\"\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\ncup:\n  name: Cuban Peso\n  priority: 100\n  iso_code: CUP\n  iso_numeric: \"192\"\n  html_code: \"&#x20B1;\"\n  symbol: \"$\"\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ncve:\n  name: Cape Verdean Escudo\n  priority: 100\n  iso_code: CVE\n  iso_numeric: \"132\"\n  html_code: \"\"\n  symbol: \"$\"\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nczk:\n  name: Czech Koruna\n  priority: 100\n  iso_code: CZK\n  iso_numeric: \"203\"\n  html_code: \"\"\n  symbol: Kč\n  minor_unit: Haléř\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \",\"\n  delimiter: \" \"\n  default_format: \"%n %u\"\n  default_precision: 2\ndjf:\n  name: Djiboutian Franc\n  priority: 100\n  iso_code: DJF\n  iso_numeric: \"262\"\n  html_code: \"\"\n  symbol: Fdj\n  minor_unit: Centime\n  minor_unit_conversion: 1\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\ndkk:\n  name: Danish Krone\n  priority: 100\n  iso_code: DKK\n  iso_numeric: \"208\"\n  html_code: \"\"\n  symbol: kr.\n  minor_unit: Øre\n  minor_unit_conversion: 100\n  smallest_denomination: 50\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%n %u\"\n  default_precision: 2\ndop:\n  name: Dominican Peso\n  priority: 100\n  iso_code: DOP\n  iso_numeric: \"214\"\n  html_code: \"&#x20B1;\"\n  symbol: \"$\"\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ndzd:\n  name: Algerian Dinar\n  priority: 100\n  iso_code: DZD\n  iso_numeric: \"012\"\n  html_code: \"\"\n  symbol: د.ج\n  minor_unit: Centime\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\negp:\n  name: Egyptian Pound\n  priority: 100\n  iso_code: EGP\n  iso_numeric: \"818\"\n  html_code: \"&#x00A3;\"\n  symbol: ج.م\n  minor_unit: Piastre\n  minor_unit_conversion: 100\n  smallest_denomination: 25\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nern:\n  name: Eritrean Nakfa\n  priority: 100\n  iso_code: ERN\n  iso_numeric: \"232\"\n  html_code: \"\"\n  symbol: Nfk\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\netb:\n  name: Ethiopian Birr\n  priority: 100\n  iso_code: ETB\n  iso_numeric: \"230\"\n  html_code: \"\"\n  symbol: Br\n  minor_unit: Santim\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nfjd:\n  name: Fijian Dollar\n  priority: 100\n  iso_code: FJD\n  iso_numeric: \"242\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nkhr:\n  name: Cambodian Riel\n  priority: 100\n  iso_code: KHR\n  iso_numeric: \"116\"\n  html_code: \"&#x17DB;\"\n  symbol: \"៛\"\n  minor_unit: Sen\n  minor_unit_conversion: 100\n  smallest_denomination: 5000\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\naed:\n  name: United Arab Emirates Dirham\n  priority: 100\n  iso_code: AED\n  iso_numeric: \"784\"\n  html_code: \"\"\n  symbol: د.إ\n  minor_unit: Fils\n  minor_unit_conversion: 100\n  smallest_denomination: 25\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nafn:\n  name: Afghan Afghani\n  priority: 100\n  iso_code: AFN\n  iso_numeric: \"971\"\n  html_code: \"\"\n  symbol: \"؋\"\n  minor_unit: Pul\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nall:\n  name: Albanian Lek\n  priority: 100\n  iso_code: ALL\n  iso_numeric: \"008\"\n  html_code: \"\"\n  symbol: L\n  minor_unit: Qintar\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\namd:\n  name: Armenian Dram\n  priority: 100\n  iso_code: AMD\n  iso_numeric: \"051\"\n  html_code: \"&#1423;\"\n  symbol: ֏\n  minor_unit: Luma\n  minor_unit_conversion: 100\n  smallest_denomination: 10\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nang:\n  name: Netherlands Antillean Gulden\n  priority: 100\n  iso_code: ANG\n  iso_numeric: \"532\"\n  html_code: \"&#x0192;\"\n  symbol: ƒ\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 2\naoa:\n  name: Angolan Kwanza\n  priority: 100\n  iso_code: AOA\n  iso_numeric: \"973\"\n  html_code: \"\"\n  symbol: Kz\n  minor_unit: Cêntimo\n  minor_unit_conversion: 100\n  smallest_denomination: 10\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nars:\n  name: Argentine Peso\n  priority: 100\n  iso_code: ARS\n  iso_numeric: \"032\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 2\nawg:\n  name: Aruban Florin\n  priority: 100\n  iso_code: AWG\n  iso_numeric: \"533\"\n  html_code: \"&#x0192;\"\n  symbol: ƒ\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nazn:\n  name: Azerbaijani Manat\n  priority: 100\n  iso_code: AZN\n  iso_numeric: \"944\"\n  html_code: \"\"\n  symbol: \"₼\"\n  minor_unit: Qəpik\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nbam:\n  name: Bosnia and Herzegovina Convertible Mark\n  priority: 100\n  iso_code: BAM\n  iso_numeric: \"977\"\n  html_code: \"\"\n  symbol: КМ\n  minor_unit: Fening\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nbbd:\n  name: Barbadian Dollar\n  priority: 100\n  iso_code: BBD\n  iso_numeric: \"052\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nbdt:\n  name: Bangladeshi Taka\n  priority: 100\n  iso_code: BDT\n  iso_numeric: \"050\"\n  html_code: \"\"\n  symbol: \"৳\"\n  minor_unit: Paisa\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nbgn:\n  name: Bulgarian Lev\n  priority: 100\n  iso_code: BGN\n  iso_numeric: \"975\"\n  html_code: \"\"\n  symbol: лв.\n  minor_unit: Stotinka\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nbhd:\n  name: Bahraini Dinar\n  priority: 100\n  iso_code: BHD\n  iso_numeric: \"048\"\n  html_code: \"\"\n  symbol: د.ب\n  minor_unit: Fils\n  minor_unit_conversion: 1000\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 3\nbif:\n  name: Burundian Franc\n  priority: 100\n  iso_code: BIF\n  iso_numeric: \"108\"\n  html_code: \"\"\n  symbol: Fr\n  minor_unit: Centime\n  minor_unit_conversion: 1\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nbmd:\n  name: Bermudian Dollar\n  priority: 100\n  iso_code: BMD\n  iso_numeric: \"060\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nbnd:\n  name: Brunei Dollar\n  priority: 100\n  iso_code: BND\n  iso_numeric: \"096\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Sen\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nbob:\n  name: Bolivian Boliviano\n  priority: 100\n  iso_code: BOB\n  iso_numeric: \"068\"\n  html_code: \"\"\n  symbol: Bs.\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 10\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nbrl:\n  name: Brazilian Real\n  priority: 100\n  iso_code: BRL\n  iso_numeric: \"986\"\n  html_code: R$\n  symbol: R$\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 2\nbsd:\n  name: Bahamian Dollar\n  priority: 100\n  iso_code: BSD\n  iso_numeric: \"044\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nbtn:\n  name: Bhutanese Ngultrum\n  priority: 100\n  iso_code: BTN\n  iso_numeric: \"064\"\n  html_code: \"\"\n  symbol: Nu.\n  minor_unit: Chertrum\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nbwp:\n  name: Botswana Pula\n  priority: 100\n  iso_code: BWP\n  iso_numeric: \"072\"\n  html_code: \"\"\n  symbol: P\n  minor_unit: Thebe\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nkmf:\n  name: Comorian Franc\n  priority: 100\n  iso_code: KMF\n  iso_numeric: \"174\"\n  html_code: \"\"\n  symbol: Fr\n  minor_unit: Centime\n  minor_unit_conversion: 1\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\nkpw:\n  name: North Korean Won\n  priority: 100\n  iso_code: KPW\n  iso_numeric: \"408\"\n  html_code: \"&#x20A9;\"\n  symbol: \"₩\"\n  minor_unit: Chŏn\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nkrw:\n  name: South Korean Won\n  priority: 100\n  iso_code: KRW\n  iso_numeric: \"410\"\n  html_code: \"&#x20A9;\"\n  symbol: \"₩\"\n  minor_unit:\n  minor_unit_conversion: 1\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 0\nkwd:\n  name: Kuwaiti Dinar\n  priority: 100\n  iso_code: KWD\n  iso_numeric: \"414\"\n  html_code: \"\"\n  symbol: د.ك\n  minor_unit: Fils\n  minor_unit_conversion: 1000\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 3\nkyd:\n  name: Cayman Islands Dollar\n  priority: 100\n  iso_code: KYD\n  iso_numeric: \"136\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nkzt:\n  name: Kazakhstani Tenge\n  priority: 100\n  iso_code: KZT\n  iso_numeric: \"398\"\n  html_code: \"\"\n  symbol: \"₸\"\n  minor_unit: Tiyn\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nlak:\n  name: Lao Kip\n  priority: 100\n  iso_code: LAK\n  iso_numeric: \"418\"\n  html_code: \"&#x20AD;\"\n  symbol: \"₭\"\n  minor_unit: Att\n  minor_unit_conversion: 100\n  smallest_denomination: 10\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nlbp:\n  name: Lebanese Pound\n  priority: 100\n  iso_code: LBP\n  iso_numeric: \"422\"\n  html_code: \"&#x00A3;\"\n  symbol: ل.ل\n  minor_unit: Piastre\n  minor_unit_conversion: 100\n  smallest_denomination: 25000\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nlkr:\n  name: Sri Lankan Rupee\n  priority: 100\n  iso_code: LKR\n  iso_numeric: \"144\"\n  html_code: \"&#8360;\"\n  symbol: \"₨\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nlrd:\n  name: Liberian Dollar\n  priority: 100\n  iso_code: LRD\n  iso_numeric: \"430\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nlsl:\n  name: Lesotho Loti\n  priority: 100\n  iso_code: LSL\n  iso_numeric: \"426\"\n  html_code: \"\"\n  symbol: L\n  minor_unit: Sente\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nlyd:\n  name: Libyan Dinar\n  priority: 100\n  iso_code: LYD\n  iso_numeric: \"434\"\n  html_code: \"\"\n  symbol: ل.د\n  minor_unit: Dirham\n  minor_unit_conversion: 1000\n  smallest_denomination: 50\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 3\nmad:\n  name: Moroccan Dirham\n  priority: 100\n  iso_code: MAD\n  iso_numeric: \"504\"\n  html_code: \"\"\n  symbol: د.م.\n  minor_unit: Centime\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nmdl:\n  name: Moldovan Leu\n  priority: 100\n  iso_code: MDL\n  iso_numeric: \"498\"\n  html_code: \"\"\n  symbol: L\n  minor_unit: Ban\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nmga:\n  name: Malagasy Ariary\n  priority: 100\n  iso_code: MGA\n  iso_numeric: \"969\"\n  html_code: \"\"\n  symbol: Ar\n  minor_unit: Iraimbilanja\n  minor_unit_conversion: 5\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 1\nmkd:\n  name: Macedonian Denar\n  priority: 100\n  iso_code: MKD\n  iso_numeric: \"807\"\n  html_code: \"\"\n  symbol: ден\n  minor_unit: Deni\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nmmk:\n  name: Myanmar Kyat\n  priority: 100\n  iso_code: MMK\n  iso_numeric: \"104\"\n  html_code: \"\"\n  symbol: K\n  minor_unit: Pya\n  minor_unit_conversion: 100\n  smallest_denomination: 50\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nmnt:\n  name: Mongolian Tögrög\n  priority: 100\n  iso_code: MNT\n  iso_numeric: \"496\"\n  html_code: \"&#x20AE;\"\n  symbol: \"₮\"\n  minor_unit: Möngö\n  minor_unit_conversion: 100\n  smallest_denomination: 2000\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nmop:\n  name: Macanese Pataca\n  priority: 100\n  iso_code: MOP\n  iso_numeric: \"446\"\n  html_code: \"\"\n  symbol: P\n  minor_unit: Avo\n  minor_unit_conversion: 100\n  smallest_denomination: 10\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nmru:\n  name: Mauritanian Ouguiya\n  priority: 100\n  iso_code: MRU\n  iso_numeric: \"929\"\n  html_code: \"\"\n  symbol: UM\n  minor_unit: Khoums\n  minor_unit_conversion: 5\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 1\nmur:\n  name: Mauritian Rupee\n  priority: 100\n  iso_code: MUR\n  iso_numeric: \"480\"\n  html_code: \"&#x20A8;\"\n  symbol: \"₨\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nfkp:\n  name: Falkland Pound\n  priority: 100\n  iso_code: FKP\n  iso_numeric: \"238\"\n  html_code: \"&#x00A3;\"\n  symbol: \"£\"\n  minor_unit: Penny\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\ngel:\n  name: Georgian Lari\n  priority: 100\n  iso_code: GEL\n  iso_numeric: \"981\"\n  html_code: \"\"\n  symbol: \"₾\"\n  minor_unit: Tetri\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nghs:\n  name: Ghanaian Cedi\n  priority: 100\n  iso_code: GHS\n  iso_numeric: \"936\"\n  html_code: \"&#x20B5;\"\n  symbol: \"₵\"\n  minor_unit: Pesewa\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ngip:\n  name: Gibraltar Pound\n  priority: 100\n  iso_code: GIP\n  iso_numeric: \"292\"\n  html_code: \"&#x00A3;\"\n  symbol: \"£\"\n  minor_unit: Penny\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ngmd:\n  name: Gambian Dalasi\n  priority: 100\n  iso_code: GMD\n  iso_numeric: \"270\"\n  html_code: \"\"\n  symbol: D\n  minor_unit: Butut\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\ngnf:\n  name: Guinean Franc\n  priority: 100\n  iso_code: GNF\n  iso_numeric: \"324\"\n  html_code: \"\"\n  symbol: Fr\n  minor_unit: Centime\n  minor_unit_conversion: 1\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 0\ngtq:\n  name: Guatemalan Quetzal\n  priority: 100\n  iso_code: GTQ\n  iso_numeric: \"320\"\n  html_code: \"\"\n  symbol: Q\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ngyd:\n  name: Guyanese Dollar\n  priority: 100\n  iso_code: GYD\n  iso_numeric: \"328\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 100\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nhkd:\n  name: Hong Kong Dollar\n  priority: 100\n  iso_code: HKD\n  iso_numeric: \"344\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 10\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nhnl:\n  name: Honduran Lempira\n  priority: 100\n  iso_code: HNL\n  iso_numeric: \"340\"\n  html_code: \"\"\n  symbol: L\n  minor_unit: Centavo\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nhtg:\n  name: Haitian Gourde\n  priority: 100\n  iso_code: HTG\n  iso_numeric: \"332\"\n  html_code: \"\"\n  symbol: G\n  minor_unit: Centime\n  minor_unit_conversion: 100\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\nhuf:\n  name: Hungarian Forint\n  priority: 100\n  iso_code: HUF\n  iso_numeric: \"348\"\n  html_code: \"\"\n  symbol: Ft\n  minor_unit: \"\"\n  minor_unit_conversion: 1\n  smallest_denomination: 5\n  separator: \",\"\n  delimiter: \" \"\n  default_format: \"%n %u\"\n  default_precision: 0\nidr:\n  name: Indonesian Rupiah\n  priority: 100\n  iso_code: IDR\n  iso_numeric: \"360\"\n  html_code: \"\"\n  symbol: Rp\n  minor_unit: Sen\n  minor_unit_conversion: 100\n  smallest_denomination: 5000\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%u%n\"\n  default_precision: 2\nils:\n  name: Israeli New Sheqel\n  priority: 100\n  iso_code: ILS\n  iso_numeric: \"376\"\n  html_code: \"&#x20AA;\"\n  symbol: \"₪\"\n  minor_unit: Agora\n  minor_unit_conversion: 100\n  smallest_denomination: 10\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\ninr:\n  name: Indian Rupee\n  priority: 100\n  iso_code: INR\n  iso_numeric: \"356\"\n  html_code: \"&#x20b9;\"\n  symbol: \"₹\"\n  minor_unit: Paisa\n  minor_unit_conversion: 100\n  smallest_denomination: 50\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\niqd:\n  name: Iraqi Dinar\n  priority: 100\n  iso_code: IQD\n  iso_numeric: \"368\"\n  html_code: \"\"\n  symbol: ع.د\n  minor_unit: Fils\n  minor_unit_conversion: 1000\n  smallest_denomination: 50000\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 3\nirr:\n  name: Iranian Rial\n  priority: 100\n  iso_code: IRR\n  iso_numeric: \"364\"\n  html_code: \"&#xFDFC;\"\n  symbol: \"﷼\"\n  minor_unit:\n  minor_unit_conversion: 100\n  smallest_denomination: 5000\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nisk:\n  name: Icelandic Króna\n  priority: 100\n  iso_code: ISK\n  iso_numeric: \"352\"\n  html_code: \"\"\n  symbol: kr.\n  minor_unit:\n  minor_unit_conversion: 1\n  smallest_denomination: 1\n  separator: \",\"\n  delimiter: \".\"\n  default_format: \"%n %u\"\n  default_precision: 0\njmd:\n  name: Jamaican Dollar\n  priority: 100\n  iso_code: JMD\n  iso_numeric: \"388\"\n  html_code: \"$\"\n  symbol: \"$\"\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\njod:\n  name: Jordanian Dinar\n  priority: 100\n  iso_code: JOD\n  iso_numeric: \"400\"\n  html_code: \"\"\n  symbol: د.ا\n  minor_unit: Fils\n  minor_unit_conversion: 1000\n  smallest_denomination: 5\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 3\nkes:\n  name: Kenyan Shilling\n  priority: 100\n  iso_code: KES\n  iso_numeric: \"404\"\n  html_code: \"\"\n  symbol: KSh\n  minor_unit: Cent\n  minor_unit_conversion: 100\n  smallest_denomination: 50\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%u%n\"\n  default_precision: 2\nkgs:\n  name: Kyrgyzstani Som\n  priority: 100\n  iso_code: KGS\n  iso_numeric: \"417\"\n  html_code: \"\"\n  symbol: som\n  minor_unit: Tyiyn\n  minor_unit_conversion: 100\n  smallest_denomination: 1\n  separator: \".\"\n  delimiter: \",\"\n  default_format: \"%n %u\"\n  default_precision: 2\n"
  },
  {
    "path": "config/database.yml",
    "content": "default: &default\n  adapter: postgresql\n  encoding: unicode\n  pool: <%= ENV.fetch(\"RAILS_MAX_THREADS\") { 3 } %>\n  host: <%= ENV.fetch(\"DB_HOST\") { \"127.0.0.1\" } %>\n  port: <%= ENV.fetch(\"DB_PORT\") { \"5432\" } %>\n  user: <%= ENV.fetch(\"POSTGRES_USER\") { nil } %>\n  password: <%= ENV.fetch(\"POSTGRES_PASSWORD\") { nil } %>\n\ndevelopment:\n  <<: *default\n  database: <%= ENV.fetch(\"POSTGRES_DB\") { \"maybe_development\" } %>\n\ntest:\n  <<: *default\n  database: <%= ENV.fetch(\"POSTGRES_DB\") { \"maybe_test\" } %>\n\nproduction:\n  <<: *default\n  database: <%= ENV.fetch(\"POSTGRES_DB\") { \"maybe_production\" } %>\n"
  },
  {
    "path": "config/environment.rb",
    "content": "# Load the Rails application.\nrequire_relative \"application\"\n\n# Initialize the Rails application.\nRails.application.initialize!\n"
  },
  {
    "path": "config/environments/development.rb",
    "content": "require \"active_support/core_ext/integer/time\"\n\nRails.application.configure do\n  # Settings specified here will take precedence over those in config/application.rb.\n\n  # In the development environment your application's code is reloaded any time\n  # it changes. This slows down response time but is perfect for development\n  # since you don't have to restart the web server when you make code changes.\n  config.enable_reloading = true\n\n  # Do not eager load code on boot.\n  config.eager_load = false\n\n  # Show full error reports.\n  config.consider_all_requests_local = true\n\n  # Enable server timing\n  config.server_timing = true\n\n  # Enable/disable caching. By default caching is disabled.\n  # Run rails dev:cache to toggle caching.\n  if Rails.root.join(\"tmp/caching-dev.txt\").exist?\n    config.action_controller.perform_caching = true\n    config.action_controller.enable_fragment_cache_logging = true\n\n    config.cache_store = :memory_store\n    config.public_file_server.headers = {\n      \"Cache-Control\" => \"public, max-age=#{2.days.to_i}\"\n    }\n  else\n    config.action_controller.perform_caching = false\n\n    config.cache_store = :null_store\n  end\n\n  # Store uploaded files on the local file system (see config/storage.yml for options).\n  config.active_storage.service = ENV.fetch(\"ACTIVE_STORAGE_SERVICE\", \"local\").to_sym\n  config.after_initialize do\n    ActiveStorage::Current.url_options = { host: \"localhost\", port: 3000 }\n  end\n\n  # Set Active Storage URL expiration time to 7 days\n  config.active_storage.urls_expire_in = 7.days\n\n  # Don't care if the mailer can't send.\n  config.action_mailer.raise_delivery_errors = false\n  config.action_mailer.delivery_method = :letter_opener\n\n  config.action_mailer.perform_caching = false\n\n  config.action_mailer.perform_deliveries = true\n\n  config.action_mailer.default_url_options = { host: \"localhost\", port: ENV.fetch(\"PORT\") { 3000 } }\n\n  # Print deprecation notices to the Rails logger.\n  config.active_support.deprecation = :log\n\n  # Raise exceptions for disallowed deprecations.\n  config.active_support.disallowed_deprecation = :raise\n\n  # Tell Active Support which deprecation messages to disallow.\n  config.active_support.disallowed_deprecation_warnings = []\n\n  # Raise an error on page load if there are pending migrations.\n  config.active_record.migration_error = :page_load\n\n  config.assets.quiet = true\n  config.active_record.verbose_query_logs = true\n  config.active_job.verbose_enqueue_logs = true\n\n  # Raises error for missing translations.\n  # config.i18n.raise_on_missing_translations = true\n\n  # Annotate rendered view with file names.\n  config.action_view.annotate_rendered_view_with_filenames = true\n\n  # Uncomment if you wish to allow Action Cable access from any origin.\n  # config.action_cable.disable_request_forgery_protection = true\n\n  # Raise error when a before_action's only/except options reference missing actions\n  config.action_controller.raise_on_missing_callback_actions = true\n\n  # Apply autocorrection by RuboCop to files generated by `bin/rails generate`.\n  config.generators.apply_rubocop_autocorrect_after_generate!\n\n  # Allow connection from any host in development\n  config.hosts = nil\nend\n"
  },
  {
    "path": "config/environments/production.rb",
    "content": "require \"active_support/core_ext/integer/time\"\n\nRails.application.configure do\n  # Settings specified here will take precedence over those in config/application.rb.\n\n  # Code is not reloaded between requests.\n  config.enable_reloading = false\n\n  # Eager load code on boot. This eager loads most of Rails and\n  # your application in memory, allowing both threaded web servers\n  # and those relying on copy on write to perform better.\n  # Rake tasks automatically ignore this option for performance.\n  config.eager_load = true\n\n  # Full error reports are disabled and caching is turned on.\n  config.consider_all_requests_local = false\n  config.action_controller.perform_caching = true\n\n  # Ensures that a master key has been made available in ENV[\"RAILS_MASTER_KEY\"], config/master.key, or an environment\n  # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files).\n  # config.require_master_key = true\n\n  # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead.\n  # config.public_file_server.enabled = false\n\n  # Enable serving of images, stylesheets, and JavaScripts from an asset server.\n  # config.asset_host = \"http://assets.example.com\"\n\n  # Specifies the header that your server uses for sending files.\n  # config.action_dispatch.x_sendfile_header = \"X-Sendfile\" # for Apache\n  # config.action_dispatch.x_sendfile_header = \"X-Accel-Redirect\" # for NGINX\n\n  # Store uploaded files on the local file system (see config/storage.yml for options).\n  config.active_storage.service = ENV.fetch(\"ACTIVE_STORAGE_SERVICE\", \"local\").to_sym\n\n  # Set Active Storage URL expiration time to 7 days\n  config.active_storage.urls_expire_in = 7.days\n\n  # Mount Action Cable outside main process or domain.\n  # config.action_cable.mount_path = nil\n  # config.action_cable.url = \"wss://example.com/cable\"\n  # config.action_cable.allowed_request_origins = [ \"http://example.com\", /http:\\/\\/example.*/ ]\n\n  # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.\n  config.force_ssl = ActiveModel::Type::Boolean.new.cast(ENV.fetch(\"RAILS_FORCE_SSL\", true))\n\n  # Assume all access to the app is happening through a SSL-terminating reverse proxy.\n  # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies.\n  config.assume_ssl = ActiveModel::Type::Boolean.new.cast(ENV.fetch(\"RAILS_ASSUME_SSL\", true))\n\n  # Log to Logtail if API key is present, otherwise log to STDOUT\n  base_logger = if ENV[\"LOGTAIL_API_KEY\"].present? && ENV[\"LOGTAIL_INGESTING_HOST\"].present?\n    Logtail::Logger.create_default_logger(\n      ENV[\"LOGTAIL_API_KEY\"],\n      ingesting_host: ENV[\"LOGTAIL_INGESTING_HOST\"]\n    )\n  else\n    ActiveSupport::Logger.new(STDOUT)\n      .tap { |logger| logger.formatter = ::Logger::Formatter.new }\n  end\n\n  config.logger = ActiveSupport::TaggedLogging.new(base_logger)\n\n  # Prepend all log lines with the following tags.\n  config.log_tags = [ :request_id ]\n\n  # \"info\" includes generic and useful information about system operation, but avoids logging too much\n  # information to avoid inadvertent exposure of personally identifiable information (PII). If you\n  # want to log everything, set the level to \"debug\".\n  config.log_level = ENV.fetch(\"RAILS_LOG_LEVEL\", \"info\")\n\n  if ENV[\"CACHE_REDIS_URL\"].present?\n    config.cache_store = :redis_cache_store, { url: ENV[\"CACHE_REDIS_URL\"] }\n  end\n\n  config.action_mailer.perform_caching = false\n  config.action_mailer.deliver_later_queue_name = :high_priority\n  config.action_mailer.default_url_options = { host: ENV[\"APP_DOMAIN\"] }\n  config.action_mailer.delivery_method = :smtp\n  config.action_mailer.smtp_settings = {\n    address:   ENV[\"SMTP_ADDRESS\"],\n    port:      ENV[\"SMTP_PORT\"],\n    user_name: ENV[\"SMTP_USERNAME\"],\n    password:  ENV[\"SMTP_PASSWORD\"],\n    tls:       ENV[\"SMTP_TLS_ENABLED\"] == \"true\"\n  }\n\n  # Ignore bad email addresses and do not raise email delivery errors.\n  # Set this to true and configure the email server for immediate delivery to raise delivery errors.\n  # config.action_mailer.raise_delivery_errors = false\n\n  # Enable locale fallbacks for I18n (makes lookups for any locale fall back to\n  # the I18n.default_locale when a translation cannot be found).\n  config.i18n.fallbacks = true\n\n  # Don't log any deprecations.\n  config.active_support.report_deprecations = false\n\n  # Do not dump schema after migrations.\n  config.active_record.dump_schema_after_migration = false\n\n  # Enable DNS rebinding protection and other `Host` header attacks.\n  # config.hosts = [\n  #   \"example.com\",     # Allow requests from example.com\n  #   /.*\\.example\\.com/ # Allow requests from subdomains like `www.example.com`\n  # ]\n  # Skip DNS rebinding protection for the default health check endpoint.\n  # config.host_authorization = { exclude: ->(request) { request.path == \"/up\" } }\n\n  # set REDIS_URL for Sidekiq to use Redis\n  config.active_job.queue_adapter = :sidekiq\nend\n"
  },
  {
    "path": "config/environments/test.rb",
    "content": "require \"active_support/core_ext/integer/time\"\n\n# The test environment is used exclusively to run your application's\n# test suite. You never need to work with it otherwise. Remember that\n# your test database is \"scratch space\" for the test suite and is wiped\n# and recreated between test runs. Don't rely on the data there!\n\nRails.application.configure do\n  # Settings specified here will take precedence over those in config/application.rb.\n\n  # While tests run files are not watched, reloading is not necessary.\n  config.enable_reloading = false\n\n  # Eager loading loads your entire application. When running a single test locally,\n  # this is usually not necessary, and can slow down your test suite. However, it's\n  # recommended that you enable it in continuous integration systems to ensure eager\n  # loading is working properly before deploying your code.\n  config.eager_load = ENV[\"CI\"].present?\n\n  # Configure public file server for tests with Cache-Control for performance.\n  config.public_file_server.enabled = true\n  config.public_file_server.headers = {\n    \"Cache-Control\" => \"public, max-age=#{1.hour.to_i}\"\n  }\n\n  # Set default sender email for tests\n  ENV[\"EMAIL_SENDER\"] = \"hello@maybefinance.com\"\n\n  # Show full error reports and disable caching.\n  config.consider_all_requests_local = true\n  config.action_controller.perform_caching = false\n  config.cache_store = :null_store\n\n  # Render exception templates for rescuable exceptions and raise for other exceptions.\n  config.action_dispatch.show_exceptions = :rescuable\n\n  # Disable request forgery protection in test environment.\n  config.action_controller.allow_forgery_protection = false\n\n  # Store uploaded files on the local file system in a temporary directory.\n  config.active_storage.service = :test\n\n  config.action_mailer.perform_caching = false\n\n  # Tell Action Mailer not to deliver emails to the real world.\n  # The :test delivery method accumulates sent emails in the\n  # ActionMailer::Base.deliveries array.\n  config.action_mailer.delivery_method = :test\n\n  # Print deprecation notices to the stderr.\n  config.active_support.deprecation = :stderr\n\n  # Raise exceptions for disallowed deprecations.\n  config.active_support.disallowed_deprecation = :raise\n\n  # Tell Active Support which deprecation messages to disallow.\n  config.active_support.disallowed_deprecation_warnings = []\n\n  config.active_job.queue_adapter = :test\n\n  # Raises error for missing translations.\n  # config.i18n.raise_on_missing_translations = true\n\n  # Annotate rendered view with file names.\n  # config.action_view.annotate_rendered_view_with_filenames = true\n\n  # Raise error when a before_action's only/except options reference missing actions\n  config.action_controller.raise_on_missing_callback_actions = true\n\n  config.active_record.encryption.primary_key = \"test\"\n  config.active_record.encryption.deterministic_key = \"test\"\n  config.active_record.encryption.key_derivation_salt = \"test\"\n  config.active_record.encryption.encrypt_fixtures = true\n\n  config.autoload_paths += %w[test/support]\n\n  config.action_mailer.default_url_options = { host: \"example.com\" }\nend\n"
  },
  {
    "path": "config/i18n-tasks.yml",
    "content": "base_locale: en\nfallbacks:\n  - default\ndata:\n  read:\n    - config/locales/**/*%{locale}.yml\n  write:\n    - config/locales/**/*%{locale}.yml\n  router: conservative_router\nsearch:\n  paths:\n    - app/\n  relative_roots:\n    - app/controllers\n    - app/controllers/concerns\n    - app/helpers\n    - app/mailers\n    - app/presenters\n    - app/views\n  strict: false\n  ## Files or `File.fnmatch` patterns to exclude from search. Some files are always excluded regardless of this setting:\n  ##   *.jpg *.jpeg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss *.less\n  ##   *.yml *.json *.zip *.tar.gz *.swf *.flv *.mp3 *.wav *.flac *.webm *.mp4 *.ogg *.opus *.webp *.map *.xlsx\n  exclude:\n    - app/assets/images\n    - app/assets/fonts\n    - app/assets/videos\n    - app/assets/builds\nignore_unused:\n  - 'activerecord.attributes.*' # i18n-tasks does not detect these on forms, forms validations (https://github.com/glebm/i18n-tasks/blob/0b4b483c82664f26c5696fb0f6aa1297356e4683/templates/config/i18n-tasks.yml#L146)\n  - 'activerecord.models.*' # i18n-tasks does not detect use in dynamic model names (e.g. object.model_name.human)\n  - 'activerecord.errors*'\n  - 'activemodel.errors.models.*'\n  - 'helpers.submit.*' # i18n-tasks does not detect used at forms\n  - 'helpers.label.*' # i18n-tasks does not detect used at forms\n  - 'accounts.show.sync_message_*' # messages generated in the sync ActiveJob\n  - 'address.attributes.*'\n  - 'date.*'\n  - 'time.*'\n  - 'datetime.*'\n  - 'number.*'\n  - 'errors.*'\n  - 'helpers.*'\n  - 'support.*'\n  - '{credit_cards,cryptos,depositories,other_assets,other_liabilities,loans,vehicles,properties,investments}.{create,update,destroy}.success'"
  },
  {
    "path": "config/importmap.rb",
    "content": "# Pin npm packages by running ./bin/importmap\n\npin \"application\"\npin \"@hotwired/turbo-rails\", to: \"turbo.min.js\", preload: true\npin \"@hotwired/stimulus\", to: \"stimulus.min.js\"\npin \"@hotwired/stimulus-loading\", to: \"stimulus-loading.js\"\npin_all_from \"app/javascript/controllers\", under: \"controllers\"\npin_all_from \"app/components\", under: \"controllers\", to: \"\"\npin_all_from \"app/javascript/services\", under: \"services\", to: \"services\"\npin \"@github/hotkey\", to: \"@github--hotkey.js\" # @3.1.1\npin \"@simonwep/pickr\", to: \"@simonwep--pickr.js\" # @1.9.1\n\n# D3 packages\npin \"d3\" # @7.9.0\npin \"d3-array\", to: \"shims/d3-array-default.js\"\npin \"d3-axis\" # @3.0.0\npin \"d3-brush\" # @3.0.0\npin \"d3-chord\" # @3.0.1\npin \"d3-color\" # @3.1.0\npin \"d3-contour\" # @4.0.2\npin \"d3-delaunay\" # @6.0.4\npin \"d3-dispatch\" # @3.0.1\npin \"d3-drag\" # @3.0.0\npin \"d3-dsv\" # @3.0.1\npin \"d3-ease\" # @3.0.1\npin \"d3-fetch\" # @3.0.1\npin \"d3-force\" # @3.0.0\npin \"d3-format\" # @3.1.0\npin \"d3-geo\" # @3.1.1\npin \"d3-hierarchy\" # @3.1.2\npin \"d3-interpolate\" # @3.0.1\npin \"d3-path\" # @3.1.0\npin \"d3-polygon\" # @3.0.1\npin \"d3-quadtree\" # @3.0.1\npin \"d3-random\" # @3.0.1\npin \"d3-scale\" # @4.0.2\npin \"d3-scale-chromatic\" # @3.1.0\npin \"d3-selection\" # @3.0.0\npin \"d3-shape\", to: \"shims/d3-shape-default.js\"\npin \"d3-time\" # @3.1.0\npin \"d3-time-format\" # @4.1.0\npin \"d3-timer\" # @3.0.1\npin \"d3-transition\" # @3.0.1\npin \"d3-zoom\" # @3.0.0\npin \"delaunator\" # @5.0.1\npin \"internmap\" # @2.0.3\npin \"robust-predicates\" # @3.0.2\npin \"@floating-ui/dom\", to: \"@floating-ui--dom.js\" # @1.7.0\npin \"@floating-ui/core\", to: \"@floating-ui--core.js\" # @1.7.0\npin \"@floating-ui/utils\", to: \"@floating-ui--utils.js\" # @0.2.9\npin \"@floating-ui/utils/dom\", to: \"@floating-ui--utils--dom.js\" # @0.2.9\npin \"d3-sankey\" # @0.12.3\npin \"d3-array-src\", to: \"d3-array.js\"\npin \"d3-shape-src\", to: \"d3-shape.js\"\n"
  },
  {
    "path": "config/initializers/active_record_encryption.rb",
    "content": "# Auto-generate Active Record encryption keys for self-hosted instances\n# This ensures encryption works out of the box without manual setup\nif Rails.application.config.app_mode.self_hosted? && !Rails.application.credentials.active_record_encryption.present?\n  # Check if keys are provided via environment variables\n  primary_key = ENV[\"ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY\"]\n  deterministic_key = ENV[\"ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY\"]\n  key_derivation_salt = ENV[\"ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT\"]\n\n  # If any key is missing, generate all of them based on SECRET_KEY_BASE\n  if primary_key.blank? || deterministic_key.blank? || key_derivation_salt.blank?\n    # Use SECRET_KEY_BASE as the seed for deterministic key generation\n    # This ensures keys are consistent across container restarts\n    secret_base = Rails.application.secret_key_base\n\n    # Generate deterministic keys from the secret base\n    primary_key = Digest::SHA256.hexdigest(\"#{secret_base}:primary_key\")[0..63]\n    deterministic_key = Digest::SHA256.hexdigest(\"#{secret_base}:deterministic_key\")[0..63]\n    key_derivation_salt = Digest::SHA256.hexdigest(\"#{secret_base}:key_derivation_salt\")[0..63]\n  end\n\n  # Configure Active Record encryption\n  Rails.application.config.active_record.encryption.primary_key = primary_key\n  Rails.application.config.active_record.encryption.deterministic_key = deterministic_key\n  Rails.application.config.active_record.encryption.key_derivation_salt = key_derivation_salt\nend\n"
  },
  {
    "path": "config/initializers/assets.rb",
    "content": "# Be sure to restart your server when you modify this file.\n\n# Version of your assets, change this if you want to expire all your assets.\nRails.application.config.assets.version = \"1.0\"\n\n# Add additional assets to the asset load path.\nRails.application.config.assets.paths << \"app/components\"\nRails.application.config.importmap.cache_sweepers << Rails.root.join(\"app/components\")\n"
  },
  {
    "path": "config/initializers/content_security_policy.rb",
    "content": "# Be sure to restart your server when you modify this file.\n\n# Define an application-wide content security policy.\n# See the Securing Rails Applications Guide for more information:\n# https://guides.rubyonrails.org/security.html#content-security-policy-header\n\n# Rails.application.configure do\n#   config.content_security_policy do |policy|\n#     policy.default_src :self, :https\n#     policy.font_src    :self, :https, :data\n#     policy.img_src     :self, :https, :data\n#     policy.object_src  :none\n#     policy.script_src  :self, :https\n#     policy.style_src   :self, :https\n#     # Specify URI for violation reports\n#     # policy.report_uri \"/csp-violation-report-endpoint\"\n#   end\n#\n#   # Generate session nonces for permitted importmap, inline scripts, and inline styles.\n#   config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }\n#   config.content_security_policy_nonce_directives = %w(script-src style-src)\n#\n#   # Report violations without enforcing the policy.\n#   # config.content_security_policy_report_only = true\n# end\n"
  },
  {
    "path": "config/initializers/doorkeeper.rb",
    "content": "# frozen_string_literal: true\n\nDoorkeeper.configure do\n  # Change the ORM that doorkeeper will use (requires ORM extensions installed).\n  # Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms\n  orm :active_record\n\n  # This block will be called to check whether the resource owner is authenticated or not.\n  resource_owner_authenticator do\n    # Manually replicate the app's session-based authentication logic, since\n    # Doorkeeper controllers don't include our Authentication concern.\n    if (session_id = cookies.signed[:session_token]).present?\n      if (session_record = Session.find_by(id: session_id))\n        # Set Current.session so downstream code expecting it behaves normally.\n        Current.session = session_record\n        # Return the authenticated user object as the resource owner.\n        session_record.user\n      else\n        redirect_to new_session_url\n      end\n    else\n      redirect_to new_session_url\n    end\n  end\n\n  # If you didn't skip applications controller from Doorkeeper routes in your application routes.rb\n  # file then you need to declare this block in order to restrict access to the web interface for\n  # adding oauth authorized applications. In other case it will return 403 Forbidden response\n  # every time somebody will try to access the admin web interface.\n  #\n  admin_authenticator do\n    if (session_id = cookies.signed[:session_token]).present?\n      if (session_record = Session.find_by(id: session_id))\n        Current.session = session_record\n        head :forbidden unless session_record.user&.super_admin?\n      else\n        redirect_to new_session_url\n      end\n    else\n      redirect_to new_session_url\n    end\n  end\n\n  # You can use your own model classes if you need to extend (or even override) default\n  # Doorkeeper models such as `Application`, `AccessToken` and `AccessGrant.\n  #\n  # By default Doorkeeper ActiveRecord ORM uses its own classes:\n  #\n  # access_token_class \"Doorkeeper::AccessToken\"\n  # access_grant_class \"Doorkeeper::AccessGrant\"\n  # application_class \"Doorkeeper::Application\"\n  #\n  # Don't forget to include Doorkeeper ORM mixins into your custom models:\n  #\n  #   *  ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken - for access token\n  #   *  ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessGrant - for access grant\n  #   *  ::Doorkeeper::Orm::ActiveRecord::Mixins::Application - for application (OAuth2 clients)\n  #\n  # For example:\n  #\n  # access_token_class \"MyAccessToken\"\n  #\n  # class MyAccessToken < ApplicationRecord\n  #   include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken\n  #\n  #   self.table_name = \"hey_i_wanna_my_name\"\n  #\n  #   def destroy_me!\n  #     destroy\n  #   end\n  # end\n\n  # Enables polymorphic Resource Owner association for Access Tokens and Access Grants.\n  # By default this option is disabled.\n  #\n  # Make sure you properly setup you database and have all the required columns (run\n  # `bundle exec rails generate doorkeeper:enable_polymorphic_resource_owner` and execute Rails\n  # migrations).\n  #\n  # If this option enabled, Doorkeeper will store not only Resource Owner primary key\n  # value, but also it's type (class name). See \"Polymorphic Associations\" section of\n  # Rails guides: https://guides.rubyonrails.org/association_basics.html#polymorphic-associations\n  #\n  # [NOTE] If you apply this option on already existing project don't forget to manually\n  # update `resource_owner_type` column in the database and fix migration template as it will\n  # set NOT NULL constraint for Access Grants table.\n  #\n  # use_polymorphic_resource_owner\n\n  # If you are planning to use Doorkeeper in Rails 5 API-only application, then you might\n  # want to use API mode that will skip all the views management and change the way how\n  # Doorkeeper responds to a requests.\n  #\n  # api_only\n\n  # Enforce token request content type to application/x-www-form-urlencoded.\n  # It is not enabled by default to not break prior versions of the gem.\n  #\n  # enforce_content_type\n\n  # Authorization Code expiration time (default: 10 minutes).\n  #\n  # authorization_code_expires_in 10.minutes\n\n  # Access token expiration time (default: 2 hours).\n  # If you set this to `nil` Doorkeeper will not expire the token and omit expires_in in response.\n  # It is RECOMMENDED to set expiration time explicitly.\n  # Prefer access_token_expires_in 100.years or similar,\n  # which would be functionally equivalent and avoid the risk of unexpected behavior by callers.\n  #\n  access_token_expires_in 1.year\n\n  # Assign custom TTL for access tokens. Will be used instead of access_token_expires_in\n  # option if defined. In case the block returns `nil` value Doorkeeper fallbacks to\n  # +access_token_expires_in+ configuration option value. If you really need to issue a\n  # non-expiring access token (which is not recommended) then you need to return\n  # Float::INFINITY from this block.\n  #\n  # `context` has the following properties available:\n  #\n  #   * `client` - the OAuth client application (see Doorkeeper::OAuth::Client)\n  #   * `grant_type` - the grant type of the request (see Doorkeeper::OAuth)\n  #   * `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes)\n  #   * `resource_owner` - authorized resource owner instance (if present)\n  #\n  # custom_access_token_expires_in do |context|\n  #   context.client.additional_settings.implicit_oauth_expiration\n  # end\n\n  # Use a custom class for generating the access token.\n  # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-access-token-generator\n  #\n  # access_token_generator '::Doorkeeper::JWT'\n\n  # The controller +Doorkeeper::ApplicationController+ inherits from.\n  # Defaults to +ActionController::Base+ unless +api_only+ is set, which changes the default to\n  # +ActionController::API+. The return value of this option must be a stringified class name.\n  # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-controllers\n  #\n  # base_controller 'ApplicationController'\n\n  # Reuse access token for the same resource owner within an application (disabled by default).\n  #\n  # This option protects your application from creating new tokens before old **valid** one becomes\n  # expired so your database doesn't bloat. Keep in mind that when this option is enabled Doorkeeper\n  # doesn't update existing token expiration time, it will create a new token instead if no active matching\n  # token found for the application, resources owner and/or set of scopes.\n  # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383\n  #\n  # You can not enable this option together with +hash_token_secrets+.\n  #\n  # reuse_access_token\n\n  # In case you enabled `reuse_access_token` option Doorkeeper will try to find matching\n  # token using `matching_token_for` Access Token API that searches for valid records\n  # in batches in order not to pollute the memory with all the database records. By default\n  # Doorkeeper uses batch size of 10 000 records. You can increase or decrease this value\n  # depending on your needs and server capabilities.\n  #\n  # token_lookup_batch_size 10_000\n\n  # Set a limit for token_reuse if using reuse_access_token option\n  #\n  # This option limits token_reusability to some extent.\n  # If not set then access_token will be reused unless it expires.\n  # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/1189\n  #\n  # This option should be a percentage(i.e. (0,100])\n  #\n  # token_reuse_limit 100\n\n  # Only allow one valid access token obtained via client credentials\n  # per client. If a new access token is obtained before the old one\n  # expired, the old one gets revoked (disabled by default)\n  #\n  # When enabling this option, make sure that you do not expect multiple processes\n  # using the same credentials at the same time (e.g. web servers spanning\n  # multiple machines and/or processes).\n  #\n  # revoke_previous_client_credentials_token\n\n  # Only allow one valid access token obtained via authorization code\n  # per client. If a new access token is obtained before the old one\n  # expired, the old one gets revoked (disabled by default)\n  #\n  # revoke_previous_authorization_code_token\n\n  # Require non-confidential clients to use PKCE when using an authorization code\n  # to obtain an access_token (disabled by default)\n  #\n  force_pkce\n\n  # Hash access and refresh tokens before persisting them.\n  # This will disable the possibility to use +reuse_access_token+\n  # since plain values can no longer be retrieved.\n  #\n  # Note: If you are already a user of doorkeeper and have existing tokens\n  # in your installation, they will be invalid without adding 'fallback: :plain'.\n  #\n  # For test environment, allow fallback to plain tokens to make testing easier\n  if Rails.env.test?\n    hash_token_secrets fallback: :plain\n  else\n    hash_token_secrets\n  end\n  # By default, token secrets will be hashed using the\n  # +Doorkeeper::Hashing::SHA256+ strategy.\n  #\n  # If you wish to use another hashing implementation, you can override\n  # this strategy as follows:\n  #\n  # hash_token_secrets using: '::Doorkeeper::Hashing::MyCustomHashImpl'\n  #\n  # Keep in mind that changing the hashing function will invalidate all existing\n  # secrets, if there are any.\n\n  # Hash application secrets before persisting them.\n  #\n  hash_application_secrets\n  #\n  # By default, applications will be hashed\n  # with the +Doorkeeper::SecretStoring::SHA256+ strategy.\n  #\n  # If you wish to use bcrypt for application secret hashing, uncomment\n  # this line instead:\n  #\n  # hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt'\n\n  # When the above option is enabled, and a hashed token or secret is not found,\n  # you can allow to fall back to another strategy. For users upgrading\n  # doorkeeper and wishing to enable hashing, you will probably want to enable\n  # the fallback to plain tokens.\n  #\n  # This will ensure that old access tokens and secrets\n  # will remain valid even if the hashing above is enabled.\n  #\n  # This can be done by adding 'fallback: plain', e.g. :\n  #\n  # hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt', fallback: :plain\n\n  # Issue access tokens with refresh token (disabled by default), you may also\n  # pass a block which accepts `context` to customize when to give a refresh\n  # token or not. Similar to +custom_access_token_expires_in+, `context` has\n  # the following properties:\n  #\n  # `client` - the OAuth client application (see Doorkeeper::OAuth::Client)\n  # `grant_type` - the grant type of the request (see Doorkeeper::OAuth)\n  # `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes)\n  #\n  use_refresh_token\n\n  # Provide support for an owner to be assigned to each registered application (disabled by default)\n  # Optional parameter confirmation: true (default: false) if you want to enforce ownership of\n  # a registered application\n  # NOTE: you must also run the rails g doorkeeper:application_owner generator\n  # to provide the necessary support\n  #\n  enable_application_owner confirmation: false\n\n  # Define access token scopes for your provider\n  # For more information go to\n  # https://doorkeeper.gitbook.io/guides/ruby-on-rails/scopes\n  #\n  default_scopes  :read\n  optional_scopes :read_write\n\n  # Allows to restrict only certain scopes for grant_type.\n  # By default, all the scopes will be available for all the grant types.\n  #\n  # Keys to this hash should be the name of grant_type and\n  # values should be the array of scopes for that grant type.\n  # Note: scopes should be from configured_scopes (i.e. default or optional)\n  #\n  # scopes_by_grant_type password: [:write], client_credentials: [:update]\n\n  # Forbids creating/updating applications with arbitrary scopes that are\n  # not in configuration, i.e. +default_scopes+ or +optional_scopes+.\n  # (disabled by default)\n  #\n  # enforce_configured_scopes\n\n  # Change the way client credentials are retrieved from the request object.\n  # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then\n  # falls back to the `:client_id` and `:client_secret` params from the `params` object.\n  # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated\n  # for more information on customization\n  #\n  # client_credentials :from_basic, :from_params\n\n  # Change the way access token is authenticated from the request object.\n  # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then\n  # falls back to the `:access_token` or `:bearer_token` params from the `params` object.\n  # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated\n  # for more information on customization\n  #\n  # access_token_methods :from_bearer_authorization, :from_access_token_param, :from_bearer_param\n\n  # Forces the usage of the HTTPS protocol in non-native redirect uris (enabled\n  # by default in non-development environments). OAuth2 delegates security in\n  # communication to the HTTPS protocol so it is wise to keep this enabled.\n  #\n  # Callable objects such as proc, lambda, block or any object that responds to\n  # #call can be used in order to allow conditional checks (to allow non-SSL\n  # redirects to localhost for example).\n  #\n  # Allow custom URL schemes for mobile apps\n  force_ssl_in_redirect_uri false\n\n  # Specify what redirect URI's you want to block during Application creation.\n  # Any redirect URI is allowed by default.\n  #\n  # You can use this option in order to forbid URI's with 'javascript' scheme\n  # for example.\n  #\n  # Block javascript URIs but allow custom schemes\n  forbid_redirect_uri { |uri| uri.scheme.to_s.downcase == \"javascript\" }\n\n  # Allows to set blank redirect URIs for Applications in case Doorkeeper configured\n  # to use URI-less OAuth grant flows like Client Credentials or Resource Owner\n  # Password Credentials. The option is on by default and checks configured grant\n  # types, but you **need** to manually drop `NOT NULL` constraint from `redirect_uri`\n  # column for `oauth_applications` database table.\n  #\n  # You can completely disable this feature with:\n  #\n  # allow_blank_redirect_uri false\n  #\n  # Or you can define your custom check:\n  #\n  # allow_blank_redirect_uri do |grant_flows, client|\n  #   client.superapp?\n  # end\n\n  # Specify how authorization errors should be handled.\n  # By default, doorkeeper renders json errors when access token\n  # is invalid, expired, revoked or has invalid scopes.\n  #\n  # If you want to render error response yourself (i.e. rescue exceptions),\n  # set +handle_auth_errors+ to `:raise` and rescue Doorkeeper::Errors::InvalidToken\n  # or following specific errors:\n  #\n  #   Doorkeeper::Errors::TokenForbidden, Doorkeeper::Errors::TokenExpired,\n  #   Doorkeeper::Errors::TokenRevoked, Doorkeeper::Errors::TokenUnknown\n  #\n  # handle_auth_errors :raise\n  #\n  # If you want to redirect back to the client application in accordance with\n  # https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1, you can set\n  # +handle_auth_errors+ to :redirect\n  #\n  # handle_auth_errors :redirect\n\n  # Customize token introspection response.\n  # Allows to add your own fields to default one that are required by the OAuth spec\n  # for the introspection response. It could be `sub`, `aud` and so on.\n  # This configuration option can be a proc, lambda or any Ruby object responds\n  # to `.call` method and result of it's invocation must be a Hash.\n  #\n  # custom_introspection_response do |token, context|\n  #   {\n  #     \"sub\": \"Z5O3upPC88QrAjx00dis\",\n  #     \"aud\": \"https://protected.example.net/resource\",\n  #     \"username\": User.find(token.resource_owner_id).username\n  #   }\n  # end\n  #\n  # or\n  #\n  # custom_introspection_response CustomIntrospectionResponder\n\n  # Specify what grant flows are enabled in array of Strings. The valid\n  # strings and the flows they enable are:\n  #\n  # \"authorization_code\" => Authorization Code Grant Flow\n  # \"implicit\"           => Implicit Grant Flow\n  # \"password\"           => Resource Owner Password Credentials Grant Flow\n  # \"client_credentials\" => Client Credentials Grant Flow\n  #\n  # If not specified, Doorkeeper enables authorization_code and\n  # client_credentials.\n  #\n  # implicit and password grant flows have risks that you should understand\n  # before enabling:\n  #   https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.2\n  #   https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.3\n  #\n  # grant_flows %w[authorization_code client_credentials]\n\n  # Allows to customize OAuth grant flows that +each+ application support.\n  # You can configure a custom block (or use a class respond to `#call`) that must\n  # return `true` in case Application instance supports requested OAuth grant flow\n  # during the authorization request to the server. This configuration +doesn't+\n  # set flows per application, it only allows to check if application supports\n  # specific grant flow.\n  #\n  # For example you can add an additional database column to `oauth_applications` table,\n  # say `t.array :grant_flows, default: []`, and store allowed grant flows that can\n  # be used with this application there. Then when authorization requested Doorkeeper\n  # will call this block to check if specific Application (passed with client_id and/or\n  # client_secret) is allowed to perform the request for the specific grant type\n  # (authorization, password, client_credentials, etc).\n  #\n  # Example of the block:\n  #\n  #   ->(flow, client) { client.grant_flows.include?(flow) }\n  #\n  # In case this option invocation result is `false`, Doorkeeper server returns\n  # :unauthorized_client error and stops the request.\n  #\n  # @param allow_grant_flow_for_client [Proc] Block or any object respond to #call\n  # @return [Boolean] `true` if allow or `false` if forbid the request\n  #\n  # allow_grant_flow_for_client do |grant_flow, client|\n  #   # `grant_flows` is an Array column with grant\n  #   # flows that application supports\n  #\n  #   client.grant_flows.include?(grant_flow)\n  # end\n\n  # If you need arbitrary Resource Owner-Client authorization you can enable this option\n  # and implement the check your need. Config option must respond to #call and return\n  # true in case resource owner authorized for the specific application or false in other\n  # cases.\n  #\n  # By default all Resource Owners are authorized to any Client (application).\n  #\n  # authorize_resource_owner_for_client do |client, resource_owner|\n  #   resource_owner.admin? || client.owners_allowlist.include?(resource_owner)\n  # end\n\n  # Allows additional data fields to be sent while granting access to an application,\n  # and for this additional data to be included in subsequently generated access tokens.\n  # The 'authorizations/new' page will need to be overridden to include this additional data\n  # in the request params when granting access. The access grant and access token models\n  # will both need to respond to these additional data fields, and have a database column\n  # to store them in.\n  #\n  # Example:\n  # You have a multi-tenanted platform and want to be able to grant access to a specific\n  # tenant, rather than all the tenants a user has access to. You can use this config\n  # option to specify that a ':tenant_id' will be passed when authorizing. This tenant_id\n  # will be included in the access tokens. When a request is made with one of these access\n  # tokens, you can check that the requested data belongs to the specified tenant.\n  #\n  # Default value is an empty Array: []\n  # custom_access_token_attributes [:tenant_id]\n\n  # Hook into the strategies' request & response life-cycle in case your\n  # application needs advanced customization or logging:\n  #\n  # before_successful_strategy_response do |request|\n  #   puts \"BEFORE HOOK FIRED! #{request}\"\n  # end\n  #\n  # after_successful_strategy_response do |request, response|\n  #   puts \"AFTER HOOK FIRED! #{request}, #{response}\"\n  # end\n\n  # Hook into Authorization flow in order to implement Single Sign Out\n  # or add any other functionality. Inside the block you have an access\n  # to `controller` (authorizations controller instance) and `context`\n  # (Doorkeeper::OAuth::Hooks::Context instance) which provides pre auth\n  # or auth objects with issued token based on hook type (before or after).\n  #\n  # before_successful_authorization do |controller, context|\n  #   Rails.logger.info(controller.request.params.inspect)\n  #\n  #   Rails.logger.info(context.pre_auth.inspect)\n  # end\n  #\n  # after_successful_authorization do |controller, context|\n  #   controller.session[:logout_urls] <<\n  #     Doorkeeper::Application\n  #       .find_by(controller.request.params.slice(:redirect_uri))\n  #       .logout_uri\n  #\n  #   Rails.logger.info(context.auth.inspect)\n  #   Rails.logger.info(context.issued_token)\n  # end\n\n  # Under some circumstances you might want to have applications auto-approved,\n  # so that the user skips the authorization step.\n  # For example if dealing with a trusted application.\n  #\n  # skip_authorization do |resource_owner, client|\n  #   client.superapp? or resource_owner.admin?\n  # end\n\n  # Configure custom constraints for the Token Introspection request.\n  # By default this configuration option allows to introspect a token by another\n  # token of the same application, OR to introspect the token that belongs to\n  # authorized client (from authenticated client) OR when token doesn't\n  # belong to any client (public token). Otherwise requester has no access to the\n  # introspection and it will return response as stated in the RFC.\n  #\n  # Block arguments:\n  #\n  # @param token [Doorkeeper::AccessToken]\n  #   token to be introspected\n  #\n  # @param authorized_client [Doorkeeper::Application]\n  #   authorized client (if request is authorized using Basic auth with\n  #   Client Credentials for example)\n  #\n  # @param authorized_token [Doorkeeper::AccessToken]\n  #   Bearer token used to authorize the request\n  #\n  # In case the block returns `nil` or `false` introspection responses with 401 status code\n  # when using authorized token to introspect, or you'll get 200 with { \"active\": false } body\n  # when using authorized client to introspect as stated in the\n  # RFC 7662 section 2.2. Introspection Response.\n  #\n  # Using with caution:\n  # Keep in mind that these three parameters pass to block can be nil as following case:\n  #  `authorized_client` is nil if and only if `authorized_token` is present, and vice versa.\n  #  `token` will be nil if and only if `authorized_token` is present.\n  # So remember to use `&` or check if it is present before calling method on\n  # them to make sure you doesn't get NoMethodError exception.\n  #\n  # You can define your custom check:\n  #\n  # allow_token_introspection do |token, authorized_client, authorized_token|\n  #   if authorized_token\n  #     # customize: require `introspection` scope\n  #     authorized_token.application == token&.application ||\n  #       authorized_token.scopes.include?(\"introspection\")\n  #   elsif token.application\n  #     # `protected_resource` is a new database boolean column, for example\n  #     authorized_client == token.application || authorized_client.protected_resource?\n  #   else\n  #     # public token (when token.application is nil, token doesn't belong to any application)\n  #     true\n  #   end\n  # end\n  #\n  # Or you can completely disable any token introspection:\n  #\n  # allow_token_introspection false\n  #\n  # If you need to block the request at all, then configure your routes.rb or web-server\n  # like nginx to forbid the request.\n\n  # WWW-Authenticate Realm (default: \"Doorkeeper\").\n  #\n  # realm \"Doorkeeper\"\nend\n"
  },
  {
    "path": "config/initializers/doorkeeper_csrf_protection.rb",
    "content": "# Disable CSRF protection for Doorkeeper endpoints.\n#\n# OAuth requests (both the authorization endpoint hit by users and the token\n# endpoint hit by confidential/public clients) are performed by third-party\n# clients that do not have access to the Rails session, and therefore cannot\n# include the standard CSRF token. Requiring the token in these controllers\n# breaks the OAuth flow with an ActionController::InvalidAuthenticityToken\n# error. It is safe to disable CSRF verification here because Doorkeeper's\n# endpoints already implement their own security semantics defined by the\n# OAuth 2.0 specification (PKCE, client/secret checks, etc.).\n#\n# This hook runs on each application reload in development and ensures the\n# callback is applied after Doorkeeper loads its controllers.\nRails.application.config.to_prepare do\n  # Doorkeeper::ApplicationController is the base controller for all\n  # Doorkeeper-provided controllers (AuthorizationsController, TokensController,\n  # TokenInfoController, etc.). Removing the authenticity-token filter here\n  # cascades to all of them.\n  Doorkeeper::ApplicationController.skip_forgery_protection\nend\n"
  },
  {
    "path": "config/initializers/doorkeeper_layout.rb",
    "content": "# Ensure Doorkeeper controllers use the correct layout\nRails.application.config.to_prepare do\n  Doorkeeper::AuthorizationsController.layout \"doorkeeper/application\"\n  Doorkeeper::AuthorizedApplicationsController.layout \"doorkeeper/application\"\n  Doorkeeper::ApplicationsController.layout \"doorkeeper/application\"\nend\n"
  },
  {
    "path": "config/initializers/enable_yjit.rb",
    "content": "# Automatically enable YJIT as of Ruby 3.3, as it brings very\n# sizeable performance improvements.\n\n# If you are deploying to a memory constrained environment\n# you may want to delete this file, but otherwise it's free\n# performance.\nif defined? RubyVM::YJIT.enable\n  Rails.application.config.after_initialize do\n    RubyVM::YJIT.enable\n  end\nend\n"
  },
  {
    "path": "config/initializers/filter_parameter_logging.rb",
    "content": "# Be sure to restart your server when you modify this file.\n\n# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.\n# Use this to limit dissemination of sensitive information.\n# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.\nRails.application.config.filter_parameters += [\n  :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn\n]\n"
  },
  {
    "path": "config/initializers/generator.rb",
    "content": "Rails.application.config.generators do |g|\n  g.orm :active_record, primary_key_type: :uuid\nend\n"
  },
  {
    "path": "config/initializers/inflections.rb",
    "content": "# Be sure to restart your server when you modify this file.\n\n# Add new inflection rules using the following format. Inflections\n# are locale specific, and you may define rules for as many different\n# locales as you wish. All of these examples are active by default:\n# ActiveSupport::Inflector.inflections(:en) do |inflect|\n#   inflect.plural /^(ox)$/i, \"\\\\1en\"\n#   inflect.singular /^(ox)en/i, \"\\\\1\"\n#   inflect.irregular \"person\", \"people\"\n#   inflect.uncountable %w( fish sheep )\n# end\n\n# These inflection rules are supported but not enabled by default:\n# ActiveSupport::Inflector.inflections(:en) do |inflect|\n#   inflect.acronym \"RESTful\"\n# end\n"
  },
  {
    "path": "config/initializers/intercom.rb",
    "content": "if ENV[\"INTERCOM_APP_ID\"].present? && ENV[\"INTERCOM_IDENTITY_VERIFICATION_KEY\"].present?\n  IntercomRails.config do |config|\n    # == Intercom app_id\n    #\n    config.app_id = ENV[\"INTERCOM_APP_ID\"]\n\n    # == Intercom session_duration\n    #\n    # config.session_duration = 300000\n    # == Intercom secret key\n    # This is required to enable Identity Verification, you can find it on your Setup\n    # guide in the \"Identity Verification\" step.\n    #\n    config.api_secret = ENV[\"INTERCOM_IDENTITY_VERIFICATION_KEY\"]\n\n    # == Enabled Environments\n    # Which environments is auto inclusion of the Javascript enabled for\n    #\n    config.enabled_environments = [ \"production\" ]\n\n    # == Current user method/variable\n    # The method/variable that contains the logged in user in your controllers.\n    # If it is `current_user` or `@user`, then you can ignore this\n    #\n    config.user.current = Proc.new { Current.user }\n\n    # == Include for logged out Users\n    # If set to true, include the Intercom messenger on all pages, regardless of whether\n    # The user model class (set below) is present.\n    config.include_for_logged_out_users = true\n\n    # == User model class\n    # The class which defines your user model\n    #\n    # config.user.model = Proc.new { User }\n\n    # == Lead/custom attributes for non-signed up users\n    # Pass additional attributes to for potential leads or\n    # non-signed up users as an an array.\n    # Any attribute contained in config.user.lead_attributes can be used\n    # as custom attribute in the application.\n    # config.user.lead_attributes = %w(ref_data utm_source)\n\n    # == Exclude users\n    # A Proc that given a user returns true if the user should be excluded\n    # from imports and Javascript inclusion, false otherwise.\n    #\n    # config.user.exclude_if = Proc.new { |user| user.deleted? }\n\n    # == User Custom Data\n    # A hash of additional data you wish to send about your users.\n    # You can provide either a method name which will be sent to the current\n    # user object, or a Proc which will be passed the current user.\n    #\n    config.user.custom_data = {\n      family_id: Proc.new { Current.family.id },\n      name: Proc.new { Current.user.display_name if Current.user.display_name != Current.user.email },\n      \"Role\": Proc.new { Current.user.role },\n      \"Connections\": Proc.new { Current.family.accounts.count },\n      \"AI Enabled\": Proc.new { Current.user.ai_enabled }\n    }\n\n    # == Current company method/variable\n    # The method/variable that contains the current company for the current user,\n    # in your controllers. 'Companies' are generic groupings of users, so this\n    # could be a company, app or group.\n    #\n    config.company.current = Proc.new { Current.family }\n    #\n    # Or if you are using devise you can just use the following config\n    #\n    # config.company.current = Proc.new { current_user.company }\n\n    # == Exclude company\n    # A Proc that given a company returns true if the company should be excluded\n    # from imports and Javascript inclusion, false otherwise.\n    #\n    # config.company.exclude_if = Proc.new { |app| app.subdomain == 'demo' }\n\n    # == Company Custom Data\n    # A hash of additional data you wish to send about a company.\n    # This works the same as User custom data above.\n    #\n    # config.company.custom_data = {\n    #   :number_of_messages => Proc.new { |app| app.messages.count },\n    #   :is_interesting => :is_interesting?\n    # }\n    config.company.custom_data = {\n      accounts_count: Proc.new { |family| family.accounts.count }\n    }\n\n    # == Company Plan name\n    # This is the name of the plan a company is currently paying (or not paying) for.\n    # e.g. Messaging, Free, Pro, etc.\n    #\n    # config.company.plan = Proc.new { |current_company| current_company.plan.name }\n\n    # == Company Monthly Spend\n    # This is the amount the company spends each month on your app. If your company\n    # has a plan, it will set the 'total value' of that plan appropriately.\n    #\n    # config.company.monthly_spend = Proc.new { |current_company| current_company.plan.price }\n    # config.company.monthly_spend = Proc.new { |current_company| (current_company.plan.price - current_company.subscription.discount) }\n\n    # == Custom Style\n    # By default, Intercom will add a button that opens the messenger to\n    # the page. If you'd like to use your own link to open the messenger,\n    # uncomment this line and clicks on any element with id 'Intercom' will\n    # open the messenger.\n    #\n    # config.inbox.style = :custom\n    #\n    # If you'd like to use your own link activator CSS selector\n    # uncomment this line and clicks on any element that matches the query will\n    # open the messenger\n    # config.inbox.custom_activator = '.intercom'\n    #\n    # If you'd like to hide default launcher button uncomment this line\n    # config.hide_default_launcher = true\n    #\n    # If you need to route your Messenger requests through a different endpoint than the default, uncomment the below line. Generally speaking, this is not needed.\n    # config.api_base = \"https://api-iam.intercom.io\"\n    #\n  end\nend\n"
  },
  {
    "path": "config/initializers/mini_profiler.rb",
    "content": "Rails.application.configure do\n  Rack::MiniProfiler.config.skip_paths = [ \"/design-system\", \"/assets\", \"/cable\", \"/manifest\", \"/favicon.ico\", \"/hotwire-livereload\", \"/logo-pwa.png\" ]\n  Rack::MiniProfiler.config.max_traces_to_show = 50\nend\n"
  },
  {
    "path": "config/initializers/pagy.rb",
    "content": "require \"pagy/extras/overflow\"\nrequire \"pagy/extras/array\"\n\nPagy::DEFAULT[:overflow] = :last_page\n"
  },
  {
    "path": "config/initializers/permissions_policy.rb",
    "content": "# Be sure to restart your server when you modify this file.\n\n# Define an application-wide HTTP permissions policy. For further\n# information see: https://developers.google.com/web/updates/2018/06/feature-policy\n\n# Rails.application.config.permissions_policy do |policy|\n#   policy.camera      :none\n#   policy.gyroscope   :none\n#   policy.microphone  :none\n#   policy.usb         :none\n#   policy.fullscreen  :self\n#   policy.payment     :self, \"https://secure.example.com\"\n# end\n"
  },
  {
    "path": "config/initializers/plaid.rb",
    "content": "Rails.application.configure do\n  config.plaid = nil\n  config.plaid_eu = nil\n\n  if ENV[\"PLAID_CLIENT_ID\"].present? && ENV[\"PLAID_SECRET\"].present?\n    config.plaid = Plaid::Configuration.new\n    config.plaid.server_index = Plaid::Configuration::Environment[ENV[\"PLAID_ENV\"] || \"sandbox\"]\n    config.plaid.api_key[\"PLAID-CLIENT-ID\"] = ENV[\"PLAID_CLIENT_ID\"]\n    config.plaid.api_key[\"PLAID-SECRET\"] = ENV[\"PLAID_SECRET\"]\n  end\n\n  if ENV[\"PLAID_EU_CLIENT_ID\"].present? && ENV[\"PLAID_EU_SECRET\"].present?\n    config.plaid_eu = Plaid::Configuration.new\n    config.plaid_eu.server_index = Plaid::Configuration::Environment[ENV[\"PLAID_ENV\"] || \"sandbox\"]\n    config.plaid_eu.api_key[\"PLAID-CLIENT-ID\"] = ENV[\"PLAID_EU_CLIENT_ID\"]\n    config.plaid_eu.api_key[\"PLAID-SECRET\"] = ENV[\"PLAID_EU_SECRET\"]\n  end\nend\n"
  },
  {
    "path": "config/initializers/rack_attack.rb",
    "content": "# frozen_string_literal: true\n\nclass Rack::Attack\n  # Enable Rack::Attack\n  enabled = Rails.env.production? || Rails.env.staging?\n\n  # Throttle requests to the OAuth token endpoint\n  throttle(\"oauth/token\", limit: 10, period: 1.minute) do |request|\n    request.ip if request.path == \"/oauth/token\"\n  end\n\n  # Determine limits based on self-hosted mode\n  self_hosted = Rails.application.config.app_mode.self_hosted?\n\n  # Throttle API requests per access token\n  throttle(\"api/requests\", limit: self_hosted ? 10_000 : 100, period: 1.hour) do |request|\n    if request.path.start_with?(\"/api/\")\n      # Extract access token from Authorization header\n      auth_header = request.get_header(\"HTTP_AUTHORIZATION\")\n      if auth_header&.start_with?(\"Bearer \")\n        token = auth_header.split(\" \").last\n        \"api_token:#{Digest::SHA256.hexdigest(token)}\"\n      else\n        # Fall back to IP-based limiting for unauthenticated requests\n        \"api_ip:#{request.ip}\"\n      end\n    end\n  end\n\n  # More permissive throttling for API requests by IP (for development/testing)\n  throttle(\"api/ip\", limit: self_hosted ? 20_000 : 200, period: 1.hour) do |request|\n    request.ip if request.path.start_with?(\"/api/\")\n  end\n\n  # Block requests that appear to be malicious\n  blocklist(\"block malicious requests\") do |request|\n    # Block requests with suspicious user agents\n    suspicious_user_agents = [\n      /sqlmap/i,\n      /nmap/i,\n      /nikto/i,\n      /masscan/i\n    ]\n\n    user_agent = request.user_agent\n    suspicious_user_agents.any? { |pattern| user_agent =~ pattern } if user_agent\n  end\n\n  # Configure response for throttled requests\n  self.throttled_responder = lambda do |request|\n    [\n      429, # status\n      {\n        \"Content-Type\" => \"application/json\",\n        \"Retry-After\" => \"60\"\n      },\n      [ { error: \"Rate limit exceeded. Try again later.\" }.to_json ]\n    ]\n  end\n\n  # Configure response for blocked requests\n  self.blocklisted_responder = lambda do |request|\n    [\n      403, # status\n      { \"Content-Type\" => \"application/json\" },\n      [ { error: \"Request blocked.\" }.to_json ]\n    ]\n  end\nend\n"
  },
  {
    "path": "config/initializers/sentry.rb",
    "content": "if ENV[\"SENTRY_DSN\"].present?\n  Sentry.init do |config|\n    config.dsn = ENV[\"SENTRY_DSN\"]\n    config.environment = ENV[\"RAILS_ENV\"]\n    config.breadcrumbs_logger = [ :active_support_logger, :http_logger ]\n    config.enabled_environments = %w[production]\n\n    # Set traces_sample_rate to 1.0 to capture 100%\n    # of transactions for performance monitoring.\n    # We recommend adjusting this value in production.\n    config.traces_sample_rate = 0.25\n\n    # Set profiles_sample_rate to profile 100%\n    # of sampled transactions.\n    # We recommend adjusting this value in production.\n    config.profiles_sample_rate = 0.25\n\n    config.profiler_class = Sentry::Vernier::Profiler\n  end\nend\n"
  },
  {
    "path": "config/initializers/sidekiq.rb",
    "content": "require \"sidekiq/web\"\n\nif Rails.env.production?\n  Sidekiq::Web.use(Rack::Auth::Basic) do |username, password|\n    configured_username = ::Digest::SHA256.hexdigest(ENV.fetch(\"SIDEKIQ_WEB_USERNAME\", \"maybe\"))\n    configured_password = ::Digest::SHA256.hexdigest(ENV.fetch(\"SIDEKIQ_WEB_PASSWORD\", \"maybe\"))\n\n    ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(username), configured_username) &&\n      ActiveSupport::SecurityUtils.secure_compare(::Digest::SHA256.hexdigest(password), configured_password)\n  end\nend\n\nSidekiq::Cron.configure do |config|\n  # 10 min \"catch-up\" window in case worker process is re-deploying when cron tick occurs\n  config.reschedule_grace_period = 600\nend\n"
  },
  {
    "path": "config/initializers/version.rb",
    "content": "module Maybe\n  class << self\n    def version\n      Semver.new(semver)\n    end\n\n    def commit_sha\n      if Rails.env.production?\n        ENV[\"BUILD_COMMIT_SHA\"]\n      else\n        `git rev-parse HEAD`.chomp\n      end\n    end\n\n    private\n      def semver\n        \"0.6.0\"\n      end\n  end\nend\n"
  },
  {
    "path": "config/locales/defaults/af.yml",
    "content": "---\naf:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validering het misluk: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Kan rekord nie skrap nie omdat afhanklike %{record} bestaan\n          has_one: Kan rekord nie skrap nie omdat 'n afhanklike %{record} bestaan\n  date:\n    abbr_day_names:\n    - Son\n    - Maan\n    - Dins\n    - Woe\n    - Don\n    - Vry\n    - Sat\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - Mei\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Okt\n    - Nov\n    - Des\n    day_names:\n    - Sondag\n    - Maandag\n    - Dinsdag\n    - Woensdag\n    - Donderdag\n    - Vrydag\n    - Saterdag\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Januarie\n    - Februarie\n    - Maart\n    - April\n    - Mei\n    - Junie\n    - Julie\n    - Augustus\n    - September\n    - Oktober\n    - November\n    - Desember\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ongeveer %{count} uur\n        other: ongeveer %{count} ure\n      about_x_months:\n        one: ongeveer %{count} maand\n        other: ongeveer %{count} maande\n      about_x_years:\n        one: ongeveer %{count} jaar\n        other: ongeveer %{count} jaar\n      almost_x_years:\n        one: sowat %{count} jaar\n        other: sowat %{count} jaar\n      half_a_minute: halfminuut\n      less_than_x_minutes:\n        one: minder as %{count} minuut\n        other: minder as %{count} minute\n      less_than_x_seconds:\n        one: minder as %{count} sekonde\n        other: minder as %{count} sekondes\n      over_x_years:\n        one: meer as %{count} jaar\n        other: meer as %{count} jaar\n      x_days:\n        one: \"%{count} dag\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minuut\"\n        other: \"%{count} minute\"\n      x_months:\n        one: \"%{count} maand\"\n        other: \"%{count} maande\"\n      x_seconds:\n        one: \"%{count} sekonde\"\n        other: \"%{count} sekondes\"\n      x_years:\n        one: \"%{count} jaar\"\n        other: \"%{count} jare\"\n    prompts:\n      day: Dag\n      hour: Uur\n      minute: Minuut\n      month: Maand\n      second: Sekondes\n      year: Jaar\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: moet aanvaar word\n      blank: mag nie leeg wees nie\n      confirmation: pas nie by bevestiging nie\n      empty: mag nie leeg wees nie\n      equal_to: moet gelyk wees aan %{count}\n      even: moet ewe wees\n      exclusion: is bespreek\n      greater_than: moet meer wees as %{count}\n      greater_than_or_equal_to: moet meer of gelykstaande wees aan %{count}\n      inclusion: is nie by die lys ingesluit nie\n      invalid: is ongeldig\n      less_than: moet minder wees as %{count}\n      less_than_or_equal_to: moet minder of gelykstaande wees aan %{count}\n      model_invalid: 'Validering het misluk: %{errors}'\n      not_a_number: is nie 'n getal nie\n      not_an_integer: moet 'n heelgetal wees\n      odd: moet onewe wees\n      other_than: moet anders wees as %{count}\n      present: moet leeg wees\n      required: moet bestaan\n      taken: is reeds geneem\n      too_long:\n        one: is te lank (maksimum is %{count} karakter)\n        other: is te lank (maksimum is %{count} karakters)\n      too_short:\n        one: is te kort (minimum is %{count} karakter)\n        other: is te kort (minimum is %{count} karakters)\n      wrong_length:\n        one: is die verkeerde lengte (moet %{count} karakter wees)\n        other: is die verkeerde lengte (moet %{count} karakters wees)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} fout het verhoed dat hierdie %{model} gestoor kon word\"\n        other: \"%{count} foute het verhoed dat hierdie %{model} gestoor kon word\"\n  helpers:\n    select:\n      prompt: Kies asseblief\n    submit:\n      create: Skep %{model}\n      submit: Stoor %{model}\n      update: Dateer %{model} op\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%u %n\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: R\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: miljard\n          million: miljoen\n          quadrillion: biljard\n          thousand: duisend\n          trillion: biljoen\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Greep\n            other: Grepe\n          gb: GG\n          kb: kG\n          mb: MG\n          tb: TG\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", en \"\n      two_words_connector: \" en \"\n      words_connector: \", \"\n  time:\n    am: vm\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: nm\n"
  },
  {
    "path": "config/locales/defaults/ar.yml",
    "content": "---\nar:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'فشل التحقّق من: %{errors}'\n        restrict_dependent_destroy:\n          has_many: لا يمكن حذف السجل لوجود سجلات تعتمد عليه %{record}\n          has_one: لا يمكن حذف السجل لوجود سجل يعتمد عليه %{record}\n  date:\n    abbr_day_names:\n    - الأحد\n    - الإثنين\n    - الثّلاثاء\n    - الأربعاء\n    - الخميس\n    - الجمعة\n    - السّبت\n    abbr_month_names:\n    -\n    - يناير\n    - فبراير\n    - مارس\n    - أبريل\n    - مايو\n    - يونيو\n    - يوليو\n    - أغسطس\n    - سبتمبر\n    - أكتوبر\n    - نوفمبر\n    - ديسمبر\n    day_names:\n    - الأحد\n    - الإثنين\n    - الثّلاثاء\n    - الأربعاء\n    - الخميس\n    - الجمعة\n    - السّبت\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%e %B، %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - يناير\n    - فبراير\n    - مارس\n    - أبريل\n    - مايو\n    - يونيو\n    - يوليو\n    - أغسطس\n    - سبتمبر\n    - أكتوبر\n    - نوفمبر\n    - ديسمبر\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: حوالي %{count} ساعات\n        many: حوالي %{count} ساعة\n        one: حوالي ساعة واحدة\n        other: حوالي %{count} ساعة\n        two: حوالي ساعتان\n        zero: حوالي صفر ساعات\n      about_x_months:\n        few: حوالي %{count} أشهر\n        many: حوالي %{count} شهر\n        one: حوالي شهر واحد\n        other: حوالي %{count} شهر\n        two: حوالي شهران\n        zero: حوالي صفر أشهر\n      about_x_years:\n        few: حوالي %{count} سنوات\n        many: حوالي %{count} سنة\n        one: حوالي سنة\n        other: حوالي %{count} سنة\n        two: حوالي سنتان\n        zero: حوالي صفر سنوات\n      almost_x_years:\n        few: ما يقرب من %{count} سنوات\n        many: ما يقرب من %{count} سنة\n        one: تقريبا سنة واحدة\n        other: ما يقرب من %{count} سنة\n        two: ما يقرب من سنتين\n        zero: ما يقرب من صفر سنوات\n      half_a_minute: نصف دقيقة\n      less_than_x_minutes:\n        few: أقل من %{count} دقائق\n        many: أقل من %{count} دقيقة\n        one: أقل من دقيقة\n        other: أقل من %{count} دقيقة\n        two: أقل من دقيقتان\n        zero: أقل من صفر دقائق\n      less_than_x_seconds:\n        few: أقل من %{count} ثوان\n        many: أقل من %{count} ثانية\n        one: أقل من ثانية\n        other: أقل من %{count} ثانية\n        two: أقل من ثانيتان\n        zero: أقل من صفر ثواني\n      over_x_years:\n        few: أكثر من %{count} سنوات\n        many: أكثر من %{count} سنة\n        one: أكثر من سنة\n        other: أكثر من %{count} سنة\n        two: أكثر من سنتين\n        zero: أكثر من صفر سنوات\n      x_days:\n        few: \"%{count} أيام\"\n        many: \"%{count} يوم\"\n        one: يوم واحد\n        other: \"%{count} يوم\"\n        two: يومان\n        zero: صفر أيام\n      x_minutes:\n        few: \"%{count} دقائق\"\n        many: \"%{count} دقيقة\"\n        one: دقيقة واحدة\n        other: \"%{count} دقيقة\"\n        two: دقيقتان\n        zero: صفر دقائق\n      x_months:\n        few: \"%{count} أشهر\"\n        many: \"%{count} شهر\"\n        one: شهر واحد\n        other: \"%{count} شهر\"\n        two: شهران\n        zero: صفر أشهر\n      x_seconds:\n        few: \"%{count} ثوان\"\n        many: \"%{count} ثانية\"\n        one: ثانية واحدة\n        other: \"%{count} ثانية\"\n        two: ثانيتان\n        zero: صفر ثواني\n    prompts:\n      day: اليوم\n      hour: ساعة\n      minute: دقيقة\n      month: الشهر\n      second: ثانية\n      year: السنة\n  errors:\n    format: \"%{message}\"\n    messages:\n      accepted: يجب أن تقبل %{attribute}\n      blank: لا يمكن أن يكون محتوى %{attribute} فارغاً\n      confirmation: محتوى %{attribute} لا يتوافق مع %{attribute}\n      empty: لا يمكن أن يكون محتوى %{attribute} فارغاً\n      equal_to: يجب أن يساوي طول %{attribute} %{count}\n      even: يجب أن يكون عدد %{attribute} زوجياً\n      exclusion: حقل %{attribute} محجوز\n      greater_than: يجب أن يكون عدد %{attribute} أكبر من %{count}\n      greater_than_or_equal_to: يجب أن يكون عدد %{attribute} أكبر أو يساوي %{count}\n      inclusion: \"%{attribute} غير وارد في القائمة\"\n      invalid: محتوى %{attribute} غير صالح\n      less_than: يجب أن يكون عدد %{attribute} أصغر من %{count}\n      less_than_or_equal_to: يجب أن يكون عدد %{attribute} أصغر أو  يساوي %{count}\n      not_a_number: يجب أن يكون محتوى %{attribute} رقماً\n      not_an_integer: يجب أن يكون عدد %{attribute} عدد صحيحاً\n      odd: يجب أن يكون عدد %{attribute} عدداً فردياً\n      other_than:\n        few: طول %{attribute} يجب ألاّ يكون %{count} أحرف\n        many: طول %{attribute} يجب ألاّ يكون %{count} حرفاً\n        one: طول %{attribute} يجب ألاّ يكون حرفاً واحداً\n        other: طول %{attribute} يجب ألاّ يكون %{count} حرفاً\n        two: طول %{attribute} يجب ألاّ يكون حرفان\n        zero: طول %{attribute} يجب ألاّ يكون صفر حرف\n      present: يجب ترك حقل %{attribute} فارغاً\n      taken: حقل %{attribute} محجوز مسبقاً\n      too_long:\n        few: محتوى %{attribute} أطول من اللّازم (الحد الأقصى هو %{count} حروف)\n        many: محتوى %{attribute} أطول من اللّازم (الحد الأقصى هو %{count} حرف)\n        one: محتوى %{attribute} أطول من اللّازم (الحد الأقصى هو حرف واحد)\n        other: محتوى %{attribute} أطول من اللّازم (الحد الأقصى هو %{count} حرف)\n        two: محتوى %{attribute} أطول من اللّازم (الحد الأقصى هو حرفان)\n        zero: محتوى %{attribute} أطول من اللّازم (الحد الأقصى هو ولا حرف)\n      too_short:\n        few: محتوى %{attribute} أقصر من اللّازم (الحد الأدنى هو %{count} حروف)\n        many: محتوى %{attribute} أقصر من اللّازم (الحد الأدنى هو %{count} حرف)\n        one: محتوى %{attribute} أقصر من اللّازم (الحد الأدنى هو حرف واحد)\n        other: محتوى %{attribute} أقصر من اللّازم (الحد الأدنى هو %{count} حرف)\n        two: محتوى %{attribute} أقصر من اللّازم (الحد الأدنى هو حرفان)\n        zero: محتوى %{attribute} أقصر من اللّازم (الحد الأدنى هو ولا حرف)\n      wrong_length:\n        few: طول %{attribute} غير مطابق للحد المطلوب (يجب أن يكون %{count} أحرف)\n        many: طول %{attribute} غير مطابق للحد المطلوب (يجب أن يكون %{count} حرف)\n        one: طول %{attribute} غير مطابق للحد المطلوب (يجب أن يكون حرف واحد)\n        other: طول %{attribute} غير مطابق للحد المطلوب (يجب أن يكون %{count} حرف)\n        two: طول %{attribute} غير مطابق للحد المطلوب (يجب أن يكون حرفان)\n        zero: طول %{attribute} غير مطابق للحد المطلوب (يجب أن يكون ولا حرف)\n    template:\n      body: يُرجى التحقّق من الحقول التّالية:%{attribute}\n      header:\n        few: ليس بالامكان حفظ %{model} لسبب وجود %{count} أخطاء.\n        many: ليس بالامكان حفظ %{model} لسبب وجود %{count} خطأ.\n        one: ليس بالامكان حفظ %{model} لسبب وجود خطأ واحد.\n        other: ليس بالامكان حفظ %{model} لسبب وجود %{count} خطأ.\n        two: ليس بالامكان حفظ %{model} لسبب وجود خطئان.\n        zero: ليس بالامكان حفظ %{model} لسبب ولا خطأ.\n  helpers:\n    select:\n      prompt: يُرجى الاختيار\n    submit:\n      create: \"%{model} إنشاء\"\n      submit: \"%{model} حِفظ\"\n      update: \"%{model} تحديث\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u %n\"\n        precision: 3\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: KWD\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: مليار\n          million: مليون\n          quadrillion: كدريليون\n          thousand: ألفّ\n          trillion: تريليون\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: Bytes\n            many: Bytes\n            one: Byte\n            other: Bytes\n            two: Bytes\n            zero: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" و \"\n      two_words_connector: \" و \"\n      words_connector: \" ، \"\n  time:\n    am: صباحًا\n    formats:\n      default: \"%a %d %b %Y %H:%M:%S %Z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: مساءً\n"
  },
  {
    "path": "config/locales/defaults/az.yml",
    "content": "---\naz:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Yoxlama uğursuz oldu: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Əlaqəli %{record} yazılar olduğundan bu yazını silmək mümkün deyil\n          has_one: Əlaqəli %{record} yazı olduğundan bu yazını silmək mümkün deyil\n  date:\n    abbr_day_names:\n    - B.\n    - B.E.\n    - Ç.A.\n    - Ç.\n    - C.A.\n    - C.\n    - Ş.\n    abbr_month_names:\n    -\n    - Yan\n    - Fev\n    - Mar\n    - Apr\n    - May\n    - İyn\n    - İyl\n    - Avq\n    - Sen\n    - Okt\n    - Noy\n    - Dek\n    day_names:\n    - Bazar\n    - Bazar ertəsi\n    - Çərşənbə axşamı\n    - Çərşənbə\n    - Cümə axşamı\n    - Cümə\n    - Şənbə\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Yanvar\n    - Fevral\n    - Mart\n    - Aprel\n    - May\n    - İyun\n    - İyul\n    - Avqust\n    - Sentyabr\n    - Oktyabr\n    - Noyabr\n    - Dekabr\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: təxminən %{count} saat\n        other: təxminən %{count} saat\n      about_x_months:\n        one: təxminən %{count} ay\n        other: təxminən %{count} ay\n      about_x_years:\n        one: təxminən %{count} il\n        other: təxminən %{count} il\n      almost_x_years:\n        one: təqribən %{count} il\n        other: təqribən %{count} il\n      half_a_minute: yarım dəqiqə\n      less_than_x_minutes:\n        one: \"%{count} dəqiqədən az\"\n        other: \"%{count} dəqiqədən az\"\n      less_than_x_seconds:\n        one: \"%{count} saniyədən az\"\n        other: \"%{count} saniyədən az\"\n      over_x_years:\n        one: \"%{count} ildən çox\"\n        other: \"%{count} ildən çox\"\n      x_days:\n        one: \"%{count} gün\"\n        other: \"%{count} gün\"\n      x_minutes:\n        one: \"%{count} dəqiqə\"\n        other: \"%{count} dəqiqə\"\n      x_months:\n        one: \"%{count} ay\"\n        other: \"%{count} ay\"\n      x_seconds:\n        one: \"%{count} saniyə\"\n        other: \"%{count} saniyə\"\n    prompts:\n      day: Gün\n      hour: Saat\n      minute: Dəqiqə\n      month: Ay\n      second: Saniyə\n      year: İl\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: qəbul olunmalıdır\n      blank: boş ola bilməz\n      confirmation: təsdiqə uygun deyil\n      empty: boş ola bilməz\n      equal_to: \"%{count}-ə bərabər olmalıdır\"\n      even: cüt olmalıdır\n      exclusion: qorunur\n      greater_than: \"%{count}-dən böyük olmalıdır\"\n      greater_than_or_equal_to: böyük və ya %{count}-ə bərabər olmalıdır\n      inclusion: siyahiyə daxil deyil\n      invalid: yanlışdır\n      less_than: \"%{count}-dən kiçik olmalıdır\"\n      less_than_or_equal_to: kiçik və ya %{count}-ə bərabər olmalıdır\n      model_invalid: 'Yoxlama uğursuz oldu: %{errors}'\n      not_a_number: rəqəm deyil\n      not_an_integer: tam rəqəm olmalıdır\n      odd: tək olmalıdır\n      other_than: \"%{count}-dən fərqli olmalıdır\"\n      present: boş olmalıdır\n      required: mövcud olmalıdır\n      taken: artıq mövcuddur\n      too_long: çox uzundur (%{count} simvoldan çox olmalı deyil)\n      too_short: çox qısadır (%{count} simvoldan az olmalı deyil)\n      wrong_length: uzunluqu səhvdir (%{count} simvol olmalıdır)\n    template:\n      body: 'Aşağıdaki səhvlər üzə çıxdı:'\n      header:\n        one: \"%{model} saxlanmadı: %{count} səhv\"\n        other: \"%{model} saxlanmadı: %{count} səhv\"\n  helpers:\n    select:\n      prompt: Seçin\n    submit:\n      create: \"%{model} yarat\"\n      submit: \"%{model} saxla\"\n      update: \"%{model} yenilə\"\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: AZN\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Milyard\n          million: Milyon\n          quadrillion: Kvadrilyon\n          thousand: Min\n          trillion: Trilyon\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Bayt\n            other: Bayt\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" və \"\n      two_words_connector: \" və \"\n      words_connector: \", \"\n  time:\n    am: günortaya qədər\n    formats:\n      default: \"%a, %d %b %Y, %H:%M:%S %z\"\n      long: \"%d %B %Y, %H:%M\"\n      short: \"%d %b, %H:%M\"\n    pm: günortadan sonra\n"
  },
  {
    "path": "config/locales/defaults/be.yml",
    "content": "---\nbe:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Памылкі валідацыі: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Нельга выдаліць запіс, таму што існуюць залежнасці %{record}\n          has_one: Нельга выдаліць запіс, таму што існуе залежнасць %{record}\n  date:\n    abbr_day_names:\n    - Пан\n    - Аўт\n    - Сер\n    - Чцв\n    - Пят\n    - Суб\n    - Ндз\n    abbr_month_names:\n    -\n    - Сту\n    - Лют\n    - Сак\n    - Кра\n    - Тра\n    - Чэр\n    - Ліп\n    - Жні\n    - Вер\n    - Кас\n    - Ліс\n    - Сне\n    day_names:\n    - Нядзеля\n    - Панядзелак\n    - Аўторак\n    - Серада\n    - Чацвер\n    - Пятніца\n    - Субота\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%-d %B %Y\"\n      short: \"%-d %b\"\n    month_names:\n    -\n    - Студзень\n    - Люты\n    - Сакавік\n    - Красавік\n    - Травень\n    - Чэрвень\n    - Ліпень\n    - Жнівень\n    - Верасень\n    - Кастрычнік\n    - Лістапад\n    - Снежань\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: каля %{count} гадзін\n        many: каля %{count} гадзін\n        one: каля %{count} гадзіны\n        other: каля %{count} гадзін\n      about_x_months:\n        few: каля %{count} месяцаў\n        many: каля %{count} месяцаў\n        one: каля %{count} месяца\n        other: каля %{count} месяцаў\n      about_x_years:\n        few: каля %{count} гадоў\n        many: каля %{count} гадоў\n        one: каля %{count} года\n        other: каля %{count} гадоў\n      almost_x_years:\n        few: амаль %{count} гады\n        many: амаль %{count} гадоў\n        one: амаль %{count} год\n        other: амаль %{count} гадоў\n      half_a_minute: палова хвіліны\n      less_than_x_minutes:\n        few: меней за %{count} хвіліны\n        many: меней за %{count} хвілін\n        one: меней за %{count} хвіліну\n        other: меней за %{count} хвіліны\n      less_than_x_seconds:\n        few: меней за %{count} секунды\n        many: меней за %{count} секунд\n        one: меней за %{count} секунду\n        other: меней за %{count} секунды\n      over_x_years:\n        few: болей за %{count} гады\n        many: болей за %{count} гадоў\n        one: болей за %{count} год\n        other: болей за %{count} года\n      x_days:\n        few: \"%{count} дні\"\n        many: \"%{count} дзён\"\n        one: \"%{count} дзень\"\n        other: \"%{count} дня\"\n      x_minutes:\n        few: \"%{count} хвіліны\"\n        many: \"%{count} хвілін\"\n        one: \"%{count} хвіліна\"\n        other: \"%{count} хвіліны\"\n      x_months:\n        few: \"%{count} месяцы\"\n        many: \"%{count} месяцаў\"\n        one: \"%{count} месяц\"\n        other: \"%{count} месяца\"\n      x_seconds:\n        few: \"%{count} секунды\"\n        many: \"%{count} секунд\"\n        one: \"%{count} секунда\"\n        other: \"%{count} секунды\"\n      x_years:\n        few: \"%{count} гады\"\n        many: \"%{count} гадоў\"\n        one: \"%{count} год\"\n        other: \"%{count} года\"\n    prompts:\n      day: Дзень\n      hour: Гадзіна\n      minute: Хвіліна\n      month: Месяц\n      second: Секунда\n      year: Год\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: трэба прыняць\n      blank: не можа быць пустым\n      confirmation: не супадае з %{attribute}\n      empty: не можа быць пустым\n      equal_to: павінна быць роўным %{count}\n      even: павінна быць цотным\n      exclusion: мае зарэзерваванае значэнне\n      greater_than: павінна быць болей за %{count}\n      greater_than_or_equal_to: павінна быць болей ці роўным %{count}\n      inclusion: значэнне не уключана ў спіс\n      invalid: няправільнае значэнне\n      less_than: павінна быць меней за %{count}\n      less_than_or_equal_to: павінна быць меней ці роўным %{count}\n      model_invalid: 'Узніклі настпупныя памылкі: %{errors}'\n      not_a_number: гэта не лічба\n      not_an_integer: павінна быць цэлай лічбай\n      odd: павінна быць няцотным\n      other_than: павінна адрознівацца ад %{count}\n      present: павінна быць пустым\n      required: не можа адсутнічаць\n      taken: ўжо занята\n      too_long:\n        few: занадта доўгі (максімум %{count} сімвала)\n        many: занадта доўгі (максімум %{count} сімвалаў)\n        one: занадта доўгі (максімум %{count} сімвал)\n        other: занадта доўгі (максімум %{count} сімвалаў)\n      too_short:\n        few: занадта кароткі (мінімум %{count} сімвала)\n        many: занадта кароткі (мінімум %{count} сімвалаў)\n        one: занадта кароткі (мінімум %{count} сімвал)\n        other: занадта кароткі (мінімум %{count} сімвалаў)\n      wrong_length:\n        few: няправільная даўжыня (павінен быць %{count} сімвала)\n        many: няправільная даўжыня (павінны быць %{count} сімвалаў)\n        one: няправільная даўжыня (павінен быць %{count} сімвал)\n        other: няправільная даўжыня (павінны быць %{count} сімвалаў)\n    template:\n      body: 'Узніклі праблемы з наступнымі палямі:'\n      header:\n        few: не атрымалася захаваць %{model} з-за %{count} памылак\n        many: не атрымалася захаваць %{model} з-за %{count} памылак\n        one: не атрымалася захаваць %{model} з-за %{count} памылкі\n        other: не атрымалася захаваць %{model} з-за %{count} памылак\n  helpers:\n    select:\n      prompt: Калі ласка, абярыце\n    submit:\n      create: Стварыць %{model}\n      submit: Захаваць %{model}\n      update: Абнавіць %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: руб.\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            few: мільярда\n            many: мільярдаў\n            one: мільярд\n            other: мільярда\n          million:\n            few: мільёна\n            many: мільёнаў\n            one: мільён\n            other: мільёна\n          quadrillion:\n            few: квадрыльёна\n            many: квадрыльёнаў\n            one: квадрыльён\n            other: квадрыльёна\n          thousand:\n            few: тысячы\n            many: тысяч\n            one: тысяча\n            other: тысячы\n          trillion:\n            few: трыльёна\n            many: трыльёнаў\n            one: трыльён\n            other: трыльёна\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: байта\n            many: байтаў\n            one: байт\n            other: байта\n          eb: ЭБ\n          gb: ГБ\n          kb: КБ\n          mb: МБ\n          pb: ПБ\n          tb: ТБ\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" і \"\n      two_words_connector: \" і \"\n      words_connector: \", \"\n  time:\n    am: раніцы\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y, %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: вечара\n"
  },
  {
    "path": "config/locales/defaults/bg.yml",
    "content": "---\nbg:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'имаше грешки: %{errors}'\n  date:\n    abbr_day_names:\n    - нед\n    - пон\n    - вт\n    - ср\n    - чет\n    - пет\n    - съб\n    abbr_month_names:\n    -\n    - ян.\n    - фев.\n    - март\n    - апр.\n    - май\n    - юни\n    - юли\n    - авг.\n    - сеп.\n    - окт.\n    - ноем.\n    - дек.\n    day_names:\n    - неделя\n    - понеделник\n    - вторник\n    - сряда\n    - четвъртък\n    - петък\n    - събота\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - януари\n    - февруари\n    - март\n    - април\n    - май\n    - юни\n    - юли\n    - август\n    - септември\n    - октомври\n    - ноември\n    - декември\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: около %{count} час\n        other: около %{count} часа\n      about_x_months:\n        one: около %{count} месец\n        other: около %{count} месеца\n      about_x_years:\n        one: около %{count} година\n        other: около %{count} години\n      almost_x_years:\n        one: почти %{count} година\n        other: почти %{count} години\n      half_a_minute: половин минута\n      less_than_x_minutes:\n        one: по-малко от %{count} минута\n        other: по-малко от %{count} минути\n      less_than_x_seconds:\n        one: по-малко от %{count} секунда\n        other: по-малко от %{count} секунди\n      over_x_years:\n        one: над %{count} година\n        other: над %{count} години\n      x_days:\n        one: \"%{count} ден\"\n        other: \"%{count} дни\"\n      x_minutes:\n        one: \"%{count} минута\"\n        other: \"%{count} минути\"\n      x_months:\n        one: \"%{count} месец\"\n        other: \"%{count} месеца\"\n      x_seconds:\n        one: \"%{count} секунда\"\n        other: \"%{count} секунди\"\n    prompts:\n      day: Ден\n      hour: Час\n      minute: Минута\n      month: Месец\n      second: Секунда\n      year: Година\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: трябва да се потвърди\n      blank: не може да е без стойност\n      confirmation: не съответства на потвърждението\n      empty: не може да е празно\n      equal_to: трябва да има стойност, равна на %{count}\n      even: трябва да е нечетно\n      exclusion: съдържа предварително зададена стойност\n      greater_than: трябва да има стойност, по-голяма от %{count}\n      greater_than_or_equal_to: трябва да има стойност, по-голяма или равна на %{count}\n      inclusion: съдържа непредвидена стойност\n      invalid: съдържа невярна стойност\n      less_than: трябва да има стойност, по-малка от %{count}\n      less_than_or_equal_to: трябва да има стойност, по-малка или равна на %{count}\n      not_a_number: не е число\n      not_an_integer: не е цяло число\n      odd: трябва да е четно\n      taken: вече съществува\n      too_long: е прекаленo дълго (не може да е повече от %{count} символа)\n      too_short: е прекалено късо (не може да бъде по-малко от %{count} символа)\n      wrong_length: е с грешна дължина (трябва да е с дължина, равна на %{count} символа)\n    template:\n      body: 'Възникнаха проблеми със следните полета:'\n      header:\n        one: \"%{model}: записът е неуспешен заради %{count} грешка\"\n        other: \"%{model}: записът е неуспешен заради %{count} грешки\"\n  helpers:\n    select:\n      prompt: Моля отбележете\n    submit:\n      create: Създай %{model}\n      submit: Запази %{model}\n      update: Обнови %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: лв.\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: милиарда\n          million: милиона\n          quadrillion: квадрилиона\n          thousand: хиляди\n          trillion: трилиона\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Байт\n            other: Байта\n          gb: ГБ\n          kb: КБ\n          mb: МБ\n          tb: ТБ\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" и \"\n      two_words_connector: \" и \"\n      words_connector: \", \"\n  time:\n    am: преди обяд\n    formats:\n      default: \"%a, %d %b %Y, %H:%M:%S %z\"\n      long: \"%d %B %Y, %H:%M\"\n      short: \"%d %b, %H:%M\"\n    pm: следобед\n"
  },
  {
    "path": "config/locales/defaults/bn.yml",
    "content": "---\nbn:\n  date:\n    abbr_day_names:\n    - রবিবার\n    - সোমবার\n    - মঙ্গলবার\n    - বুধবার\n    - বৃহস্পতিবার\n    - শুক্রবার\n    - শনিবার\n    abbr_month_names:\n    -\n    - জানুয়ারি\n    - ফেব্রুয়ারি\n    - মার্চ\n    - এপ্রিল\n    - মে\n    - জুন\n    - জুলাই\n    - আগস্ট\n    - সেপ্টেম্বর\n    - অক্টোবর\n    - নভেম্বর\n    - ডিসেম্বর\n    day_names:\n    - রবিবার\n    - সোমবার\n    - মঙ্গলবার\n    - বুধবার\n    - বৃহস্পতিবার\n    - শুক্রবার\n    - শনিবার\n    formats:\n      default: \"%e/%m/%Y\"\n      long: \"%e %B %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - জানুয়ারি\n    - ফেব্রুয়ারি\n    - মার্চ\n    - এপ্রিল\n    - মে\n    - জুন\n    - জুলাই\n    - আগস্ট\n    - সেপ্টেম্বর\n    - অক্টোবর\n    - নভেম্বর\n    - ডিসেম্বর\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: প্রায় ১ ঘণ্টা\n        other: প্রায় %{count} ঘণ্টা\n      about_x_months:\n        one: প্রায় ১ মাস\n        other: প্রায় %{count} মাস\n      about_x_years:\n        one: প্রায় ১ বছর\n        other: প্রায় %{count} বছর\n      almost_x_years:\n        one: প্রায় ১ বছর\n        other: প্রায় %{count} বছর\n      half_a_minute: অর্ধ মিনিট\n      less_than_x_minutes:\n        one: ১ মিনিটের কম\n        other: \"%{count} মিনিটের কম\"\n      less_than_x_seconds:\n        one: ১ সেকেন্ডর কম\n        other: \"%{count} সেকেন্ডের কম\"\n      over_x_years:\n        one: ১ বছরের বেশি\n        other: \"%{count} বছরের বেশি\"\n      x_days:\n        one: ১ দিন\n        other: \"%{count} দিন\"\n      x_minutes:\n        one: ১ মিনিট\n        other: \"%{count} মিনিট\"\n      x_months:\n        one: ১ মাস\n        other: \"%{count} মাস\"\n      x_seconds:\n        one: ১ সেকেন্ড\n        other: \"%{count} সেকেন্ড\"\n    prompts:\n      day: দিন\n      hour: ঘণ্টা\n      minute: মিনিট\n      month: মাস\n      second: সেকেন্ড\n      year: বছর\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: গ্রাহ্য করতে হবে\n      blank: ফাঁকা রাখা যাবে না\n      confirmation: অনুমোদনের সঙ্গে মিলছে না\n      empty: খালি রাখা যাবে না\n      equal_to: \"%{count} এর সঙ্গে সমান হতে হবে\"\n      even: জোড় হতে হবে\n      exclusion: রিসার্ভ করা আছে\n      greater_than: \"%{count} থেকে বড় হতে হবে\"\n      greater_than_or_equal_to: \"%{count} থেকে বড় অথবা তার সমান হতে হবে\"\n      inclusion: তালিকায় অন্তর্ভুক্ত নয়\n      invalid: সঠিক নয়\n      less_than: \"%{count} থেকে ছোটো হতে হবে\"\n      less_than_or_equal_to: \"%{count} থেকে ছোটো অথবা তার সমান হতে হবে\"\n      not_a_number: নম্বর নয়\n      odd: বেজোড় হতে হবে\n      taken: আগেই নিয়ে নেওয়া হয়েছে\n      too_long: খুব বড়ো (সর্বোচ্চ %{count} অক্ষর)\n      too_short: খুব ছোটো (সর্বনিম্ন %{count} অক্ষর)\n      wrong_length: দৈর্ঘ্যটি সঠিক নয় (%{count} অক্ষর হতে হবে)\n    template:\n      body: 'এই ফিল্ডগুলোতে কিছু সমস্যা দেখা দিয়েছে:'\n      header:\n        one: ১টি ত্রুটির কারণে %{model} সংরক্ষণ করা সম্ভব হয়নি\n        other: \"%{count}টি ত্রুটির কারণে %{model} সংরক্ষণ করা সম্ভব হয়নি\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u %n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"৳\"\n    format:\n      delimiter: \",\"\n      precision: 2\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: বাইট\n            other: বাইট\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", এবং \"\n      two_words_connector: \" এবং \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A, %e %B %Y %H:%M:%S %z\"\n      long: \"%e %B %Y %H:%M\"\n      short: \"%e %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/bs.yml",
    "content": "---\nbs:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validacija nije uspjela: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Nije moguće izbrisati zapis jer postoje ovisni %{record}\n          has_one: Nije moguće izbrisati zapis jer postoji ovisan %{record}\n  date:\n    abbr_day_names:\n    - ned\n    - pon\n    - uto\n    - sri\n    - čet\n    - pet\n    - sub\n    abbr_month_names:\n    -\n    - jan\n    - feb\n    - mar\n    - apr\n    - maj\n    - jun\n    - jul\n    - aug\n    - sep\n    - okt\n    - nov\n    - dec\n    day_names:\n    - nedjelja\n    - ponedjeljak\n    - utorak\n    - srijeda\n    - četvrtak\n    - petak\n    - subota\n    formats:\n      default: \"%d.%m.%Y.\"\n      long: \"%e. %B %Y.\"\n      short: \"%e. %b. %Y.\"\n    month_names:\n    -\n    - januar\n    - februar\n    - mart\n    - april\n    - maj\n    - juni\n    - juli\n    - august\n    - septembar\n    - oktobar\n    - novembar\n    - decembar\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: oko %{count} sata\n        many: oko %{count} sati\n        one: oko sat\n        other: oko %{count} sati\n      about_x_months:\n        few: oko %{count} mjeseca\n        many: oko %{count} mjeseci\n        one: oko mjesec\n        other: oko %{count} mjeseci\n      about_x_years:\n        few: oko %{count} godine\n        many: oko %{count} godina\n        one: oko godine\n        other: oko %{count} godina\n      almost_x_years:\n        few: skoro %{count} godine\n        many: skoro %{count} godina\n        one: skoro %{count} godina\n        other: skoro %{count} godina\n      half_a_minute: pola minute\n      less_than_x_minutes:\n        few: manje od %{count} minute\n        many: manje od %{count} minuta\n        one: manje od minute\n        other: manje od %{count} minuta\n      less_than_x_seconds:\n        few: manje od %{count} sekunde\n        many: manje od %{count} sekundi\n        one: manje od sekunde\n        other: manje od %{count} sekundi\n      over_x_years:\n        few: preko %{count} godine\n        many: preko %{count} godina\n        one: preko godine\n        other: preko %{count} godina\n      x_days:\n        few: \"%{count} dana\"\n        many: \"%{count} dana\"\n        one: \"%{count} dan\"\n        other: \"%{count} dana\"\n      x_minutes:\n        few: \"%{count} minute\"\n        many: \"%{count} minuta\"\n        one: \"%{count} minut\"\n        other: \"%{count} minuta\"\n      x_months:\n        few: \"%{count} mjeseca\"\n        many: \"%{count} mjeseci\"\n        one: \"%{count} mjesec\"\n        other: \"%{count} mjeseci\"\n      x_seconds:\n        few: \"%{count} sekunde\"\n        many: \"%{count} sekundi\"\n        one: \"%{count} sekund\"\n        other: \"%{count} sekundi\"\n    prompts:\n      day: dan\n      hour: sat\n      minute: minut\n      month: mjesec\n      second: sekundi\n      year: godina\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: mora biti prihvaćeno\n      blank: ne smije biti prazno\n      confirmation: se ne poklapa sa potvrdom\n      empty: ne smije biti prazno\n      equal_to: mora biti %{count}\n      even: mora biti parno\n      exclusion: je rezervisano\n      greater_than: mora biti veće od %{count}\n      greater_than_or_equal_to: mora biti veće ili jednako %{count}\n      inclusion: nije uključeno u listu\n      invalid: nije validno\n      less_than: mora biti manje od %{count}\n      less_than_or_equal_to: mora biti manje ili jednako %{count}\n      not_a_number: nije broj\n      not_an_integer: mora biti cijeli broj\n      odd: mora biti neparno\n      other_than: mora biti različito od %{count}\n      present: mora biti prazno\n      taken: je već zauzet\n      too_long: je predugo (maksimalno je dozvoljeno %{count} znakova)\n      too_short: je prekratko (predviđeno je minimalno %{count} znakova)\n      wrong_length: je pogrešne dužine (trebalo bi biti tačno %{count} znakova)\n    template:\n      body: 'Desili su se problemi sa sljedećim poljima:'\n      header:\n        few: \"%{count} greške su spriječile da se ovaj %{model} spremi\"\n        many: \"%{count} grešaka je spriječilo da se ovaj %{model} spremi\"\n        one: \"%{count} greška je spriječila da se ovaj %{model} spremi\"\n        other: \"%{count} grešaka je spriječilo da se ovaj %{model} spremi\"\n  helpers:\n    select:\n      prompt: Molimo odaberite\n    submit:\n      create: Kreiraj %{model}\n      submit: Sačuvaj %{model}\n      update: Osvježi %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n%u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: true\n        unit: KM\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: true\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            few: milijarde\n            many: milijardi\n            one: milijarda\n            other: milijardi\n          million:\n            few: miliona\n            many: miliona\n            one: milion\n            other: miliona\n          quadrillion:\n            few: bilijarde\n            many: bilijardi\n            one: bilijarda\n            other: bilijardi\n          thousand:\n            few: hiljade\n            many: hiljada\n            one: hiljada\n            other: hiljada\n          trillion:\n            few: biliona\n            many: biliona\n            one: bilion\n            other: biliona\n          unit: ''\n      format:\n        delimiter: \",\"\n        precision: 0\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: bajta\n            many: bajtova\n            one: bajt\n            other: bajtova\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \",\"\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" i \"\n      two_words_connector: \" i \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%d.%m.%Y. %H:%M:%S\"\n      long: \"%d. %B %Y. - %H:%M:%S\"\n      short: \"%d. %b %Y. %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/ca.yml",
    "content": "---\nca:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validació ha fallat: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No es pot eliminar el registre perquè existeixen %{record} dependents\n          has_one: No es pot eliminar el registre perquè existeix un %{record} dependent\n  date:\n    abbr_day_names:\n    - Dg\n    - Dl\n    - Dm\n    - Dc\n    - Dj\n    - Dv\n    - Ds\n    abbr_month_names:\n    -\n    - Gen\n    - Feb\n    - Mar\n    - Abr\n    - Mai\n    - Jun\n    - Jul\n    - Ago\n    - Set\n    - Oct\n    - Nov\n    - Des\n    day_names:\n    - Diumenge\n    - Dilluns\n    - Dimarts\n    - Dimecres\n    - Dijous\n    - Divendres\n    - Dissabte\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d de %B de %Y\"\n      short: \"%d de %b\"\n    month_names:\n    -\n    - Gener\n    - Febrer\n    - Març\n    - Abril\n    - Maig\n    - Juny\n    - Juliol\n    - Agost\n    - Setembre\n    - Octubre\n    - Novembre\n    - Desembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: aproximadament %{count} hora\n        other: aproximadament %{count} hores\n      about_x_months:\n        one: aproximadament %{count} mes\n        other: aproximadament %{count} mesos\n      about_x_years:\n        one: aproximadament %{count} any\n        other: aproximadament %{count} anys\n      almost_x_years:\n        one: quasi %{count} any\n        other: quasi %{count} anys\n      half_a_minute: mig minut\n      less_than_x_minutes:\n        one: menys d'%{count} minut\n        other: menys de %{count} minuts\n      less_than_x_seconds:\n        one: menys d'%{count} segon\n        other: menys de %{count} segons\n      over_x_years:\n        one: més d'%{count} any\n        other: més de %{count} anys\n      x_days:\n        one: \"%{count} dia\"\n        other: \"%{count} dies\"\n      x_minutes:\n        one: \"%{count} minut\"\n        other: \"%{count} minuts\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} mesos\"\n      x_seconds:\n        one: \"%{count} segon\"\n        other: \"%{count} segons\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: dia\n      hour: hora\n      minute: minut\n      month: mes\n      second: segon\n      year: any\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: ha de ser acceptat\n      blank: no pot estar en blanc\n      confirmation: no coincideix\n      empty: no pot estar buit\n      equal_to: ha de ser igual a %{count}\n      even: ha de ser parell\n      exclusion: està reservat\n      greater_than: ha de ser més gran que %{count}\n      greater_than_or_equal_to: ha de ser més gran o igual a %{count}\n      inclusion: no està inclós a la llista\n      invalid: no és vàlid\n      less_than: ha de ser menor que %{count}\n      less_than_or_equal_to: ha de ser menor o igual a %{count}\n      model_invalid: 'La validació ha fallat: %{errors}'\n      not_a_number: no és un número\n      not_an_integer: ha de ser un enter\n      odd: ha de ser senar\n      other_than: ha de ser diferent de %{count}\n      present: ha d'estar en blanc\n      required: ha d'existir\n      taken: no està disponible\n      too_long:\n        one: és massa llarg (%{count} caràcter màxim)\n        other: és massa llarg (%{count} caràcters màxim)\n      too_short:\n        one: és massa curt (%{count} caràcter mínim)\n        other: és massa curt (%{count} caràcters mínim)\n      wrong_length:\n        one: no té la longitud correcta (%{count} caràcter exactament)\n        other: no té la longitud correcta (%{count} caràcters exactament)\n    template:\n      body: 'Hi ha hagut problemes amb els següents camps:'\n      header:\n        one: No s'ha pogut desar aquest/a %{model} perquè hi ha %{count} error\n        other: No s'ha pogut desar aquest/a %{model} perquè hi ha hagut %{count} errors\n  helpers:\n    select:\n      prompt: Si us plau tria\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualitzar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil milions\n          million:\n            one: milió\n            other: milions\n          quadrillion:\n            one: quadrilió\n            other: quadrilions\n          thousand:\n            one: miler\n            other: milers\n          trillion:\n            one: bilió\n            other: bilions\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n %\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", i \"\n      two_words_connector: \" i \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A, %d de %B de %Y %H:%M:%S %z\"\n      long: \"%d de %B de %Y %H:%M\"\n      short: \"%d de %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/cs.yml",
    "content": "---\ncs:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validace je neúspešná: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Nemůžu smazat položku protože existuje závislé/ý %{record}\n          has_one: Nemůžu smazat položku protože existuje závislá/ý/é %{record}\n  date:\n    abbr_day_names:\n    - Ne\n    - Po\n    - Út\n    - St\n    - Čt\n    - Pá\n    - So\n    abbr_month_names:\n    -\n    - Led\n    - Úno\n    - Bře\n    - Dub\n    - Kvě\n    - Čvn\n    - Čvc\n    - Srp\n    - Zář\n    - Říj\n    - Lis\n    - Pro\n    day_names:\n    - Neděle\n    - Pondělí\n    - Úterý\n    - Středa\n    - Čtvrtek\n    - Pátek\n    - Sobota\n    formats:\n      default: \"%d. %m. %Y\"\n      long: \"%d. %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Leden\n    - Únor\n    - Březen\n    - Duben\n    - Květen\n    - Červen\n    - Červenec\n    - Srpen\n    - Září\n    - Říjen\n    - Listopad\n    - Prosinec\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: asi %{count} hodinami\n        one: asi hodinou\n        other: asi %{count} hodinami\n      about_x_months:\n        few: asi %{count} měsíci\n        one: asi měsícem\n        other: asi %{count} měsíci\n      about_x_years:\n        few: asi %{count} roky\n        one: asi rokem\n        other: asi %{count} roky\n      almost_x_years:\n        few: téměř %{count} roky\n        one: téměř rokem\n        other: téměř %{count} roky\n      half_a_minute: půl minutou\n      less_than_x_minutes:\n        few: ani ne %{count} minutami\n        one: necelou minutou\n        other: ani ne %{count} minutami\n      less_than_x_seconds:\n        few: ani ne %{count} sekundami\n        one: necelou sekundou\n        other: ani ne %{count} sekundami\n      over_x_years:\n        few: více než %{count} roky\n        one: více než rokem\n        other: více než %{count} roky\n      x_days:\n        few: \"%{count} dny\"\n        one: 24 hodinami\n        other: \"%{count} dny\"\n      x_minutes:\n        few: \"%{count} minutami\"\n        one: minutou\n        other: \"%{count} minutami\"\n      x_months:\n        few: \"%{count} měsíci\"\n        one: měsícem\n        other: \"%{count} měsíci\"\n      x_seconds:\n        few: \"%{count} sekundami\"\n        one: sekundou\n        other: \"%{count} sekundami\"\n    prompts:\n      day: Den\n      hour: Hodina\n      minute: Minuta\n      month: Měsíc\n      second: Sekunda\n      year: Rok\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: musí být potvrzeno\n      blank: je povinná položka\n      confirmation: nebylo potvrzeno\n      empty: nesmí být prázdný/á/é\n      equal_to: musí být rovno %{count}\n      even: musí být sudé číslo\n      exclusion: je vyhrazeno pro jiný účel\n      greater_than: musí být větší než %{count}\n      greater_than_or_equal_to: musí být větší nebo rovno %{count}\n      inclusion: není v seznamu povolených hodnot\n      invalid: není platná hodnota\n      less_than: musí být méně než %{count}\n      less_than_or_equal_to: musí být méně nebo rovno %{count}\n      not_a_number: není číslo\n      not_an_integer: musí být celé číslo\n      odd: musí být liché číslo\n      other_than: musí být rozdílný/á/é od %{count}\n      present: musí být prázdný/á/é\n      required: musí existovat\n      taken: již databáze obsahuje\n      too_long: je příliš dlouhý/á/é (max. %{count} znaků)\n      too_short: je příliš krátký/á/é (min. %{count} znaků)\n      wrong_length: nemá správnou délku (očekáváno %{count} znaků)\n    template:\n      body: 'Následující pole obsahují chybně vyplněné údaje: '\n      header:\n        few: Při ukládání objektu %{model} došlo ke %{count} chybám a nebylo možné\n          jej uložit\n        one: Při ukládání objektu %{model} došlo k chybám a nebylo jej možné uložit\n        other: Při ukládání objektu %{model} došlo ke %{count} chybám a nebylo možné\n          jej uložit\n  helpers:\n    select:\n      prompt: Prosím vyberte si\n    submit:\n      create: Vytvořit %{model}\n      submit: Uložit %{model}\n      update: Aktualizovat %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: Kč\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Miliarda\n          million: Milion\n          quadrillion: Biliarda\n          thousand: Tisíc\n          trillion: Bilion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: B\n            one: B\n            other: B\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" a \"\n      two_words_connector: \" a \"\n      words_connector: \", \"\n  time:\n    am: dopoledne\n    formats:\n      default: \"%a %e. %B %Y %H:%M %z\"\n      long: \"%A %e. %B %Y %H:%M\"\n      short: \"%e. %-m. %H:%M\"\n    pm: odpoledne\n"
  },
  {
    "path": "config/locales/defaults/cy.yml",
    "content": "---\ncy:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Wedi methu dilysu: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Methu dileu cofnod oherwydd bod %{record} yn bodoli\n          has_one: Methu dileu cofnod oherwydd bod %{record} yn bodoli\n  date:\n    abbr_day_names:\n    - Sul\n    - Llun\n    - Maw\n    - Mer\n    - Iau\n    - Gwe\n    - Sad\n    abbr_month_names:\n    -\n    - Ion\n    - Chw\n    - Maw\n    - Ebr\n    - Mai\n    - Meh\n    - Gor\n    - Awst\n    - Med\n    - Hyd\n    - Tach\n    - Rha\n    day_names:\n    - Dydd Sul\n    - Dydd Llun\n    - Dydd Mawrth\n    - Dydd Mercher\n    - Dydd Iau\n    - Dydd Gwener\n    - Dydd Sadwrn\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Ionawr\n    - Chwefror\n    - Mawrth\n    - Ebrill\n    - Mai\n    - Mehefin\n    - Gorffennaf\n    - Awst\n    - Medi\n    - Hydref\n    - Tachwedd\n    - Rhagfyr\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: tua %{count} awr\n        many: tua %{count} awr\n        one: tuag awr\n        other: tua %{count} awr\n        two: tua %{count} awr\n        zero: tua %{count} awr\n      about_x_months:\n        few: tua %{count} mis\n        many: tua %{count} mis\n        one: tua mis\n        other: tua %{count} mis\n        two: tua %{count} mis\n        zero: tua %{count} mis\n      about_x_years:\n        few: tua %{count} blynedd\n        many: tua %{count} blynedd\n        one: tua blwyddyn\n        other: tua %{count} blynedd\n        two: tua %{count} blynedd\n        zero: tua %{count} blynedd\n      almost_x_years:\n        few: bron yn %{count} blynedd\n        many: bron yn %{count} blynedd\n        one: bron yn flwyddyn\n        other: bron yn %{count} blynedd\n        two: bron yn %{count} blynedd\n        zero: bron yn %{count} blynedd\n      half_a_minute: hanner munud\n      less_than_x_minutes:\n        few: llai na %{count} munud\n        many: llai na %{count} munud\n        one: llai na munud\n        other: llai na %{count} munud\n        two: llai na %{count} munud\n        zero: llai na %{count} munud\n      less_than_x_seconds:\n        few: llai na %{count} eiliad\n        many: llai na %{count} eiliad\n        one: llai nag eiliad\n        other: llai na %{count} eiliad\n        two: llai na %{count} eiliad\n        zero: llai na %{count} eiliad\n      over_x_years:\n        few: dros %{count} blynedd\n        many: dros %{count} blynedd\n        one: dros flwyddyn\n        other: dros %{count} blynedd\n        two: dros %{count} blynedd\n        zero: dros %{count} blynedd\n      x_days:\n        few: \"%{count} diwrnod\"\n        many: \"%{count} diwrnod\"\n        one: 1 diwrnod\n        other: \"%{count} diwrnod\"\n        two: \"%{count} diwrnod\"\n        zero: \"%{count} diwrnod\"\n      x_minutes:\n        few: \"%{count} o funudau\"\n        many: \"%{count} o funudau\"\n        one: 1 munud\n        other: \"%{count} o funudau\"\n        two: \"%{count} o funudau\"\n        zero: \"%{count} o funudau\"\n      x_months:\n        few: \"%{count} mis\"\n        many: \"%{count} mis\"\n        one: 1 mis\n        other: \"%{count} mis\"\n        two: \"%{count} mis\"\n        zero: \"%{count} mis\"\n      x_seconds:\n        few: \"%{count} o eiliadau\"\n        many: \"%{count} o eiliadau\"\n        one: 1 eiliad\n        other: \"%{count} o eiliadau\"\n        two: \"%{count} o eiliadau\"\n        zero: \"%{count} o eiliadau\"\n      x_years:\n        few: \"%{count} blwyddyn\"\n        many: \"%{count} blwyddyn\"\n        one: 1 flwyddyn\n        other: \"%{count} blwyddyn\"\n        two: \"%{count} blwyddyn\"\n        zero: \"%{count} blwyddyn\"\n    prompts:\n      day: Diwrnod\n      hour: Awr\n      minute: Munud\n      month: Mis\n      second: Eiliad\n      year: Blwyddyn\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: angen ei dderbyn\n      blank: methu bod yn wag\n      confirmation: heb fod yn gyfateb\n      empty: methu bod yn wag\n      equal_to: angen bod yn %{count}\n      even: rhaid bod yn eilrif\n      exclusion: wedi cadw\n      greater_than: angen bod yn fwy na %{count}\n      greater_than_or_equal_to: angen bod yr un maint neu fwy na %{count}\n      in: rhaid bod mewn %{count}\n      inclusion: heb fod yn y rhestr\n      invalid: heb fod yn nheilwng\n      less_than: angen bod yn llai na %{count}\n      less_than_or_equal_to: angen bod yr un maint neu lai na %{count}\n      model_invalid: 'Methodd y dilysu: %{errors}'\n      not_a_number: heb fod yn rhif\n      not_an_integer: heb fod yn rhif llawn\n      odd: rhaid bod yn odrif\n      other_than: rhaid bod yn wahanol na %{count}\n      present: rhaid bod yn wag\n      required: rhaid bodoli\n      taken: wedi'i gymryd yn barod\n      too_long: yn rhy hir (cewch %{count} llythyren ar y fwyaf)\n      too_short: yn rhy fyr (rhaid am o leiaf %{count} llythyren)\n      wrong_length: gyda maint anghywir o lythrennau (dylai fod yn %{count} llythyren)\n    template:\n      body: 'Cafwyd broblemau gyda''r meysydd canlynol:'\n      header:\n        few: Atalwyd y %{model} hwn rhag ei gadw gan %{count} nam\n        many: Atalwyd y %{model} hwn rhag ei gadw gan %{count} nam\n        one: Atalwyd y %{model} hwn rhag ei gadw gan 1 nam\n        other: Atalwyd y %{model} hwn rhag ei gadw gan %{count} nam\n        two: Atalwyd y %{model} hwn rhag ei gadw gan %{count} nam\n        zero: Atalwyd y %{model} hwn rhag ei gadw gan 0 nam\n  helpers:\n    select:\n      prompt: Dewiswch\n    submit:\n      create: Creu %{model}\n      submit: Cadw %{model}\n      update: Diweddaru %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"£\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Biliwn\n          million: Miliwn\n          quadrillion: Cwadriliwn\n          thousand: Mil\n          trillion: Triliwn\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: Bytes\n            many: Bytes\n            one: Byte\n            other: Bytes\n            two: Bytes\n            zero: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", a \"\n      two_words_connector: \" a \"\n      words_connector: \", \"\n  time:\n    am: yb\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: yh\n"
  },
  {
    "path": "config/locales/defaults/da.yml",
    "content": "---\nda:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Godkendelse gik galt: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Kunne ikke slette posten fordi afhængige %{record} findes\n          has_one: Kunne ikke slette posten fordi en afhængig %{record} findes\n  date:\n    abbr_day_names:\n    - søn\n    - man\n    - tir\n    - ons\n    - tor\n    - fre\n    - lør\n    abbr_month_names:\n    -\n    - jan\n    - feb\n    - mar\n    - apr\n    - maj\n    - jun\n    - jul\n    - aug\n    - sep\n    - okt\n    - nov\n    - dec\n    day_names:\n    - søndag\n    - mandag\n    - tirsdag\n    - onsdag\n    - torsdag\n    - fredag\n    - lørdag\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%e. %B %Y\"\n      short: \"%e. %b %Y\"\n    month_names:\n    -\n    - januar\n    - februar\n    - marts\n    - april\n    - maj\n    - juni\n    - juli\n    - august\n    - september\n    - oktober\n    - november\n    - december\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: cirka en time\n        other: cirka %{count} timer\n      about_x_months:\n        one: cirka en måned\n        other: cirka %{count} måneder\n      about_x_years:\n        one: cirka et år\n        other: cirka %{count} år\n      almost_x_years:\n        one: næsten et år\n        other: næsten %{count} år\n      half_a_minute: et halvt minut\n      less_than_x_minutes:\n        one: mindre end et minut\n        other: mindre end %{count} minutter\n      less_than_x_seconds:\n        one: mindre end et sekund\n        other: mindre end %{count} sekunder\n      over_x_years:\n        one: mere end et år\n        other: mere end %{count} år\n      x_days:\n        one: en dag\n        other: \"%{count} dage\"\n      x_minutes:\n        one: et minut\n        other: \"%{count} minutter\"\n      x_months:\n        one: en måned\n        other: \"%{count} måneder\"\n      x_seconds:\n        one: et sekund\n        other: \"%{count} sekunder\"\n      x_years:\n        one: et år\n        other: \"%{count} år\"\n    prompts:\n      day: Dag\n      hour: Time\n      minute: Minut\n      month: Måned\n      second: Sekund\n      year: År\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: skal accepteres\n      blank: skal udfyldes\n      confirmation: stemmer ikke overens med %{attribute}\n      empty: må ikke udelades\n      equal_to: skal være %{count}\n      even: skal være et lige tal\n      exclusion: er reserveret\n      greater_than: skal være større end %{count}\n      greater_than_or_equal_to: skal være større end eller lig med %{count}\n      inclusion: er ikke på listen\n      invalid: er ikke gyldig\n      less_than: skal være mindre end %{count}\n      less_than_or_equal_to: skal være mindre end eller lig med %{count}\n      model_invalid: 'Godkendelse gik galt: %{errors}'\n      not_a_number: er ikke et tal\n      not_an_integer: er ikke et heltal\n      odd: skal være et ulige tal\n      other_than: skal være forskellig fra %{count}\n      present: skal være tom\n      required: skal udfyldes\n      taken: er allerede brugt\n      too_long:\n        one: er for lang (højst %{count} tegn)\n        other: er for lang (højst %{count} tegn)\n      too_short:\n        one: er for kort (mindst %{count} tegn)\n        other: er for kort (mindst %{count} tegn)\n      wrong_length:\n        one: har forkert længde (skulle være %{count} tegn)\n        other: har forkert længde (skulle være %{count} tegn)\n    template:\n      body: 'Der var problemer med følgende felter:'\n      header:\n        one: En fejl forhindrede %{model} i at blive gemt\n        other: \"%{count} fejl forhindrede %{model} i at blive gemt\"\n  helpers:\n    select:\n      prompt: Vælg...\n    submit:\n      create: Opret %{model}\n      submit: Gem %{model}\n      update: Opdater %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%u %n\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: DKK\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: milliard\n            other: milliarder\n          million:\n            one: million\n            other: millioner\n          quadrillion:\n            one: Billiard\n            other: Billiarder\n          thousand: tusind\n          trillion:\n            one: billion\n            other: billioner\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: byte\n            other: bytes\n          gb: GB\n          kb: kB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n %\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" og \"\n      two_words_connector: \" og \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%e. %B %Y, %H.%M\"\n      long: \"%A d. %e. %B %Y, %H.%M\"\n      short: \"%e. %b %Y, %H.%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/de-AT.yml",
    "content": "---\nde-AT:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Datensatz kann nicht gelöscht werden, da abhängige %{record} existieren.\n          has_one: Datensatz kann nicht gelöscht werden, da ein abhängiger %{record}-Datensatz\n            existiert.\n  date:\n    abbr_day_names:\n    - So\n    - Mo\n    - Di\n    - Mi\n    - Do\n    - Fr\n    - Sa\n    abbr_month_names:\n    -\n    - Jän\n    - Feb\n    - Mär\n    - Apr\n    - Mai\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Okt\n    - Nov\n    - Dez\n    day_names:\n    - Sonntag\n    - Montag\n    - Dienstag\n    - Mittwoch\n    - Donnerstag\n    - Freitag\n    - Samstag\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%e. %B %Y\"\n      short: \"%e. %b\"\n    month_names:\n    -\n    - Jänner\n    - Februar\n    - März\n    - April\n    - Mai\n    - Juni\n    - Juli\n    - August\n    - September\n    - Oktober\n    - November\n    - Dezember\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: etwa eine Stunde\n        other: etwa %{count} Stunden\n      about_x_months:\n        one: etwa ein Monat\n        other: etwa %{count} Monate\n      about_x_years:\n        one: etwa ein Jahr\n        other: etwa %{count} Jahre\n      almost_x_years:\n        one: fast ein Jahr\n        other: fast %{count} Jahre\n      half_a_minute: eine halbe Minute\n      less_than_x_minutes:\n        one: weniger als eine Minute\n        other: weniger als %{count} Minuten\n      less_than_x_seconds:\n        one: weniger als eine Sekunde\n        other: weniger als %{count} Sekunden\n      over_x_years:\n        one: mehr als ein Jahr\n        other: mehr als %{count} Jahre\n      x_days:\n        one: ein Tag\n        other: \"%{count} Tage\"\n      x_minutes:\n        one: eine Minute\n        other: \"%{count} Minuten\"\n      x_months:\n        one: ein Monat\n        other: \"%{count} Monate\"\n      x_seconds:\n        one: eine Sekunde\n        other: \"%{count} Sekunden\"\n    prompts:\n      day: Tag\n      hour: Stunde\n      minute: Minuten\n      month: Monat\n      second: Sekunden\n      year: Jahr\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: muss akzeptiert werden\n      blank: muss ausgefüllt werden\n      confirmation: stimmt nicht mit %{attribute} überein\n      empty: muss ausgefüllt werden\n      equal_to: muss genau %{count} sein\n      even: muss gerade sein\n      exclusion: ist nicht verfügbar\n      greater_than: muss größer als %{count} sein\n      greater_than_or_equal_to: muss größer oder gleich %{count} sein\n      inclusion: ist kein gültiger Wert\n      invalid: ist nicht gültig\n      less_than: muss kleiner als %{count} sein\n      less_than_or_equal_to: muss kleiner oder gleich %{count} sein\n      model_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}'\n      not_a_number: ist keine Zahl\n      not_an_integer: muss ganzzahlig sein\n      odd: muss ungerade sein\n      other_than: darf nicht gleich %{count} sein\n      present: darf nicht ausgefüllt werden\n      required: muss ausgefüllt werden\n      taken: ist bereits vergeben\n      too_long:\n        one: ist zu lang (mehr als %{count} Zeichen)\n        other: ist zu lang (mehr als %{count} Zeichen)\n      too_short:\n        one: ist zu kurz (weniger als %{count} Zeichen)\n        other: ist zu kurz (weniger als %{count} Zeichen)\n      wrong_length:\n        one: hat die falsche Länge (muss genau %{count} Zeichen haben)\n        other: hat die falsche Länge (muss genau %{count} Zeichen haben)\n    template:\n      body: 'Bitte überprüfen Sie die folgenden Felder:'\n      header:\n        one: 'Konnte %{model} nicht speichern: ein Fehler.'\n        other: 'Konnte %{model} nicht speichern: %{count} Fehler.'\n  helpers:\n    select:\n      prompt: Bitte wählen\n    submit:\n      create: \"%{model} erstellen\"\n      submit: \"%{model} speichern\"\n      update: \"%{model} aktualisieren\"\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%u %n\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: Milliarde\n            other: Milliarden\n          million:\n            one: Million\n            other: Millionen\n          quadrillion:\n            one: Billiarde\n            other: Billiarden\n          thousand: Tausend\n          trillion:\n            one: Billion\n            other: Billionen\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" und \"\n      two_words_connector: \" und \"\n      words_connector: \", \"\n  time:\n    am: vormittags\n    formats:\n      default: \"%A, %d. %B %Y, %H:%M Uhr\"\n      long: \"%A, %d. %B %Y, %H:%M Uhr\"\n      short: \"%d. %b, %H:%M Uhr\"\n    pm: nachmittags\n"
  },
  {
    "path": "config/locales/defaults/de-CH.yml",
    "content": "---\nde-CH:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Datensatz kann nicht gelöscht werden, da abhängige %{record} existieren.\n          has_one: Datensatz kann nicht gelöscht werden, da ein abhängiger %{record}-Datensatz\n            existiert.\n  date:\n    abbr_day_names:\n    - So\n    - Mo\n    - Di\n    - Mi\n    - Do\n    - Fr\n    - Sa\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mär\n    - Apr\n    - Mai\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Okt\n    - Nov\n    - Dez\n    day_names:\n    - Sonntag\n    - Montag\n    - Dienstag\n    - Mittwoch\n    - Donnerstag\n    - Freitag\n    - Samstag\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%e. %B %Y\"\n      short: \"%e. %b\"\n    month_names:\n    -\n    - Januar\n    - Februar\n    - März\n    - April\n    - Mai\n    - Juni\n    - Juli\n    - August\n    - September\n    - Oktober\n    - November\n    - Dezember\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: etwa eine Stunde\n        other: etwa %{count} Stunden\n      about_x_months:\n        one: etwa ein Monat\n        other: etwa %{count} Monate\n      about_x_years:\n        one: etwa ein Jahr\n        other: etwa %{count} Jahre\n      almost_x_years:\n        one: fast ein Jahr\n        other: fast %{count} Jahre\n      half_a_minute: eine halbe Minute\n      less_than_x_minutes:\n        one: weniger als eine Minute\n        other: weniger als %{count} Minuten\n      less_than_x_seconds:\n        one: weniger als eine Sekunde\n        other: weniger als %{count} Sekunden\n      over_x_years:\n        one: mehr als ein Jahr\n        other: mehr als %{count} Jahre\n      x_days:\n        one: ein Tag\n        other: \"%{count} Tage\"\n      x_minutes:\n        one: eine Minute\n        other: \"%{count} Minuten\"\n      x_months:\n        one: ein Monat\n        other: \"%{count} Monate\"\n      x_seconds:\n        one: eine Sekunde\n        other: \"%{count} Sekunden\"\n    prompts:\n      day: Tag\n      hour: Stunde\n      minute: Minute\n      month: Monat\n      second: Sekunde\n      year: Jahr\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: muss akzeptiert werden\n      blank: muss ausgefüllt werden\n      confirmation: stimmt nicht mit %{attribute} überein\n      empty: muss ausgefüllt werden\n      equal_to: muss genau %{count} sein\n      even: muss gerade sein\n      exclusion: ist nicht verfügbar\n      greater_than: muss grösser als %{count} sein\n      greater_than_or_equal_to: muss grösser oder gleich %{count} sein\n      inclusion: ist kein gültiger Wert\n      invalid: ist nicht gültig\n      less_than: muss kleiner als %{count} sein\n      less_than_or_equal_to: muss kleiner oder gleich %{count} sein\n      model_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}'\n      not_a_number: ist keine Zahl\n      not_an_integer: muss ganzzahlig sein\n      odd: muss ungerade sein\n      other_than: darf nicht gleich %{count} sein\n      present: darf nicht ausgefüllt werden\n      required: muss ausgefüllt werden\n      taken: ist bereits vergeben\n      too_long:\n        one: ist zu lang (mehr als %{count} Zeichen)\n        other: ist zu lang (mehr als %{count} Zeichen)\n      too_short:\n        one: ist zu kurz (weniger als %{count} Zeichen)\n        other: ist zu kurz (weniger als %{count} Zeichen)\n      wrong_length:\n        one: hat die falsche Länge (muss genau %{count} Zeichen haben)\n        other: hat die falsche Länge (muss genau %{count} Zeichen haben)\n    template:\n      body: 'Bitte überprüfen Sie die folgenden Felder:'\n      header:\n        one: 'Konnte %{model} nicht speichern: ein Fehler.'\n        other: 'Konnte %{model} nicht speichern: %{count} Fehler.'\n  helpers:\n    select:\n      prompt: Bitte wählen\n    submit:\n      create: \"%{model} erstellen\"\n      submit: \"%{model} speichern\"\n      update: \"%{model} aktualisieren\"\n  number:\n    currency:\n      format:\n        delimiter: \"'\"\n        format: \"%u %n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: CHF\n    format:\n      delimiter: \"'\"\n      precision: 2\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: Milliarde\n            other: Milliarden\n          million:\n            one: Million\n            other: Millionen\n          quadrillion:\n            one: Billiarde\n            other: Billiarden\n          thousand: Tausend\n          trillion:\n            one: Billion\n            other: Billionen\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" und \"\n      two_words_connector: \" und \"\n      words_connector: \", \"\n  time:\n    am: vormittags\n    formats:\n      default: \"%A, %d. %B %Y, %H:%M Uhr\"\n      long: \"%A, %d. %B %Y, %H:%M Uhr\"\n      short: \"%d. %b, %H:%M Uhr\"\n    pm: nachmittags\n"
  },
  {
    "path": "config/locales/defaults/de-DE.yml",
    "content": "---\nde-DE:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Datensatz kann nicht gelöscht werden, da abhängige %{record} existieren.\n          has_one: Datensatz kann nicht gelöscht werden, da ein abhängiger %{record}-Datensatz\n            existiert.\n  date:\n    abbr_day_names:\n    - So\n    - Mo\n    - Di\n    - Mi\n    - Do\n    - Fr\n    - Sa\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mär\n    - Apr\n    - Mai\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Okt\n    - Nov\n    - Dez\n    day_names:\n    - Sonntag\n    - Montag\n    - Dienstag\n    - Mittwoch\n    - Donnerstag\n    - Freitag\n    - Samstag\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%e. %B %Y\"\n      short: \"%e. %b\"\n    month_names:\n    -\n    - Januar\n    - Februar\n    - März\n    - April\n    - Mai\n    - Juni\n    - Juli\n    - August\n    - September\n    - Oktober\n    - November\n    - Dezember\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: etwa eine Stunde\n        other: etwa %{count} Stunden\n      about_x_months:\n        one: etwa ein Monat\n        other: etwa %{count} Monate\n      about_x_years:\n        one: etwa ein Jahr\n        other: etwa %{count} Jahre\n      almost_x_years:\n        one: fast ein Jahr\n        other: fast %{count} Jahre\n      half_a_minute: eine halbe Minute\n      less_than_x_minutes:\n        one: weniger als eine Minute\n        other: weniger als %{count} Minuten\n      less_than_x_seconds:\n        one: weniger als eine Sekunde\n        other: weniger als %{count} Sekunden\n      over_x_years:\n        one: mehr als ein Jahr\n        other: mehr als %{count} Jahre\n      x_days:\n        one: ein Tag\n        other: \"%{count} Tage\"\n      x_minutes:\n        one: eine Minute\n        other: \"%{count} Minuten\"\n      x_months:\n        one: ein Monat\n        other: \"%{count} Monate\"\n      x_seconds:\n        one: eine Sekunde\n        other: \"%{count} Sekunden\"\n    prompts:\n      day: Tag\n      hour: Stunde\n      minute: Minute\n      month: Monat\n      second: Sekunde\n      year: Jahr\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: muss akzeptiert werden\n      blank: muss ausgefüllt werden\n      confirmation: stimmt nicht mit %{attribute} überein\n      empty: muss ausgefüllt werden\n      equal_to: muss genau %{count} sein\n      even: muss gerade sein\n      exclusion: ist nicht verfügbar\n      greater_than: muss größer als %{count} sein\n      greater_than_or_equal_to: muss größer oder gleich %{count} sein\n      inclusion: ist kein gültiger Wert\n      invalid: ist nicht gültig\n      less_than: muss kleiner als %{count} sein\n      less_than_or_equal_to: muss kleiner oder gleich %{count} sein\n      model_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}'\n      not_a_number: ist keine Zahl\n      not_an_integer: muss ganzzahlig sein\n      odd: muss ungerade sein\n      other_than: darf nicht gleich %{count} sein\n      present: darf nicht ausgefüllt werden\n      required: muss ausgefüllt werden\n      taken: ist bereits vergeben\n      too_long:\n        one: ist zu lang (mehr als %{count} Zeichen)\n        other: ist zu lang (mehr als %{count} Zeichen)\n      too_short:\n        one: ist zu kurz (weniger als %{count} Zeichen)\n        other: ist zu kurz (weniger als %{count} Zeichen)\n      wrong_length:\n        one: hat die falsche Länge (muss genau %{count} Zeichen haben)\n        other: hat die falsche Länge (muss genau %{count} Zeichen haben)\n    template:\n      body: 'Bitte überprüfen Sie die folgenden Felder:'\n      header:\n        one: 'Konnte %{model} nicht speichern: ein Fehler.'\n        other: 'Konnte %{model} nicht speichern: %{count} Fehler.'\n  helpers:\n    select:\n      prompt: Bitte wählen\n    submit:\n      create: \"%{model} erstellen\"\n      submit: \"%{model} speichern\"\n      update: \"%{model} aktualisieren\"\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: Milliarde\n            other: Milliarden\n          million:\n            one: Million\n            other: Millionen\n          quadrillion:\n            one: Billiarde\n            other: Billiarden\n          thousand: Tausend\n          trillion:\n            one: Billion\n            other: Billionen\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n %\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" und \"\n      two_words_connector: \" und \"\n      words_connector: \", \"\n  time:\n    am: vormittags\n    formats:\n      default: \"%A, %d. %B %Y, %H:%M Uhr\"\n      long: \"%A, %d. %B %Y, %H:%M Uhr\"\n      short: \"%d. %b, %H:%M Uhr\"\n    pm: nachmittags\n"
  },
  {
    "path": "config/locales/defaults/de.yml",
    "content": "---\nde:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Datensatz kann nicht gelöscht werden, da abhängige %{record} existieren.\n          has_one: Datensatz kann nicht gelöscht werden, da ein abhängiger %{record}-Datensatz\n            existiert.\n  date:\n    abbr_day_names:\n    - So\n    - Mo\n    - Di\n    - Mi\n    - Do\n    - Fr\n    - Sa\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mär\n    - Apr\n    - Mai\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Okt\n    - Nov\n    - Dez\n    day_names:\n    - Sonntag\n    - Montag\n    - Dienstag\n    - Mittwoch\n    - Donnerstag\n    - Freitag\n    - Samstag\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%e. %B %Y\"\n      short: \"%e. %b\"\n    month_names:\n    -\n    - Januar\n    - Februar\n    - März\n    - April\n    - Mai\n    - Juni\n    - Juli\n    - August\n    - September\n    - Oktober\n    - November\n    - Dezember\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: etwa eine Stunde\n        other: etwa %{count} Stunden\n      about_x_months:\n        one: etwa ein Monat\n        other: etwa %{count} Monate\n      about_x_years:\n        one: etwa ein Jahr\n        other: etwa %{count} Jahre\n      almost_x_years:\n        one: fast ein Jahr\n        other: fast %{count} Jahre\n      half_a_minute: eine halbe Minute\n      less_than_x_minutes:\n        one: weniger als eine Minute\n        other: weniger als %{count} Minuten\n      less_than_x_seconds:\n        one: weniger als eine Sekunde\n        other: weniger als %{count} Sekunden\n      over_x_years:\n        one: mehr als ein Jahr\n        other: mehr als %{count} Jahre\n      x_days:\n        one: ein Tag\n        other: \"%{count} Tage\"\n      x_minutes:\n        one: eine Minute\n        other: \"%{count} Minuten\"\n      x_months:\n        one: ein Monat\n        other: \"%{count} Monate\"\n      x_seconds:\n        one: eine Sekunde\n        other: \"%{count} Sekunden\"\n      x_years:\n        one: ein Jahr\n        other: \"%{count} Jahre\"\n    prompts:\n      day: Tag\n      hour: Stunde\n      minute: Minute\n      month: Monat\n      second: Sekunde\n      year: Jahr\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: muss akzeptiert werden\n      blank: muss ausgefüllt werden\n      confirmation: stimmt nicht mit %{attribute} überein\n      empty: muss ausgefüllt werden\n      equal_to: muss genau %{count} sein\n      even: muss gerade sein\n      exclusion: ist nicht verfügbar\n      greater_than: muss größer als %{count} sein\n      greater_than_or_equal_to: muss größer oder gleich %{count} sein\n      inclusion: ist kein gültiger Wert\n      invalid: ist nicht gültig\n      less_than: muss kleiner als %{count} sein\n      less_than_or_equal_to: muss kleiner oder gleich %{count} sein\n      model_invalid: 'Gültigkeitsprüfung ist fehlgeschlagen: %{errors}'\n      not_a_number: ist keine Zahl\n      not_an_integer: muss ganzzahlig sein\n      odd: muss ungerade sein\n      other_than: darf nicht gleich %{count} sein\n      present: darf nicht ausgefüllt werden\n      required: muss ausgefüllt werden\n      taken: ist bereits vergeben\n      too_long:\n        one: ist zu lang (mehr als %{count} Zeichen)\n        other: ist zu lang (mehr als %{count} Zeichen)\n      too_short:\n        one: ist zu kurz (weniger als %{count} Zeichen)\n        other: ist zu kurz (weniger als %{count} Zeichen)\n      wrong_length:\n        one: hat die falsche Länge (muss genau %{count} Zeichen haben)\n        other: hat die falsche Länge (muss genau %{count} Zeichen haben)\n    template:\n      body: 'Bitte überprüfen Sie die folgenden Felder:'\n      header:\n        one: 'Konnte %{model} nicht speichern: ein Fehler.'\n        other: 'Konnte %{model} nicht speichern: %{count} Fehler.'\n  helpers:\n    select:\n      prompt: Bitte wählen\n    submit:\n      create: \"%{model} erstellen\"\n      submit: \"%{model} speichern\"\n      update: \"%{model} aktualisieren\"\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: Milliarde\n            other: Milliarden\n          million:\n            one: Million\n            other: Millionen\n          quadrillion:\n            one: Billiarde\n            other: Billiarden\n          thousand: Tausend\n          trillion:\n            one: Billion\n            other: Billionen\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n %\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" und \"\n      two_words_connector: \" und \"\n      words_connector: \", \"\n  time:\n    am: vormittags\n    formats:\n      default: \"%A, %d. %B %Y, %H:%M Uhr\"\n      long: \"%A, %d. %B %Y, %H:%M Uhr\"\n      short: \"%d. %b, %H:%M Uhr\"\n    pm: nachmittags\n"
  },
  {
    "path": "config/locales/defaults/dz.yml",
    "content": "---\ndz:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'ཆ་འཇོག་འཐུས་ཤོར་ཡི་: %{errors}'\n        restrict_dependent_destroy:\n          has_many: ཡིག་ཐོ་ %{record} བརྟེན་ཏེ་ཡོད་པ་ལས་བཏོན་གཏང་མི་བཏུབ་\n          has_one: ཡིག་ཐོ་ %{record} བརྟེན་ཏེ་ཡོད་པ་ལས་བཏོན་གཏང་མི་བཏུབ་\n  date:\n    abbr_day_names:\n    - གཟའ་ཟླཝ\n    - གཟའ་མིག་དམར\n    - གཟའ་ལྷགཔ\n    - གཟའ་ཕུརཔ\n    - གཟའ་པ་སངས\n    - གཟའ་སྤེན་པ\n    - གཟའ་ཉིམ\n    abbr_month_names:\n    -\n    - ཟླཝ་དང་པ\n    - ཟླཝ་གཉིས་པ\n    - ཟླཝ་གསུམ་པ\n    - ཟླཝ་བཞི་པ\n    - ཟླཝ་ལྔ་པ\n    - ཟླཝ་དྲུག་པ\n    - ཟླཝ་བདུན་པ\n    - ཟླཝ་བརྒྱད་པ\n    - ཟླཝ་དགུ་པ\n    - ཟླཝ་བཅུ་པ\n    - ཟླཝ་བཅུ་གཅིག་པ\n    - ཟླཝ་བཅུ་གཉིས་པ\n    day_names:\n    - གཟའ་ཟླཝ\n    - གཟའ་མིག་དམར\n    - གཟའ་ལྷག་པ\n    - གཟའ་ཕུརཔ\n    - གཟའ་པ་སངས\n    - གཟའ་སྤེན་པ\n    - གཟའ་ཉི་མ\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - ཟླཝ་དང་པ\n    - ཟླཝ་གཉིས་པ\n    - ཟླཝ་གསུམ་པ\n    - ཟླཝ་བཞི་པ\n    - ཟླཝ་ལྔ་པ\n    - ཟླཝ་དྲུག་པ\n    - ཟླཝ་བདུན་པ\n    - ཟླཝ་བརྒྱད་པ\n    - ཟླཝ་དགུ་པ\n    - ཟླཝ་བཅུ་པ\n    - ཟླཝ་བཅུ་གཅིག་པ\n    - ཟླཝ་བཅུ་གཉིས་པ\n    order:\n    - :ལོ\n    - :ཟླཝ\n    - :ཉིནམ\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ཆུ་ཚོད་ ༡ ་དེ་ཅིག\n        other: ཆུ་ཚོད་ %{count} དེ་ཅིག\n      about_x_months:\n        one: ཟླཝ་ ༡ ་དེ་ཅིག\n        other: ཟླཝ་ %{count} དེ་ཅིག\n      about_x_years:\n        one: ལོ་ ༡ ་དེ་ཅིག\n        other: ལོ་ %{count} དེ་ཅིག\n      almost_x_years: ལོ %{count} མ་ལྷགཔ་ཅིག\n      half_a_minute: སྐར་མ་ཕྱེད་ཀ\n      less_than_x_minutes:\n        one: སྐར་མ་ ༡ ་ལས་ཉུངམ\n        other: སྐར་མ %{count} ལས་ཉུངམ\n      less_than_x_seconds:\n        one: སྐར་ཆ་ ༡ ་ལས་ཉུངམ\n        other: སྐར་ཆ %{count} ལས་ཉུངམ\n      over_x_years:\n        one: ལོ་ ༡ ་ལྷག\n        other: ལོ་%{count}ལས་ལྷག\n      x_days:\n        one: ཉིནམ་གཅིག\n        other: ཉིནམ %{count}\n      x_minutes:\n        one: སྐར་མ་གཅིག\n        other: སྐར་མ %{count}\n      x_months:\n        one: ཟླཝ་གཅིག\n        other: ཟླཝ %{count}\n      x_seconds:\n        one: སྐར་ཆ་གཅིག\n        other: སྐར་ཆ %{count}\n      x_years:\n        one: ལོ་གཅིག\n        other: ལོ %{count}\n    prompts:\n      day: ཉིནམ\n      hour: ཆུ་ཚོད\n      minute: སྐར་མ\n      month: ཟླཝ\n      second: སྐར་ཆ\n      year: ལོ\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: ངོས་ལན་འབད་དགོ\n      blank: སྟོང་པ་མི་བཏུབ\n      confirmation: \"%{attribute} དང་ཅོག་འཐོད་མིན་འདུག\"\n      empty: སྟོངམ་མི་བཏུབ\n      equal_to: \"%{count} དང་འདྲ་མཉམ་དགོ\"\n      even: འདྲན་འདྲ་དགོ\n      exclusion: ཟུར་གསོག\n      greater_than: \"%{count} ལས་སྦོམ་དགོ\"\n      greater_than_or_equal_to: \"%{count} ལས་མང་སུ་ཡང་ན་འདྲན་འདྲ་དགོ\"\n      inclusion: ཐོ་ཡིག་གི་གྲངས་སུ་མིན་འདུག\n      invalid: ཆ་མེད་ཨིན\n      less_than: \"%{count} ལས་ཉུང་སུ་ཅིག་དགོ\"\n      less_than_or_equal_to: \"%{count} ལས་ཉུང་སུ་ཡང་ན་འདྲན་འདྲ་དགོ\"\n      model_invalid: 'ཆ་འཇོག་འཐུས་ཤོར་ཡི: %{errors}'\n      not_a_number: ཨང་གྲངས་མེན\n      not_an_integer: ཧྲིལ་གྲངས་དགོ\n      odd: ཡ་ཨང་དགོ\n      other_than: \"%{count} ལས་སོ་སོ་ཅིག་དགོ\"\n      present: ས་སྟོང་བཞག་དགོ\n      required: དགོ་རང་དགོ\n      taken: ཧེ་མ་ལས་འབག་ཚར་ཡི\n      too_long:\n        one: ཡིག་འབྲུ་གནམ་མེད་ས་མེད་རིངམ་ཨིན་མེ་(ཡིག་འབྲུ་མཐོ་ཚད་གཅིག་་ཨིན)\n        other: ཡིག་འབྲུ་གནམ་མེད་ས་མེད་རིངམ་ཨིན་མེ་(ཡིག་འབྲུ་མཐོ་ཚད་ %{count} ཨིན)\n      too_short:\n        one: ཡིག་འབྲུ་གནམ་མེད་ས་མེད་ཐུང་སུ་ནུག་(ཡིག་འབྲུ་ཉུང་མཐའ་གཅིག་ཨིན)\n        other: ཡིག་འབྲུ་གནམ་མེད་ས་མེད་ཐུང་སུ་ནུག་(ཡིག་འབྲུ་ཉུང་མཐའ་ %{count} ཨིན)\n      wrong_length:\n        one: ཡིག་འབྲུ་འཛུལ་ནུག་(ཡིག་འབྲུ་གཅིག་ངེས་པར་དུ་དགོ)\n        other: ཡིག་འབྲུ་འཛུལ་ནུག་(ཡིག་འབྲུ་ %{count} ངེས་པར་དུ་དགོ)\n    template:\n      body: འོག་གི་ས་ཁོངས་ཚུ་ན་བཀའ་ངལ་འདུག\n      header:\n        one: འཛོལ་བ་ཅིག་གིས་ %{model} བསྡུ་བཞག་འབད་ནི་ལུ་བཀག་རྐྱབ་ཅིག\n        other: འཛོལ་བ་ %{count} ་གིས་ %{model} འདི་བསྡུ་བཞག་འབད་ནི་ལུ་བཀག་རྐྱབ་ཅིག\n  helpers:\n    select:\n      prompt: གདམ་ཁ་རྐྱབ\n    submit:\n      create: \"%{model} གསར་སྤྲིན་འབད\"\n      submit: \"%{model} བསྡུ་བཞག་འབད\"\n      update: \"%{model} དུས་མཐུན་ཁ་གསོ་འབད\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: ༢\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: ༣\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: ཐེར་འབུམ\n          million: ས་ཡ\n          quadrillion: Quadrillion\n          thousand: སྟོང་ཕྲག\n          trillion: ཁྲག་ཁྲིག་ཆེན་པོ\n          unit: ''\n      format:\n        delimiter: ''\n        precision: ༣\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", དང \"\n      two_words_connector: \" དང \"\n      words_connector: \", \"\n  time:\n    am: དྲོ་པ་གི་ཆུ་ཚོད་་\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: ཕྱི་རུ་གི་ཆུ་ཚོད་\n"
  },
  {
    "path": "config/locales/defaults/el-CY.yml",
    "content": "---\nel-CY:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Η επικύρωση απέτυχε: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Η εγγραφή δεν μπορεί να διαγραφεί γιατί υπάρχουν εξαρτημένα %{record}\n          has_one: Η εγγραφή δεν μπορεί να διαγραφεί γιατί υπάρχει εξαρτημένο %{record}\n  date:\n    abbr_day_names:\n    - Κυρ\n    - Δευ\n    - Τρί\n    - Τετ\n    - Πέμ\n    - Παρ\n    - Σάβ\n    abbr_month_names:\n    -\n    - Ιαν\n    - Φεβ\n    - Μαρ\n    - Απρ\n    - Μαϊ\n    - Ιουν\n    - Ιουλ\n    - Αυγ\n    - Σεπ\n    - Οκτ\n    - Νοε\n    - Δεκ\n    day_names:\n    - Κυριακή\n    - Δευτέρα\n    - Τρίτη\n    - Τετάρτη\n    - Πέμπτη\n    - Παρασκευή\n    - Σάββατο\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%e %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Ιανουάριος\n    - Φεβρουάριος\n    - Μάρτιος\n    - Απρίλιος\n    - Μάιος\n    - Ιούνιος\n    - Ιούλιος\n    - Αύγουστος\n    - Σεπτέμβριος\n    - Οκτώβριος\n    - Νοέμβριος\n    - Δεκέμβριος\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: περίπου μία ώρα\n        other: περίπου %{count} ώρες\n      about_x_months:\n        one: περίπου ένα μήνα\n        other: περίπου %{count} μήνες\n      about_x_years:\n        one: περίπου ένα χρόνο\n        other: περίπου %{count} χρόνια\n      almost_x_years:\n        one: σχεδόν ένα χρόνο\n        other: σχεδόν %{count} χρόνια\n      half_a_minute: μισό λεπτό\n      less_than_x_minutes:\n        one: λιγότερο από ένα λεπτό\n        other: λιγότερο από %{count} λεπτά\n      less_than_x_seconds:\n        one: λιγότερο από ένα δευτερόλεπτο\n        other: λιγότερο από %{count} δευτερόλεπτα\n      over_x_years:\n        one: πάνω από ένα χρόνο\n        other: πάνω από %{count} χρόνια\n      x_days:\n        one: \"%{count} μέρα\"\n        other: \"%{count} ημέρες\"\n      x_minutes:\n        one: \"%{count} λεπτό\"\n        other: \"%{count} λεπτά\"\n      x_months:\n        one: \"%{count} μήνα\"\n        other: \"%{count} μήνες\"\n      x_seconds:\n        one: \"%{count} δευτερόλεπτο\"\n        other: \"%{count} δευτερόλεπτα\"\n      x_years:\n        one: \"%{count} χρόνος\"\n        other: \"%{count} χρόνια\"\n    prompts:\n      day: Ημέρα\n      hour: Ώρα\n      minute: Λεπτό\n      month: Μήνας\n      second: Δευτερόλεπτο\n      year: Έτος\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: πρέπει να είναι αποδεκτό\n      blank: δεν πρέπει να είναι κενό\n      confirmation: δεν ταιριάζει με την επικύρωση\n      empty: δεν πρέπει να είναι άδειο\n      equal_to: πρέπει να είναι ίσο με %{count}\n      even: πρέπει να είναι άρτιος\n      exclusion: είναι δεσμευμένο\n      greater_than: πρέπει να είναι μεγαλύτερο από %{count}\n      greater_than_or_equal_to: πρέπει να είναι μεγαλύτερο ή ίσο με %{count}\n      inclusion: δεν συμπεριλαμβάνεται στη λίστα\n      invalid: είναι άκυρο\n      less_than: πρέπει να είναι λιγότερο από %{count}\n      less_than_or_equal_to: πρέπει να είναι λιγότερο ή ίσο με %{count}\n      model_invalid: 'Η επικύρωση απέτυχε: %{errors}'\n      not_a_number: δεν είναι αριθμός\n      not_an_integer: πρέπει να είναι ακέραιος αριθμός\n      odd: πρέπει να είναι περιττός\n      other_than: πρέπει να είναι διάφορο του %{count}\n      present: πρέπει να είναι κενό\n      required: πρέπει να υπάρχει\n      taken: το έχουν ήδη χρησιμοποιήσει\n      too_long:\n        one: είναι πολύ μεγάλο (το μέγιστο μήκος είναι %{count} χαρακτήρας)\n        other: είναι πολύ μεγάλο (το μέγιστο μήκος είναι %{count} χαρακτήρες)\n      too_short:\n        one: είναι πολύ μικρό (το ελάχιστο μήκος είναι %{count} χαρακτήρας)\n        other: είναι πολύ μικρό (το ελάχιστο μήκος είναι %{count} χαρακτήρες)\n      wrong_length:\n        one: έχει λανθασμένο μήκος (πρέπει να είναι %{count} χαρακτήρας)\n        other: έχει λανθασμένο μήκος (πρέπει να είναι %{count} χαρακτήρες)\n    template:\n      body: 'Υπήρξαν προβλήματα με τα ακόλουθα πεδία:'\n      header:\n        one: \"%{count} λάθος εμπόδισε αυτό το %{model} να αποθηκευτεί.\"\n        other: \"%{count} λάθη εμπόδισαν αυτό το %{model} να αποθηκευτεί.\"\n  helpers:\n    select:\n      prompt: Παρακαλώ επιλέξτε\n    submit:\n      create: Δημιουργήστε %{model}\n      submit: Αποθηκεύστε %{model}\n      update: Ενημερώστε %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: δισεκατομμύριο\n          million: εκατομμύριο\n          quadrillion: τετράκις εκατομμύριο\n          thousand: χίλια\n          trillion: τρισεκατομμύριο\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" και \"\n      two_words_connector: \" και \"\n      words_connector: \", \"\n  time:\n    am: π.μ.\n    formats:\n      default: \"%d %B %Y %H:%M\"\n      long: \"%A %d %B %Y %H:%M:%S %Z\"\n      short: \"%d %b %H:%M\"\n    pm: μ.μ.\n"
  },
  {
    "path": "config/locales/defaults/el.yml",
    "content": "---\nel:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Η επικύρωση απέτυχε: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Η εγγραφή δεν μπορεί να διαγραφεί γιατί υπάρχουν εξαρτημένα %{record}\n          has_one: Η εγγραφή δεν μπορεί να διαγραφεί γιατί υπάρχει εξαρτημένο %{record}\n  date:\n    abbr_day_names:\n    - Κυρ\n    - Δευ\n    - Τρί\n    - Τετ\n    - Πέμ\n    - Παρ\n    - Σάβ\n    abbr_month_names:\n    -\n    - Ιαν\n    - Φεβ\n    - Μαρ\n    - Απρ\n    - Μαϊ\n    - Ιουν\n    - Ιουλ\n    - Αυγ\n    - Σεπ\n    - Οκτ\n    - Νοε\n    - Δεκ\n    day_names:\n    - Κυριακή\n    - Δευτέρα\n    - Τρίτη\n    - Τετάρτη\n    - Πέμπτη\n    - Παρασκευή\n    - Σάββατο\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%e %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Ιανουάριος\n    - Φεβρουάριος\n    - Μάρτιος\n    - Απρίλιος\n    - Μάιος\n    - Ιούνιος\n    - Ιούλιος\n    - Αύγουστος\n    - Σεπτέμβριος\n    - Οκτώβριος\n    - Νοέμβριος\n    - Δεκέμβριος\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: περίπου μία ώρα\n        other: περίπου %{count} ώρες\n      about_x_months:\n        one: περίπου ένα μήνα\n        other: περίπου %{count} μήνες\n      about_x_years:\n        one: περίπου ένα χρόνο\n        other: περίπου %{count} χρόνια\n      almost_x_years:\n        one: σχεδόν ένα χρόνο\n        other: σχεδόν %{count} χρόνια\n      half_a_minute: μισό λεπτό\n      less_than_x_minutes:\n        one: λιγότερο από ένα λεπτό\n        other: λιγότερο από %{count} λεπτά\n      less_than_x_seconds:\n        one: λιγότερο από ένα δευτερόλεπτο\n        other: λιγότερο από %{count} δευτερόλεπτα\n      over_x_years:\n        one: πάνω από ένα χρόνο\n        other: πάνω από %{count} χρόνια\n      x_days:\n        one: \"%{count} μέρα\"\n        other: \"%{count} ημέρες\"\n      x_minutes:\n        one: \"%{count} λεπτό\"\n        other: \"%{count} λεπτά\"\n      x_months:\n        one: \"%{count} μήνα\"\n        other: \"%{count} μήνες\"\n      x_seconds:\n        one: \"%{count} δευτερόλεπτο\"\n        other: \"%{count} δευτερόλεπτα\"\n      x_years:\n        one: \"%{count} χρόνος\"\n        other: \"%{count} χρόνια\"\n    prompts:\n      day: Ημέρα\n      hour: Ώρα\n      minute: Λεπτό\n      month: Μήνας\n      second: Δευτερόλεπτο\n      year: Έτος\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: πρέπει να είναι αποδεκτό\n      blank: δεν πρέπει να είναι κενό\n      confirmation: δεν ταιριάζει με την επικύρωση\n      empty: δεν πρέπει να είναι άδειο\n      equal_to: πρέπει να είναι ίσο με %{count}\n      even: πρέπει να είναι άρτιος\n      exclusion: είναι δεσμευμένο\n      greater_than: πρέπει να είναι μεγαλύτερο από %{count}\n      greater_than_or_equal_to: πρέπει να είναι μεγαλύτερο ή ίσο με %{count}\n      inclusion: δεν συμπεριλαμβάνεται στη λίστα\n      invalid: είναι άκυρο\n      less_than: πρέπει να είναι λιγότερο από %{count}\n      less_than_or_equal_to: πρέπει να είναι λιγότερο ή ίσο με %{count}\n      model_invalid: 'Η επικύρωση απέτυχε: %{errors}'\n      not_a_number: δεν είναι αριθμός\n      not_an_integer: πρέπει να είναι ακέραιος αριθμός\n      odd: πρέπει να είναι περιττός\n      other_than: πρέπει να είναι διάφορο του %{count}\n      present: πρέπει να είναι κενό\n      required: πρέπει να υπάρχει\n      taken: το έχουν ήδη χρησιμοποιήσει\n      too_long:\n        one: είναι πολύ μεγάλο (το μέγιστο μήκος είναι %{count} χαρακτήρας)\n        other: είναι πολύ μεγάλο (το μέγιστο μήκος είναι %{count} χαρακτήρες)\n      too_short:\n        one: είναι πολύ μικρό (το ελάχιστο μήκος είναι %{count} χαρακτήρας)\n        other: είναι πολύ μικρό (το ελάχιστο μήκος είναι %{count} χαρακτήρες)\n      wrong_length:\n        one: έχει λανθασμένο μήκος (πρέπει να είναι %{count} χαρακτήρας)\n        other: έχει λανθασμένο μήκος (πρέπει να είναι %{count} χαρακτήρες)\n    template:\n      body: 'Υπήρξαν προβλήματα με τα ακόλουθα πεδία:'\n      header:\n        one: \"%{count} λάθος εμπόδισε αυτό το %{model} να αποθηκευτεί.\"\n        other: \"%{count} λάθη εμπόδισαν αυτό το %{model} να αποθηκευτεί.\"\n  helpers:\n    select:\n      prompt: Παρακαλώ επιλέξτε\n    submit:\n      create: Δημιουργήστε %{model}\n      submit: Αποθηκεύστε %{model}\n      update: Ενημερώστε %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: δισεκατομμύριο\n          million: εκατομμύριο\n          quadrillion: τετράκις εκατομμύριο\n          thousand: χίλια\n          trillion: τρισεκατομμύριο\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" και \"\n      two_words_connector: \" και \"\n      words_connector: \", \"\n  time:\n    am: π.μ.\n    formats:\n      default: \"%d %B %Y %H:%M\"\n      long: \"%A %d %B %Y %H:%M:%S %Z\"\n      short: \"%d %b %H:%M\"\n    pm: μ.μ.\n"
  },
  {
    "path": "config/locales/defaults/en-AU.yml",
    "content": "---\nen-AU:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cannot delete record because dependent %{record} exist\n          has_one: Cannot delete record because a dependent %{record} exists\n  date:\n    abbr_day_names:\n    - Sun\n    - Mon\n    - Tue\n    - Wed\n    - Thu\n    - Fri\n    - Sat\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - Sunday\n    - Monday\n    - Tuesday\n    - Wednesday\n    - Thursday\n    - Friday\n    - Saturday\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B, %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - January\n    - February\n    - March\n    - April\n    - May\n    - June\n    - July\n    - August\n    - September\n    - October\n    - November\n    - December\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: about %{count} hour\n        other: about %{count} hours\n      about_x_months:\n        one: about %{count} month\n        other: about %{count} months\n      about_x_years:\n        one: about %{count} year\n        other: about %{count} years\n      almost_x_years:\n        one: almost %{count} year\n        other: almost %{count} years\n      half_a_minute: half a minute\n      less_than_x_minutes:\n        one: less than a minute\n        other: less than %{count} minutes\n      less_than_x_seconds:\n        one: less than %{count} second\n        other: less than %{count} seconds\n      over_x_years:\n        one: over %{count} year\n        other: over %{count} years\n      x_days:\n        one: \"%{count} day\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} month\"\n        other: \"%{count} months\"\n      x_seconds:\n        one: \"%{count} second\"\n        other: \"%{count} seconds\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: Day\n      hour: Hour\n      minute: Minute\n      month: Month\n      second: Second\n      year: Year\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: must be accepted\n      blank: can't be blank\n      confirmation: doesn't match %{attribute}\n      empty: can't be empty\n      equal_to: must be equal to %{count}\n      even: must be even\n      exclusion: is reserved\n      greater_than: must be greater than %{count}\n      greater_than_or_equal_to: must be greater than or equal to %{count}\n      inclusion: is not included in the list\n      invalid: is invalid\n      less_than: must be less than %{count}\n      less_than_or_equal_to: must be less than or equal to %{count}\n      model_invalid: 'Validation failed: %{errors}'\n      not_a_number: is not a number\n      not_an_integer: must be an integer\n      odd: must be odd\n      other_than: must be other than %{count}\n      present: must be blank\n      required: must exist\n      taken: has already been taken\n      too_long:\n        one: is too long (maximum is %{count} character)\n        other: is too long (maximum is %{count} characters)\n      too_short:\n        one: is too short (minimum is %{count} character)\n        other: is too short (minimum is %{count} characters)\n      wrong_length:\n        one: is the wrong length (should be %{count} character)\n        other: is the wrong length (should be %{count} characters)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} error prohibited this %{model} from being saved\"\n        other: \"%{count} errors prohibited this %{model} from being saved\"\n  helpers:\n    select:\n      prompt: Please select\n    submit:\n      create: Create %{model}\n      submit: Save %{model}\n      update: Update %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Billion\n          million: Million\n          quadrillion: Quadrillion\n          thousand: Thousand\n          trillion: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", and \"\n      two_words_connector: \" and \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B, %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/en-CA.yml",
    "content": "---\nen-CA:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cannot delete record because dependent %{record} exist\n          has_one: Cannot delete record because a dependent %{record} exists\n  date:\n    abbr_day_names:\n    - Sun\n    - Mon\n    - Tue\n    - Wed\n    - Thu\n    - Fri\n    - Sat\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - Sunday\n    - Monday\n    - Tuesday\n    - Wednesday\n    - Thursday\n    - Friday\n    - Saturday\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%B %d, %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - January\n    - February\n    - March\n    - April\n    - May\n    - June\n    - July\n    - August\n    - September\n    - October\n    - November\n    - December\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: about %{count} hour\n        other: about %{count} hours\n      about_x_months:\n        one: about %{count} month\n        other: about %{count} months\n      about_x_years:\n        one: about %{count} year\n        other: about %{count} years\n      almost_x_years:\n        one: almost %{count} year\n        other: almost %{count} years\n      half_a_minute: half a minute\n      less_than_x_minutes:\n        one: less than a minute\n        other: less than %{count} minutes\n      less_than_x_seconds:\n        one: less than %{count} second\n        other: less than %{count} seconds\n      over_x_years:\n        one: over %{count} year\n        other: over %{count} years\n      x_days:\n        one: \"%{count} day\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} month\"\n        other: \"%{count} months\"\n      x_seconds:\n        one: \"%{count} second\"\n        other: \"%{count} seconds\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: Day\n      hour: Hour\n      minute: Minute\n      month: Month\n      second: Second\n      year: Year\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: must be accepted\n      blank: can't be blank\n      confirmation: doesn't match %{attribute}\n      empty: can't be empty\n      equal_to: must be equal to %{count}\n      even: must be even\n      exclusion: is reserved\n      greater_than: must be greater than %{count}\n      greater_than_or_equal_to: must be greater than or equal to %{count}\n      inclusion: is not included in the list\n      invalid: is invalid\n      less_than: must be less than %{count}\n      less_than_or_equal_to: must be less than or equal to %{count}\n      model_invalid: 'Validation failed: %{errors}'\n      not_a_number: is not a number\n      not_an_integer: must be an integer\n      odd: must be odd\n      other_than: must be other than %{count}\n      present: must be blank\n      required: must exist\n      taken: has already been taken\n      too_long:\n        one: is too long (maximum is %{count} character)\n        other: is too long (maximum is %{count} characters)\n      too_short:\n        one: is too short (minimum is %{count} character)\n        other: is too short (minimum is %{count} characters)\n      wrong_length:\n        one: is the wrong length (should be %{count} character)\n        other: is the wrong length (should be %{count} characters)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} error prohibited this %{model} from being saved\"\n        other: \"%{count} errors prohibited this %{model} from being saved\"\n  helpers:\n    select:\n      prompt: Please select\n    submit:\n      create: Create %{model}\n      submit: Save %{model}\n      update: Update %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Billion\n          million: Million\n          quadrillion: Quadrillion\n          thousand: Thousand\n          trillion: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", and \"\n      two_words_connector: \" and \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %I:%M:%S %p %Z\"\n      long: \"%B %d, %Y %I:%M %p\"\n      short: \"%d %b %I:%M %p\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/en-CY.yml",
    "content": "---\nen-CY:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cannot delete record because dependent %{record} exist\n          has_one: Cannot delete record because a dependent %{record} exists\n  date:\n    abbr_day_names:\n    - Sun\n    - Mon\n    - Tue\n    - Wed\n    - Thu\n    - Fri\n    - Sat\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - Sunday\n    - Monday\n    - Tuesday\n    - Wednesday\n    - Thursday\n    - Friday\n    - Saturday\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B, %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - January\n    - February\n    - March\n    - April\n    - May\n    - June\n    - July\n    - August\n    - September\n    - October\n    - November\n    - December\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: about %{count} hour\n        other: about %{count} hours\n      about_x_months:\n        one: about %{count} month\n        other: about %{count} months\n      about_x_years:\n        one: about %{count} year\n        other: about %{count} years\n      almost_x_years:\n        one: almost %{count} year\n        other: almost %{count} years\n      half_a_minute: half a minute\n      less_than_x_minutes:\n        one: less than a minute\n        other: less than %{count} minutes\n      less_than_x_seconds:\n        one: less than %{count} second\n        other: less than %{count} seconds\n      over_x_years:\n        one: over %{count} year\n        other: over %{count} years\n      x_days:\n        one: \"%{count} day\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} month\"\n        other: \"%{count} months\"\n      x_seconds:\n        one: \"%{count} second\"\n        other: \"%{count} seconds\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: Day\n      hour: Hour\n      minute: Minute\n      month: Month\n      second: Second\n      year: Year\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: must be accepted\n      blank: can't be blank\n      confirmation: doesn't match %{attribute}\n      empty: can't be empty\n      equal_to: must be equal to %{count}\n      even: must be even\n      exclusion: is reserved\n      greater_than: must be greater than %{count}\n      greater_than_or_equal_to: must be greater than or equal to %{count}\n      inclusion: is not included in the list\n      invalid: is invalid\n      less_than: must be less than %{count}\n      less_than_or_equal_to: must be less than or equal to %{count}\n      model_invalid: 'Validation failed: %{errors}'\n      not_a_number: is not a number\n      not_an_integer: must be an integer\n      odd: must be odd\n      other_than: must be other than %{count}\n      present: must be blank\n      required: must exist\n      taken: has already been taken\n      too_long:\n        one: is too long (maximum is %{count} character)\n        other: is too long (maximum is %{count} characters)\n      too_short:\n        one: is too short (minimum is %{count} character)\n        other: is too short (minimum is %{count} characters)\n      wrong_length:\n        one: is the wrong length (should be %{count} character)\n        other: is the wrong length (should be %{count} characters)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} error prohibited this %{model} from being saved\"\n        other: \"%{count} errors prohibited this %{model} from being saved\"\n  helpers:\n    select:\n      prompt: Please select\n    submit:\n      create: Create %{model}\n      submit: Save %{model}\n      update: Update %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Billion\n          million: Million\n          quadrillion: Quadrillion\n          thousand: Thousand\n          trillion: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", and \"\n      two_words_connector: \" and \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B, %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/en-GB.yml",
    "content": "---\nen-GB:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cannot delete record because dependent %{record} exist\n          has_one: Cannot delete record because a dependent %{record} exists\n  date:\n    abbr_day_names:\n    - Sun\n    - Mon\n    - Tue\n    - Wed\n    - Thu\n    - Fri\n    - Sat\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - Sunday\n    - Monday\n    - Tuesday\n    - Wednesday\n    - Thursday\n    - Friday\n    - Saturday\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B, %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - January\n    - February\n    - March\n    - April\n    - May\n    - June\n    - July\n    - August\n    - September\n    - October\n    - November\n    - December\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: about %{count} hour\n        other: about %{count} hours\n      about_x_months:\n        one: about %{count} month\n        other: about %{count} months\n      about_x_years:\n        one: about %{count} year\n        other: about %{count} years\n      almost_x_years:\n        one: almost %{count} year\n        other: almost %{count} years\n      half_a_minute: half a minute\n      less_than_x_minutes:\n        one: less than a minute\n        other: less than %{count} minutes\n      less_than_x_seconds:\n        one: less than %{count} second\n        other: less than %{count} seconds\n      over_x_years:\n        one: over %{count} year\n        other: over %{count} years\n      x_days:\n        one: \"%{count} day\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} month\"\n        other: \"%{count} months\"\n      x_seconds:\n        one: \"%{count} second\"\n        other: \"%{count} seconds\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: Day\n      hour: Hour\n      minute: Minute\n      month: Month\n      second: Second\n      year: Year\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: must be accepted\n      blank: can't be blank\n      confirmation: doesn't match %{attribute}\n      empty: can't be empty\n      equal_to: must be equal to %{count}\n      even: must be even\n      exclusion: is reserved\n      greater_than: must be greater than %{count}\n      greater_than_or_equal_to: must be greater than or equal to %{count}\n      inclusion: is not included in the list\n      invalid: is invalid\n      less_than: must be less than %{count}\n      less_than_or_equal_to: must be less than or equal to %{count}\n      model_invalid: 'Validation failed: %{errors}'\n      not_a_number: is not a number\n      not_an_integer: must be an integer\n      odd: must be odd\n      other_than: must be other than %{count}\n      present: must be blank\n      required: must exist\n      taken: has already been taken\n      too_long:\n        one: is too long (maximum is %{count} character)\n        other: is too long (maximum is %{count} characters)\n      too_short:\n        one: is too short (minimum is %{count} character)\n        other: is too short (minimum is %{count} characters)\n      wrong_length:\n        one: is the wrong length (should be %{count} character)\n        other: is the wrong length (should be %{count} characters)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} error prohibited this %{model} from being saved\"\n        other: \"%{count} errors prohibited this %{model} from being saved\"\n  helpers:\n    select:\n      prompt: Please select\n    submit:\n      create: Create %{model}\n      submit: Save %{model}\n      update: Update %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"£\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Billion\n          million: Million\n          quadrillion: Quadrillion\n          thousand: Thousand\n          trillion: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", and \"\n      two_words_connector: \" and \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B, %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/en-IE.yml",
    "content": "---\nen-IE:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cannot delete record because dependent %{record} exist\n          has_one: Cannot delete record because a dependent %{record} exists\n  date:\n    abbr_day_names:\n    - Sun\n    - Mon\n    - Tue\n    - Wed\n    - Thu\n    - Fri\n    - Sat\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - Sunday\n    - Monday\n    - Tuesday\n    - Wednesday\n    - Thursday\n    - Friday\n    - Saturday\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B, %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - January\n    - February\n    - March\n    - April\n    - May\n    - June\n    - July\n    - August\n    - September\n    - October\n    - November\n    - December\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: about %{count} hour\n        other: about %{count} hours\n      about_x_months:\n        one: about %{count} month\n        other: about %{count} months\n      about_x_years:\n        one: about %{count} year\n        other: about %{count} years\n      almost_x_years:\n        one: almost %{count} year\n        other: almost %{count} years\n      half_a_minute: half a minute\n      less_than_x_minutes:\n        one: less than a minute\n        other: less than %{count} minutes\n      less_than_x_seconds:\n        one: less than %{count} second\n        other: less than %{count} seconds\n      over_x_years:\n        one: over %{count} year\n        other: over %{count} years\n      x_days:\n        one: \"%{count} day\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} month\"\n        other: \"%{count} months\"\n      x_seconds:\n        one: \"%{count} second\"\n        other: \"%{count} seconds\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: Day\n      hour: Hour\n      minute: Minute\n      month: Month\n      second: Second\n      year: Year\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: must be accepted\n      blank: can't be blank\n      confirmation: doesn't match %{attribute}\n      empty: can't be empty\n      equal_to: must be equal to %{count}\n      even: must be even\n      exclusion: is reserved\n      greater_than: must be greater than %{count}\n      greater_than_or_equal_to: must be greater than or equal to %{count}\n      inclusion: is not included in the list\n      invalid: is invalid\n      less_than: must be less than %{count}\n      less_than_or_equal_to: must be less than or equal to %{count}\n      model_invalid: 'Validation failed: %{errors}'\n      not_a_number: is not a number\n      not_an_integer: must be an integer\n      odd: must be odd\n      other_than: must be other than %{count}\n      present: must be blank\n      required: must exist\n      taken: has already been taken\n      too_long:\n        one: is too long (maximum is %{count} character)\n        other: is too long (maximum is %{count} characters)\n      too_short:\n        one: is too short (minimum is %{count} character)\n        other: is too short (minimum is %{count} characters)\n      wrong_length:\n        one: is the wrong length (should be %{count} character)\n        other: is the wrong length (should be %{count} characters)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} error prohibited this %{model} from being saved\"\n        other: \"%{count} errors prohibited this %{model} from being saved\"\n  helpers:\n    select:\n      prompt: Please select\n    submit:\n      create: Create %{model}\n      submit: Save %{model}\n      update: Update %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Billion\n          million: Million\n          quadrillion: Quadrillion\n          thousand: Thousand\n          trillion: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", and \"\n      two_words_connector: \" and \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B, %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/en-IN.yml",
    "content": "---\nen-IN:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cannot delete record because dependent %{record} exist\n          has_one: Cannot delete record because a dependent %{record} exists\n  date:\n    abbr_day_names:\n    - Sun\n    - Mon\n    - Tue\n    - Wed\n    - Thu\n    - Fri\n    - Sat\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - Sunday\n    - Monday\n    - Tuesday\n    - Wednesday\n    - Thursday\n    - Friday\n    - Saturday\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - January\n    - February\n    - March\n    - April\n    - May\n    - June\n    - July\n    - August\n    - September\n    - October\n    - November\n    - December\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: about %{count} hour\n        other: about %{count} hours\n      about_x_months:\n        one: about %{count} month\n        other: about %{count} months\n      about_x_years:\n        one: about %{count} year\n        other: about %{count} years\n      almost_x_years:\n        one: almost %{count} year\n        other: almost %{count} years\n      half_a_minute: half a minute\n      less_than_x_minutes:\n        one: less than a minute\n        other: less than %{count} minutes\n      less_than_x_seconds:\n        one: less than %{count} second\n        other: less than %{count} seconds\n      over_x_years:\n        one: over %{count} year\n        other: over %{count} years\n      x_days:\n        one: \"%{count} day\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} month\"\n        other: \"%{count} months\"\n      x_seconds:\n        one: \"%{count} second\"\n        other: \"%{count} seconds\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: Day\n      hour: Hour\n      minute: Minute\n      month: Month\n      second: Second\n      year: Year\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: must be accepted\n      blank: can't be blank\n      confirmation: doesn't match %{attribute}\n      empty: can't be empty\n      equal_to: must be equal to %{count}\n      even: must be even\n      exclusion: is reserved\n      greater_than: must be greater than %{count}\n      greater_than_or_equal_to: must be greater than or equal to %{count}\n      inclusion: is not included in the list\n      invalid: is invalid\n      less_than: must be less than %{count}\n      less_than_or_equal_to: must be less than or equal to %{count}\n      model_invalid: 'Validation failed: %{errors}'\n      not_a_number: is not a number\n      not_an_integer: must be an integer\n      odd: must be odd\n      other_than: must be other than %{count}\n      present: must be blank\n      required: must exist\n      taken: has already been taken\n      too_long:\n        one: is too long (maximum is %{count} character)\n        other: is too long (maximum is %{count} characters)\n      too_short:\n        one: is too short (minimum is %{count} character)\n        other: is too short (minimum is %{count} characters)\n      wrong_length:\n        one: is the wrong length (should be %{count} character)\n        other: is the wrong length (should be %{count} characters)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} error prohibited this %{model} from being saved\"\n        other: \"%{count} errors prohibited this %{model} from being saved\"\n  helpers:\n    select:\n      prompt: Please select\n    submit:\n      create: Create %{model}\n      submit: Save %{model}\n      update: Update %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"₹\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Billion\n          million: Million\n          quadrillion: Quadrillion\n          thousand: Thousand\n          trillion: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", and \"\n      two_words_connector: \" and \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/en-NZ.yml",
    "content": "---\nen-NZ:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cannot delete record because dependent %{record} exist\n          has_one: Cannot delete record because a dependent %{record} exists\n  date:\n    abbr_day_names:\n    - Sun\n    - Mon\n    - Tue\n    - Wed\n    - Thu\n    - Fri\n    - Sat\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - Sunday\n    - Monday\n    - Tuesday\n    - Wednesday\n    - Thursday\n    - Friday\n    - Saturday\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B, %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - January\n    - February\n    - March\n    - April\n    - May\n    - June\n    - July\n    - August\n    - September\n    - October\n    - November\n    - December\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: about %{count} hour\n        other: about %{count} hours\n      about_x_months:\n        one: about %{count} month\n        other: about %{count} months\n      about_x_years:\n        one: about %{count} year\n        other: about %{count} years\n      almost_x_years:\n        one: almost %{count} year\n        other: almost %{count} years\n      half_a_minute: half a minute\n      less_than_x_minutes:\n        one: less than a minute\n        other: less than %{count} minutes\n      less_than_x_seconds:\n        one: less than %{count} second\n        other: less than %{count} seconds\n      over_x_years:\n        one: over %{count} year\n        other: over %{count} years\n      x_days:\n        one: \"%{count} day\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} month\"\n        other: \"%{count} months\"\n      x_seconds:\n        one: \"%{count} second\"\n        other: \"%{count} seconds\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: Day\n      hour: Hour\n      minute: Minute\n      month: Month\n      second: Second\n      year: Year\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: must be accepted\n      blank: can't be blank\n      confirmation: doesn't match %{attribute}\n      empty: can't be empty\n      equal_to: must be equal to %{count}\n      even: must be even\n      exclusion: is reserved\n      greater_than: must be greater than %{count}\n      greater_than_or_equal_to: must be greater than or equal to %{count}\n      inclusion: is not included in the list\n      invalid: is invalid\n      less_than: must be less than %{count}\n      less_than_or_equal_to: must be less than or equal to %{count}\n      model_invalid: 'Validation failed: %{errors}'\n      not_a_number: is not a number\n      not_an_integer: must be an integer\n      odd: must be odd\n      other_than: must be other than %{count}\n      present: must be blank\n      required: must exist\n      taken: has already been taken\n      too_long:\n        one: is too long (maximum is %{count} character)\n        other: is too long (maximum is %{count} characters)\n      too_short:\n        one: is too short (minimum is %{count} character)\n        other: is too short (minimum is %{count} characters)\n      wrong_length:\n        one: is the wrong length (should be %{count} character)\n        other: is the wrong length (should be %{count} characters)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} error prohibited this %{model} from being saved\"\n        other: \"%{count} errors prohibited this %{model} from being saved\"\n  helpers:\n    select:\n      prompt: Please select\n    submit:\n      create: Create %{model}\n      submit: Save %{model}\n      update: Update %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Billion\n          million: Million\n          quadrillion: Quadrillion\n          thousand: Thousand\n          trillion: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", and \"\n      two_words_connector: \" and \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B, %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/en-TT.yml",
    "content": "---\nen-TT:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cannot delete record because dependent %{record} exist\n          has_one: Cannot delete record because a dependent %{record} exists\n  date:\n    abbr_day_names:\n    - Sun\n    - Mon\n    - Tue\n    - Wed\n    - Thu\n    - Fri\n    - Sat\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - Sunday\n    - Monday\n    - Tuesday\n    - Wednesday\n    - Thursday\n    - Friday\n    - Saturday\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - January\n    - February\n    - March\n    - April\n    - May\n    - June\n    - July\n    - August\n    - September\n    - October\n    - November\n    - December\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: about %{count} hour\n        other: about %{count} hours\n      about_x_months:\n        one: about %{count} month\n        other: about %{count} months\n      about_x_years:\n        one: about %{count} year\n        other: about %{count} years\n      almost_x_years:\n        one: almost %{count} year\n        other: almost %{count} years\n      half_a_minute: half a minute\n      less_than_x_minutes:\n        one: less than a minute\n        other: less than %{count} minutes\n      less_than_x_seconds:\n        one: less than %{count} second\n        other: less than %{count} seconds\n      over_x_years:\n        one: over %{count} year\n        other: over %{count} years\n      x_days:\n        one: \"%{count} day\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} month\"\n        other: \"%{count} months\"\n      x_seconds:\n        one: \"%{count} second\"\n        other: \"%{count} seconds\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: Day\n      hour: Hour\n      minute: Minute\n      month: Month\n      second: Seconds\n      year: Year\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: must be accepted\n      blank: can't be blank\n      confirmation: doesn't match %{attribute}\n      empty: can't be empty\n      equal_to: must be equal to %{count}\n      even: must be even\n      exclusion: is reserved\n      greater_than: must be greater than %{count}\n      greater_than_or_equal_to: must be greater than or equal to %{count}\n      inclusion: is not included in the list\n      invalid: is invalid\n      less_than: must be less than %{count}\n      less_than_or_equal_to: must be less than or equal to %{count}\n      model_invalid: 'Validation failed: %{errors}'\n      not_a_number: is not a number\n      not_an_integer: must be an integer\n      odd: must be odd\n      other_than: must be other than %{count}\n      present: must be blank\n      required: must exist\n      taken: has already been taken\n      too_long:\n        one: is too long (maximum is %{count} character)\n        other: is too long (maximum is %{count} characters)\n      too_short:\n        one: is too short (minimum is %{count} character)\n        other: is too short (minimum is %{count} characters)\n      wrong_length:\n        one: is the wrong length (should be %{count} character)\n        other: is the wrong length (should be %{count} characters)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} error prohibited this %{model} from being saved\"\n        other: \"%{count} errors prohibited this %{model} from being saved\"\n  helpers:\n    select:\n      prompt: Please select\n    submit:\n      create: Create %{model}\n      submit: Save %{model}\n      update: Update %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: TT$\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Billion\n          million: Million\n          quadrillion: Quadrillion\n          thousand: Thousand\n          trillion: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", and \"\n      two_words_connector: \" and \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/en-US.yml",
    "content": "---\nen-US:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cannot delete record because dependent %{record} exist\n          has_one: Cannot delete record because a dependent %{record} exists\n  date:\n    abbr_day_names:\n    - Sun\n    - Mon\n    - Tue\n    - Wed\n    - Thu\n    - Fri\n    - Sat\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - Sunday\n    - Monday\n    - Tuesday\n    - Wednesday\n    - Thursday\n    - Friday\n    - Saturday\n    formats:\n      default: \"%m-%d-%Y\"\n      long: \"%B %d, %Y\"\n      short: \"%b %d\"\n    month_names:\n    -\n    - January\n    - February\n    - March\n    - April\n    - May\n    - June\n    - July\n    - August\n    - September\n    - October\n    - November\n    - December\n    order:\n    - :month\n    - :day\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: about %{count} hour\n        other: about %{count} hours\n      about_x_months:\n        one: about %{count} month\n        other: about %{count} months\n      about_x_years:\n        one: about %{count} year\n        other: about %{count} years\n      almost_x_years:\n        one: almost %{count} year\n        other: almost %{count} years\n      half_a_minute: half a minute\n      less_than_x_minutes:\n        one: less than a minute\n        other: less than %{count} minutes\n      less_than_x_seconds:\n        one: less than %{count} second\n        other: less than %{count} seconds\n      over_x_years:\n        one: over %{count} year\n        other: over %{count} years\n      x_days:\n        one: \"%{count} day\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} month\"\n        other: \"%{count} months\"\n      x_seconds:\n        one: \"%{count} second\"\n        other: \"%{count} seconds\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: Day\n      hour: Hour\n      minute: Minute\n      month: Month\n      second: Second\n      year: Year\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: must be accepted\n      blank: can't be blank\n      confirmation: doesn't match %{attribute}\n      empty: can't be empty\n      equal_to: must be equal to %{count}\n      even: must be even\n      exclusion: is reserved\n      greater_than: must be greater than %{count}\n      greater_than_or_equal_to: must be greater than or equal to %{count}\n      in: must be in %{count}\n      inclusion: is not included in the list\n      invalid: is invalid\n      less_than: must be less than %{count}\n      less_than_or_equal_to: must be less than or equal to %{count}\n      model_invalid: 'Validation failed: %{errors}'\n      not_a_number: is not a number\n      not_an_integer: must be an integer\n      odd: must be odd\n      other_than: must be other than %{count}\n      present: must be blank\n      required: must exist\n      taken: has already been taken\n      too_long:\n        one: is too long (maximum is %{count} character)\n        other: is too long (maximum is %{count} characters)\n      too_short:\n        one: is too short (minimum is %{count} character)\n        other: is too short (minimum is %{count} characters)\n      wrong_length:\n        one: is the wrong length (should be %{count} character)\n        other: is the wrong length (should be %{count} characters)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} error prohibited this %{model} from being saved\"\n        other: \"%{count} errors prohibited this %{model} from being saved\"\n  helpers:\n    select:\n      prompt: Please select\n    submit:\n      create: Create %{model}\n      submit: Save %{model}\n      update: Update %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Billion\n          million: Million\n          quadrillion: Quadrillion\n          thousand: Thousand\n          trillion: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", and \"\n      two_words_connector: \" and \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %I:%M:%S %p %Z\"\n      long: \"%B %d, %Y %I:%M %p\"\n      short: \"%d %b %I:%M %p\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/en-ZA.yml",
    "content": "---\nen-ZA:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cannot delete record because dependent %{record} exist\n          has_one: Cannot delete record because a dependent %{record} exists\n  date:\n    abbr_day_names:\n    - Sun\n    - Mon\n    - Tue\n    - Wed\n    - Thu\n    - Fri\n    - Sat\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - Sunday\n    - Monday\n    - Tuesday\n    - Wednesday\n    - Thursday\n    - Friday\n    - Saturday\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - January\n    - February\n    - March\n    - April\n    - May\n    - June\n    - July\n    - August\n    - September\n    - October\n    - November\n    - December\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: about %{count} hour\n        other: about %{count} hours\n      about_x_months:\n        one: about %{count} month\n        other: about %{count} months\n      about_x_years:\n        one: about %{count} year\n        other: about %{count} years\n      almost_x_years:\n        one: almost %{count} year\n        other: almost %{count} years\n      half_a_minute: half a minute\n      less_than_x_minutes:\n        one: less than a minute\n        other: less than %{count} minutes\n      less_than_x_seconds:\n        one: less than %{count} second\n        other: less than %{count} seconds\n      over_x_years:\n        one: over %{count} year\n        other: over %{count} years\n      x_days:\n        one: \"%{count} day\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} month\"\n        other: \"%{count} months\"\n      x_seconds:\n        one: \"%{count} second\"\n        other: \"%{count} seconds\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: Day\n      hour: Hour\n      minute: Minute\n      month: Month\n      second: Second\n      year: Year\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: must be accepted\n      blank: can't be blank\n      confirmation: doesn't match %{attribute}\n      empty: can't be empty\n      equal_to: must be equal to %{count}\n      even: must be even\n      exclusion: is reserved\n      greater_than: must be greater than %{count}\n      greater_than_or_equal_to: must be greater than or equal to %{count}\n      inclusion: is not included in the list\n      invalid: is invalid\n      less_than: must be less than %{count}\n      less_than_or_equal_to: must be less than or equal to %{count}\n      model_invalid: 'Validation failed: %{errors}'\n      not_a_number: is not a number\n      not_an_integer: must be an integer\n      odd: must be odd\n      other_than: must be other than %{count}\n      present: must be blank\n      required: must exist\n      taken: has already been taken\n      too_long:\n        one: is too long (maximum is %{count} character)\n        other: is too long (maximum is %{count} characters)\n      too_short:\n        one: is too short (minimum is %{count} character)\n        other: is too short (minimum is %{count} characters)\n      wrong_length:\n        one: is the wrong length (should be %{count} character)\n        other: is the wrong length (should be %{count} characters)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} error prohibited this %{model} from being saved\"\n        other: \"%{count} errors prohibited this %{model} from being saved\"\n  helpers:\n    select:\n      prompt: Please select\n    submit:\n      create: Create %{model}\n      submit: Save %{model}\n      update: Update %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%u %n\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: R\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Billion\n          million: Million\n          quadrillion: Quadrillion\n          thousand: Thousand\n          trillion: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", and \"\n      two_words_connector: \" and \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/en.yml",
    "content": "---\nen:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cannot delete record because dependent %{record} exist\n          has_one: Cannot delete record because a dependent %{record} exists\n  date:\n    abbr_day_names:\n    - Sun\n    - Mon\n    - Tue\n    - Wed\n    - Thu\n    - Fri\n    - Sat\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - Sunday\n    - Monday\n    - Tuesday\n    - Wednesday\n    - Thursday\n    - Friday\n    - Saturday\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%B %d, %Y\"\n      short: \"%b %d\"\n    month_names:\n    -\n    - January\n    - February\n    - March\n    - April\n    - May\n    - June\n    - July\n    - August\n    - September\n    - October\n    - November\n    - December\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: about %{count} hour\n        other: about %{count} hours\n      about_x_months:\n        one: about %{count} month\n        other: about %{count} months\n      about_x_years:\n        one: about %{count} year\n        other: about %{count} years\n      almost_x_years:\n        one: almost %{count} year\n        other: almost %{count} years\n      half_a_minute: half a minute\n      less_than_x_minutes:\n        one: less than a minute\n        other: less than %{count} minutes\n      less_than_x_seconds:\n        one: less than %{count} second\n        other: less than %{count} seconds\n      over_x_years:\n        one: over %{count} year\n        other: over %{count} years\n      x_days:\n        one: \"%{count} day\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} month\"\n        other: \"%{count} months\"\n      x_seconds:\n        one: \"%{count} second\"\n        other: \"%{count} seconds\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: Day\n      hour: Hour\n      minute: Minute\n      month: Month\n      second: Second\n      year: Year\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: must be accepted\n      blank: can't be blank\n      confirmation: doesn't match %{attribute}\n      empty: can't be empty\n      equal_to: must be equal to %{count}\n      even: must be even\n      exclusion: is reserved\n      greater_than: must be greater than %{count}\n      greater_than_or_equal_to: must be greater than or equal to %{count}\n      in: must be in %{count}\n      inclusion: is not included in the list\n      invalid: is invalid\n      less_than: must be less than %{count}\n      less_than_or_equal_to: must be less than or equal to %{count}\n      model_invalid: 'Validation failed: %{errors}'\n      not_a_number: is not a number\n      not_an_integer: must be an integer\n      odd: must be odd\n      other_than: must be other than %{count}\n      present: must be blank\n      required: must exist\n      taken: has already been taken\n      too_long:\n        one: is too long (maximum is %{count} character)\n        other: is too long (maximum is %{count} characters)\n      too_short:\n        one: is too short (minimum is %{count} character)\n        other: is too short (minimum is %{count} characters)\n      wrong_length:\n        one: is the wrong length (should be %{count} character)\n        other: is the wrong length (should be %{count} characters)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} error prohibited this %{model} from being saved\"\n        other: \"%{count} errors prohibited this %{model} from being saved\"\n  helpers:\n    select:\n      prompt: Please select\n    submit:\n      create: Create %{model}\n      submit: Save %{model}\n      update: Update %{model}\n  number:\n    currency:\n      format:\n        significant: false\n        strip_insignificant_zeros: false\n    format:\n      delimiter: \",\"\n      precision: 3\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Billion\n          million:\n            one: Million\n            other: Million\n          quadrillion: Quadrillion\n          thousand: Thousand\n          trillion:\n            one: Trillion\n            other: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", and \"\n      two_words_connector: \" and \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%B %d, %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/eo.yml",
    "content": "---\neo:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validado malsukcesis: %{errors}'\n  date:\n    abbr_day_names:\n    - dim\n    - lun\n    - mar\n    - mer\n    - ĵaŭ\n    - ven\n    - sab\n    abbr_month_names:\n    -\n    - jan.\n    - feb.\n    - mar.\n    - apr.\n    - majo\n    - jun.\n    - jul.\n    - aŭg.\n    - sep.\n    - okt.\n    - nov.\n    - dec.\n    day_names:\n    - dimanĉo\n    - lundo\n    - mardo\n    - merkredo\n    - ĵaŭdo\n    - vendredo\n    - sabato\n    formats:\n      default: \"%Y/%m/%d\"\n      long: \"%e %B %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - januaro\n    - februaro\n    - marto\n    - aprilo\n    - majo\n    - junio\n    - julio\n    - aŭgusto\n    - septembro\n    - oktobro\n    - novembro\n    - decembro\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ĉirkaŭ unu horo\n        other: ĉirkaŭ %{count} horoj\n      about_x_months:\n        one: ĉirkaŭ unu monato\n        other: ĉirkaŭ %{count} monatoj\n      about_x_years:\n        one: ĉirkaŭ uno jaro\n        other: ĉirkaŭ %{count} jaroj\n      almost_x_years:\n        one: preskaŭ unu jaro\n        other: preskaŭ %{count} jaroj\n      half_a_minute: duona minuto\n      less_than_x_minutes:\n        one: malpli ol unu minuto\n        other: malpli ol %{count} minutoj\n        zero: malpli ol unu minuto\n      less_than_x_seconds:\n        one: malpli ol unu sekundo\n        other: malpli ol %{count} sekundoj\n        zero: malpli ol unu sekundo\n      over_x_years:\n        one: pli ol unu jaro\n        other: pli ol %{count} jaroj\n      x_days:\n        one: \"%{count} tago\"\n        other: \"%{count} tagoj\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutoj\"\n      x_months:\n        one: \"%{count} monato\"\n        other: \"%{count} monatoj\"\n      x_seconds:\n        one: \"%{count} sekundo\"\n        other: \"%{count} sekundoj\"\n    prompts:\n      day: Tago\n      hour: Horo\n      minute: Minuto\n      month: Monato\n      second: Sekundo\n      year: Jaro\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: devas esti akceptita\n      blank: devas esti kompletigita\n      confirmation: ne kongruas kun la konfirmo\n      empty: devas esti kompletigita\n      equal_to: devas egali %{count}\n      even: devas esti para\n      exclusion: ne estas disponebla\n      greater_than: devas superi %{count}\n      greater_than_or_equal_to: devas superi aŭ egali %{count}\n      inclusion: ne estas inkluzivita de la listo\n      invalid: estas nevalida\n      less_than: devas malsuperi %{count}\n      less_than_or_equal_to: devas malsuperi aŭ egali %{count}\n      not_a_number: ne estas nombro\n      not_an_integer: devas esti entjero\n      odd: devas esti nepara\n      taken: ne estas disponebla\n      too_long: estas tro longa (maksimume %{count} karekteroj)\n      too_short: estas tro mallonga (minimume %{count} karakteroj)\n      wrong_length: ne estas je ĝusta longo (devas enhavi %{count} karakterojn)\n    template:\n      body: 'Kontrolu la jenajn kampojn: '\n      header:\n        one: 'Ne eblas registri tiun %{model}: %{count} eraro'\n        other: 'Ne eblas registri tiun %{model}: %{count} eraroj'\n  helpers:\n    select:\n      prompt: Bonvolu elekti\n    submit:\n      create: Krei %{model}\n      submit: Registri tiun %{model}\n      update: Modifi tiun %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: miliardo\n          million: miliono\n          quadrillion: miliono da miliardoj\n          thousand: mil\n          trillion: mil miliardoj\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: bitoko\n            other: bitokoj\n          gb: Gb\n          kb: kb\n          mb: Mb\n          tb: Tb\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" kaj \"\n      two_words_connector: \" kaj \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%d %B %Y %H:%M:%S\"\n      long: \"%A %d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-419.yml",
    "content": "---\nes-419:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un(a) %{record}\n            dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%A, %d de %B de %Y\"\n      short: \"%d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: cerca de %{count} hora\n        other: cerca de %{count} horas\n      about_x_months:\n        one: cerca de %{count} mes\n        other: cerca de %{count} meses\n      about_x_years:\n        one: cerca de %{count} año\n        other: cerca de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser un número par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor o igual que %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: es inválido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor o igual que %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser un número non\n      other_than: debe ser diferente de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya ha sido tomado\n      too_long:\n        one: es demasiado largo (máximo %{count} carácter)\n        other: es demasiado largo (máximo %{count} caracteres)\n      too_short:\n        one: es demasiado corto (mínimo %{count} carácter)\n        other: es demasiado corto (mínimo %{count} caracteres)\n      wrong_length:\n        one: longitud errónea (debe ser de %{count} carácter)\n        other: longitud errónea (debe ser de %{count} caracteres)\n    template:\n      body: 'Revise que los siguientes campos sean válidos:'\n      header:\n        one: \"%{model} no pudo guardarse debido a %{count} error\"\n        other: \"%{model} no pudo guardarse debido a %{count} errores\"\n  helpers:\n    select:\n      prompt: Por favor selecciona\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"¤\"\n    format:\n      delimiter: \",\"\n      precision: 2\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: \",\"\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \",\"\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: \",\"\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d de %b de %Y a las %H:%M:%S %Z\"\n      long: \"%A, %d de %B de %Y a las %I:%M %p\"\n      short: \"%d de %b a las %H:%M hrs\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-AR.yml",
    "content": "---\nes-AR:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un %{record} dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%A, %d de %B de %Y\"\n      short: \"%d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: cerca de %{count} hora\n        other: cerca de %{count} horas\n      about_x_months:\n        one: cerca de %{count} mes\n        other: cerca de %{count} meses\n      about_x_years:\n        one: cerca de %{count} año\n        other: cerca de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser un número par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor o igual que %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: es inválido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor o igual que %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser un número impar\n      other_than: debe ser distinto de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya ha sido tomado\n      too_long:\n        one: es demasiado largo (máximo %{count} carácter)\n        other: es demasiado largo (máximo %{count} caracteres)\n      too_short:\n        one: es demasiado corto (mínimo %{count} carácter)\n        other: es demasiado corto (mínimo %{count} caracteres)\n      wrong_length:\n        one: longitud errónea (debe ser de %{count} carácter)\n        other: longitud errónea (debe ser de %{count} caracteres)\n    template:\n      body: 'Revise que los siguientes campos sean válidos:'\n      header:\n        one: \"%{model} no pudo guardarse debido a %{count} error\"\n        other: \"%{model} no pudo guardarse debido a %{count} errores\"\n  helpers:\n    select:\n      prompt: Por favor selecciona\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: \",\"\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \",\"\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: \",\"\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d de %b de %Y a las %H:%M:%S %Z\"\n      long: \"%A, %d de %B de %Y a las %I:%M %p\"\n      short: \"%d de %b a las %H:%M hrs\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-CL.yml",
    "content": "---\nes-CL:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un %{record} dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%A %d de %B de %Y\"\n      short: \"%d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: alrededor de %{count} hora\n        other: alrededor de %{count} horas\n      about_x_months:\n        one: alrededor de %{count} mes\n        other: alrededor de %{count} meses\n      about_x_years:\n        one: alrededor de %{count} año\n        other: alrededor de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor que o igual a %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: no es válido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor que o igual a %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser impar\n      other_than: debe ser distinto de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya está en uso\n      too_long:\n        one: es demasiado largo (%{count} carácter máximo)\n        other: es demasiado largo (%{count} caracteres máximo)\n      too_short:\n        one: es demasiado corto (%{count} carácter mínimo)\n        other: es demasiado corto (%{count} caracteres mínimo)\n      wrong_length:\n        one: no tiene la longitud correcta (%{count} carácter exacto)\n        other: no tiene la longitud correcta (%{count} caracteres exactos)\n    template:\n      body: 'Se encontraron problemas con los siguientes campos:'\n      header:\n        one: No se pudo guardar este/a %{model} porque se encontró %{count} error\n        other: No se pudo guardar este/a %{model} porque se encontraron %{count} errores\n  helpers:\n    select:\n      prompt: Por favor seleccione\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%u %n\"\n        precision: 0\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \".\"\n      precision: 3\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A, %d de %B de %Y %H:%M:%S %z\"\n      long: \"%A %d de %B de %Y %H:%M\"\n      short: \"%d de %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-CO.yml",
    "content": "---\nes-CO:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un %{record} dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%A, %d de %B de %Y\"\n      short: \"%d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: cerca de %{count} hora\n        other: cerca de %{count} horas\n      about_x_months:\n        one: cerca de %{count} mes\n        other: cerca de %{count} meses\n      about_x_years:\n        one: cerca de %{count} año\n        other: cerca de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser un número par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor o igual que %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: es inválido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor o igual que %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser un número impar\n      other_than: debe ser distinto de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya ha sido tomado\n      too_long:\n        one: es demasiado largo (máximo %{count} carácter)\n        other: es demasiado largo (máximo %{count} caracteres)\n      too_short:\n        one: es demasiado corto (mínimo %{count} carácter)\n        other: es demasiado corto (mínimo %{count} caracteres)\n      wrong_length:\n        one: longitud errónea (debe ser de %{count} carácter)\n        other: longitud errónea (debe ser de %{count} caracteres)\n    template:\n      body: 'Revise que los siguientes campos sean válidos:'\n      header:\n        one: \"%{model} no pudo guardarse debido a %{count} error\"\n        other: \"%{model} no pudo guardarse debido a %{count} errores\"\n  helpers:\n    select:\n      prompt: Por favor selecciona\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%u%n\"\n        precision: 0\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d de %b de %Y a las %H:%M:%S %Z\"\n      long: \"%A, %d de %B de %Y a las %I:%M %p\"\n      short: \"%d de %b a las %H:%M hrs\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-CR.yml",
    "content": "---\nes-CR:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un %{record} dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%A, %d de %B de %Y\"\n      short: \"%d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: cerca de %{count} hora\n        other: cerca de %{count} horas\n      about_x_months:\n        one: cerca de %{count} mes\n        other: cerca de %{count} meses\n      about_x_years:\n        one: cerca de %{count} año\n        other: cerca de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser un número par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor o igual que %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: es inválido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor o igual que %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser un número impar\n      other_than: debe ser distinto de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya ha sido utilizado\n      too_long:\n        one: es demasiado largo (máximo %{count} carácter)\n        other: es demasiado largo (máximo %{count} caracteres)\n      too_short:\n        one: es demasiado corto (mínimo %{count} carácter)\n        other: es demasiado corto (mínimo %{count} caracteres)\n      wrong_length:\n        one: longitud errónea (debe ser de %{count} carácter)\n        other: longitud errónea (debe ser de %{count} caracteres)\n    template:\n      body: 'Revise que los siguientes campos sean válidos:'\n      header:\n        one: \"%{model} no pudo guardarse debido a %{count} error\"\n        other: \"%{model} no pudo guardarse debido a %{count} errores\"\n  helpers:\n    select:\n      prompt: Por favor seleccione\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 0\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"¢\"\n    format:\n      delimiter: \",\"\n      precision: 2\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: \",\"\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \",\"\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: \",\"\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d de %b de %Y a las %H:%M:%S %Z\"\n      long: \"%A, %d de %B de %Y a las %I:%M %p\"\n      short: \"%d de %b a las %H:%M hrs\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-EC.yml",
    "content": "---\nes-EC:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un(a) %{record}\n            dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%-d/%m/%Y\"\n      long: \"%A, %-d de %B de %Y\"\n      short: \"%-d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: cerca de %{count} hora\n        other: cerca de %{count} horas\n      about_x_months:\n        one: cerca de %{count} mes\n        other: cerca de %{count} meses\n      about_x_years:\n        one: cerca de %{count} año\n        other: cerca de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser un número par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor que o igual a %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: no es válido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor que o igual a %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser un número impar\n      other_than: debe ser diferente de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya está en uso\n      too_long:\n        one: es demasiado largo (máximo %{count} carácter)\n        other: es demasiado largo (máximo %{count} caracteres)\n      too_short:\n        one: es demasiado corto (mínimo %{count} carácter)\n        other: es demasiado corto (mínimo %{count} caracteres)\n      wrong_length:\n        one: no tiene la longitud correcta (debe ser de %{count} carácter)\n        other: no tiene la longitud correcta (debe ser de %{count} caracteres)\n    template:\n      body: 'Revise que los siguientes campos sean válidos:'\n      header:\n        one: No se pudo guardar este/a %{model} porque se encontró %{count} error\n        other: No se pudo guardar este/a %{model} porque se encontraron %{count} errores\n  helpers:\n    select:\n      prompt: Por favor seleccione\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A, %-d de %B de %Y a las %-I:%M:%S %p %Z\"\n      long: \"%-d de %B de %Y a las %-I:%M %p\"\n      short: \"%-d %b %-I:%M %p\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-ES.yml",
    "content": "---\nes-ES:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un %{record} dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%d de %B de %Y\"\n      short: \"%d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: alrededor de %{count} hora\n        other: alrededor de %{count} horas\n      about_x_months:\n        one: alrededor de %{count} mes\n        other: alrededor de %{count} meses\n      about_x_years:\n        one: alrededor de %{count} año\n        other: alrededor de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor que o igual a %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: no es válido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor que o igual a %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser impar\n      other_than: debe ser distinto de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya está en uso\n      too_long:\n        one: es demasiado largo (%{count} carácter máximo)\n        other: es demasiado largo (%{count} caracteres máximo)\n      too_short:\n        one: es demasiado corto (%{count} carácter mínimo)\n        other: es demasiado corto (%{count} caracteres mínimo)\n      wrong_length:\n        one: no tiene la longitud correcta (%{count} carácter exactos)\n        other: no tiene la longitud correcta (%{count} caracteres exactos)\n    template:\n      body: 'Se encontraron problemas con los siguientes campos:'\n      header:\n        one: No se pudo guardar este/a %{model} porque se encontró %{count} error\n        other: No se pudo guardar este/a %{model} porque se encontraron %{count} errores\n  helpers:\n    select:\n      prompt: Por favor seleccione\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 3\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n %\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A, %d de %B de %Y %H:%M:%S %z\"\n      long: \"%d de %B de %Y %H:%M\"\n      short: \"%d de %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-MX.yml",
    "content": "---\nes-MX:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: El registro no puede ser eliminado pues existen %{record} dependientes\n          has_one: El registro no puede ser eliminado pues existe un %{record} dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%A, %d de %B de %Y\"\n      short: \"%d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: cerca de %{count} hora\n        other: cerca de %{count} horas\n      about_x_months:\n        one: cerca de %{count} mes\n        other: cerca de %{count} meses\n      about_x_years:\n        one: cerca de %{count} año\n        other: cerca de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser un número par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor o igual que %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: es inválido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor o igual que %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser un número non\n      other_than: debe ser diferente a %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya ha sido tomado\n      too_long:\n        one: es demasiado largo (máximo %{count} carácter)\n        other: es demasiado largo (máximo %{count} caracteres)\n      too_short:\n        one: es demasiado corto (mínimo %{count} carácter)\n        other: es demasiado corto (mínimo %{count} caracteres)\n      wrong_length:\n        one: longitud errónea (debe ser de %{count} carácter)\n        other: longitud errónea (debe ser de %{count} caracteres)\n    template:\n      body: 'Revise que los siguientes campos sean válidos:'\n      header:\n        one: \"%{model} no pudo guardarse debido a %{count} error\"\n        other: \"%{model} no pudo guardarse debido a %{count} errores\"\n  helpers:\n    select:\n      prompt: Por favor selecciona\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: 2\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: \",\"\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \",\"\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: \",\"\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d de %b de %Y a las %H:%M:%S %Z\"\n      long: \"%A, %d de %B de %Y a las %I:%M %p\"\n      short: \"%d de %b a las %H:%M hrs\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-NI.yml",
    "content": "---\nes-NI:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un %{record} dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%A, %d de %B de %Y\"\n      short: \"%d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: cerca de %{count} hora\n        other: cerca de %{count} horas\n      about_x_months:\n        one: cerca de %{count} mes\n        other: cerca de %{count} meses\n      about_x_years:\n        one: cerca de %{count} año\n        other: cerca de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser un número par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor o igual que %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: es inválido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor o igual que %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser un número impar\n      other_than: debe ser distinto de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya ha sido utilizado\n      too_long:\n        one: es demasiado largo (máximo %{count} carácter)\n        other: es demasiado largo (máximo %{count} caracteres)\n      too_short:\n        one: es demasiado corto (mínimo %{count} carácter)\n        other: es demasiado corto (mínimo %{count} caracteres)\n      wrong_length:\n        one: longitud errónea (debe ser de %{count} carácter)\n        other: longitud errónea (debe ser de %{count} caracteres)\n    template:\n      body: 'Revise que los siguientes campos sean válidos:'\n      header:\n        one: \"%{model} no pudo guardarse debido a %{count} error\"\n        other: \"%{model} no pudo guardarse debido a %{count} errores\"\n  helpers:\n    select:\n      prompt: Por favor seleccione\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 0\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: C$\n    format:\n      delimiter: \",\"\n      precision: 2\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: \",\"\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \",\"\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: \",\"\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d de %b de %Y a las %H:%M:%S %Z\"\n      long: \"%A, %d de %B de %Y a las %I:%M %p\"\n      short: \"%d de %b a las %H:%M hrs\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-PA.yml",
    "content": "---\nes-PA:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un(a) %{record}\n            dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%-d/%-m/%Y\"\n      long: \"%A, %-d de %B de %Y\"\n      short: \"%-d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: alrededor de %{count} hora\n        other: alrededor de %{count} horas\n      about_x_months:\n        one: alrededor de %{count} mes\n        other: alrededor de %{count} meses\n      about_x_years:\n        one: alrededor de %{count} año\n        other: alrededor de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado(a)\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío(a)\n      equal_to: debe ser igual a %{count}\n      even: debe ser un número par\n      exclusion: está reservado(a)\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor que o igual a %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido(a) en la lista\n      invalid: no es válido(a)\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor que o igual a %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un número entero\n      odd: debe ser un número impar\n      other_than: debe ser diferente de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya está en uso\n      too_long:\n        one: es demasiado largo(a) (máximo %{count} carácter)\n        other: es demasiado largo(a) (máximo %{count} caracteres)\n      too_short:\n        one: es demasiado corto(a) (mínimo %{count} carácter)\n        other: es demasiado corto(a) (mínimo %{count} caracteres)\n      wrong_length:\n        one: no tiene la longitud correcta (debe ser de %{count} carácter)\n        other: no tiene la longitud correcta (debe ser de %{count} caracteres)\n    template:\n      body: 'Revise que los siguientes campos sean válidos:'\n      header:\n        one: No se pudo guardar este(a) %{model} porque se encontró %{count} error\n        other: No se pudo guardar este(a) %{model} porque se encontraron %{count}\n          errores\n  helpers:\n    select:\n      prompt: Por favor seleccione\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: B/.\n    format:\n      delimiter: \",\"\n      precision: 3\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A, %-d de %B de %Y a las %-I:%M:%S %p %Z\"\n      long: \"%-d de %B de %Y a las %-I:%M %p\"\n      short: \"%-d %b %-I:%M %p\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-PE.yml",
    "content": "---\nes-PE:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Falla de validación: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un %{record} dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%A, %d de %B del %Y\"\n      short: \"%d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: cerca de %{count} hora\n        other: cerca de %{count} horas\n      about_x_months:\n        one: cerca de %{count} mes\n        other: cerca de %{count} meses\n      about_x_years:\n        one: cerca de %{count} año\n        other: cerca de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser un número par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor o igual que %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: es inválido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor o igual que %{count}\n      model_invalid: 'Falla de validación: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser un número non\n      other_than: debe ser distinto de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya ha sido tomado\n      too_long:\n        one: es demasiado largo (máximo %{count} carácter)\n        other: es demasiado largo (máximo %{count} caracteres)\n      too_short:\n        one: es demasiado corto (mínimo %{count} carácter)\n        other: es demasiado corto (mínimo %{count} caracteres)\n      wrong_length:\n        one: longitud errónea (debe ser de %{count} carácter)\n        other: longitud errónea (debe ser de %{count} caracteres)\n    template:\n      body: 'Revise que los siguientes campos sean válidos:'\n      header:\n        one: \"%{model} no pudo guardarse debido a %{count} error\"\n        other: \"%{model} no pudo guardarse debido a %{count} errores\"\n  helpers:\n    select:\n      prompt: Por favor seleccione\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: S/\n    format:\n      delimiter: \",\"\n      precision: 2\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: \",\"\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \",\"\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: \",\"\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d de %b del %Y a las %H:%M:%S %Z\"\n      long: \"%A, %d de %B del %Y a las %I:%M %p\"\n      short: \"%d de %b a las %H:%M hrs\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-US.yml",
    "content": "---\nes-US:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un %{record} dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%A, %d de %B de %Y\"\n      short: \"%d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: cerca de %{count} hora\n        other: cerca de %{count} horas\n      about_x_months:\n        one: cerca de %{count} mes\n        other: cerca de %{count} meses\n      about_x_years:\n        one: cerca de %{count} año\n        other: cerca de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser un número par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor o igual que %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: es inválido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor o igual que %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser un número non\n      other_than: debe ser distinto de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya ha sido tomado\n      too_long:\n        one: es demasiado largo (máximo %{count} carácter)\n        other: es demasiado largo (máximo %{count} caracteres)\n      too_short:\n        one: es demasiado corto (mínimo %{count} carácter)\n        other: es demasiado corto (mínimo %{count} caracteres)\n      wrong_length:\n        one: longitud errónea (debe ser de %{count} carácter)\n        other: longitud errónea (debe ser de %{count} caracteres)\n    template:\n      body: 'Revise que los siguientes campos sean válidos:'\n      header:\n        one: \"%{model} no pudo guardarse debido a %{count} error\"\n        other: \"%{model} no pudo guardarse debido a %{count} errores\"\n  helpers:\n    select:\n      prompt: Por favor selecciona\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: 2\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: \",\"\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \",\"\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: \",\"\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d de %b de %Y a las %H:%M:%S %Z\"\n      long: \"%A, %d de %B de %Y a las %I:%M %p\"\n      short: \"%d de %b a las %H:%M hrs\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es-VE.yml",
    "content": "---\nes-VE:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un %{record} dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%A, %d de %B de %Y\"\n      short: \"%d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: cerca de %{count} hora\n        other: cerca de %{count} horas\n      about_x_months:\n        one: cerca de %{count} mes\n        other: cerca de %{count} meses\n      about_x_years:\n        one: cerca de %{count} año\n        other: cerca de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser un número par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor o igual que %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: no es válido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor o igual que %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser un número impar\n      other_than: debe ser distinto de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya está en uso\n      too_long:\n        one: es demasiado largo (máximo %{count} carácter)\n        other: es demasiado largo (máximo %{count} caracteres)\n      too_short:\n        one: es demasiado corto (mínimo %{count} carácter)\n        other: es demasiado corto (mínimo %{count} caracteres)\n      wrong_length:\n        one: longitud errónea (debe ser de %{count} carácter)\n        other: longitud errónea (debe ser de %{count} caracteres)\n    template:\n      body: 'Revise que los siguientes campos sean válidos:'\n      header:\n        one: \"%{model} no pudo guardarse debido a %{count} error\"\n        other: \"%{model} no pudo guardarse debido a %{count} errores\"\n  helpers:\n    select:\n      prompt: Por favor selecciona\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: Bs.\n    format:\n      delimiter: \".\"\n      precision: 2\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: \".\"\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \",\"\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: \",\"\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d de %b de %Y a las %H:%M:%S%p %Z\"\n      long: \"%A, %d de %B de %Y a las %I:%M%p\"\n      short: \"%d de %b a las %H:%M%p\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/es.yml",
    "content": "---\nes:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validación falló: %{errors}'\n        restrict_dependent_destroy:\n          has_many: No se puede eliminar el registro porque existen %{record} dependientes\n          has_one: No se puede eliminar el registro porque existe un %{record} dependiente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mié\n    - jue\n    - vie\n    - sáb\n    abbr_month_names:\n    -\n    - ene\n    - feb\n    - mar\n    - abr\n    - may\n    - jun\n    - jul\n    - ago\n    - sep\n    - oct\n    - nov\n    - dic\n    day_names:\n    - domingo\n    - lunes\n    - martes\n    - miércoles\n    - jueves\n    - viernes\n    - sábado\n    formats:\n      default: \"%-d/%-m/%Y\"\n      long: \"%-d de %B de %Y\"\n      short: \"%-d de %b\"\n    month_names:\n    -\n    - enero\n    - febrero\n    - marzo\n    - abril\n    - mayo\n    - junio\n    - julio\n    - agosto\n    - septiembre\n    - octubre\n    - noviembre\n    - diciembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: alrededor de %{count} hora\n        other: alrededor de %{count} horas\n      about_x_months:\n        one: alrededor de %{count} mes\n        other: alrededor de %{count} meses\n      about_x_years:\n        one: alrededor de %{count} año\n        other: alrededor de %{count} años\n      almost_x_years:\n        one: casi %{count} año\n        other: casi %{count} años\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: menos de %{count} minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: más de %{count} año\n        other: más de %{count} años\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} año\"\n        other: \"%{count} años\"\n    prompts:\n      day: Día\n      hour: Hora\n      minute: Minuto\n      month: Mes\n      second: Segundo\n      year: Año\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: no puede estar en blanco\n      confirmation: no coincide\n      empty: no puede estar vacío\n      equal_to: debe ser igual a %{count}\n      even: debe ser par\n      exclusion: está reservado\n      greater_than: debe ser mayor que %{count}\n      greater_than_or_equal_to: debe ser mayor que o igual a %{count}\n      in: debe estar en %{count}\n      inclusion: no está incluido en la lista\n      invalid: no es válido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor que o igual a %{count}\n      model_invalid: 'La validación falló: %{errors}'\n      not_a_number: no es un número\n      not_an_integer: debe ser un entero\n      odd: debe ser impar\n      other_than: debe ser distinto de %{count}\n      present: debe estar en blanco\n      required: debe existir\n      taken: ya está en uso\n      too_long:\n        one: es demasiado largo (%{count} carácter máximo)\n        other: es demasiado largo (%{count} caracteres máximo)\n      too_short:\n        one: es demasiado corto (%{count} carácter mínimo)\n        other: es demasiado corto (%{count} caracteres mínimo)\n      wrong_length:\n        one: no tiene la longitud correcta (%{count} carácter exactos)\n        other: no tiene la longitud correcta (%{count} caracteres exactos)\n    template:\n      body: 'Se encontraron problemas con los siguientes campos:'\n      header:\n        one: No se pudo guardar este/a %{model} porque se encontró %{count} error\n        other: No se pudo guardar este/a %{model} porque se encontraron %{count} errores\n  helpers:\n    select:\n      prompt: Por favor seleccione\n    submit:\n      create: Crear %{model}\n      submit: Guardar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 3\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: mil millones\n          million:\n            one: millón\n            other: millones\n          quadrillion: mil billones\n          thousand: mil\n          trillion:\n            one: billón\n            other: billones\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n %\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" y \"\n      two_words_connector: \" y \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A, %-d de %B de %Y %H:%M:%S %z\"\n      long: \"%-d de %B de %Y %H:%M\"\n      short: \"%-d de %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/et.yml",
    "content": "---\net:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Valideerimine ebaõnnestus: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Ei saa kirjet kustutada kuna sõltuvad %{record} on olemas\n          has_one: Ei saa kirjet kustutada kuna sõltuv %{record} on olemas\n  date:\n    abbr_day_names:\n    - P\n    - E\n    - T\n    - K\n    - \"N\"\n    - R\n    - L\n    abbr_month_names:\n    -\n    - jaan.\n    - veebr.\n    - märts\n    - apr.\n    - mai\n    - juuni\n    - juuli\n    - aug.\n    - sept.\n    - okt.\n    - nov.\n    - dets.\n    day_names:\n    - pühapäev\n    - esmaspäev\n    - teisipäev\n    - kolmapäev\n    - neljapäev\n    - reede\n    - laupäev\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%d. %B %Y\"\n      short: \"%d.%m.%y\"\n    month_names:\n    -\n    - jaanuar\n    - veebruar\n    - märts\n    - aprill\n    - mai\n    - juuni\n    - juuli\n    - august\n    - september\n    - oktoober\n    - november\n    - detsember\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: umbes %{count} tund\n        other: umbes %{count} tundi\n      about_x_months:\n        one: umbes %{count} kuu\n        other: umbes %{count} kuud\n      about_x_years:\n        one: umbes %{count} aasta\n        other: umbes %{count} aastat\n      almost_x_years:\n        one: peaaegu üks aasta\n        other: peaaegu %{count} aastat\n      half_a_minute: pool minutit\n      less_than_x_minutes:\n        one: vähem kui %{count} minut\n        other: vähem kui %{count} minutit\n      less_than_x_seconds:\n        one: vähem kui %{count} sekund\n        other: vähem kui %{count} sekundit\n      over_x_years:\n        one: üle %{count} aasta\n        other: üle %{count} aasta\n      x_days:\n        one: \"%{count} päev\"\n        other: \"%{count} päeva\"\n      x_minutes:\n        one: \"%{count} minut\"\n        other: \"%{count} minutit\"\n      x_months:\n        one: \"%{count} kuu\"\n        other: \"%{count} kuud\"\n      x_seconds:\n        one: \"%{count} sekund\"\n        other: \"%{count} sekundit\"\n      x_years:\n        one: \"%{count} aasta\"\n        other: \"%{count} aastat\"\n    prompts:\n      day: Päev\n      hour: Tunde\n      minute: Minutit\n      month: Kuu\n      second: Sekundit\n      year: Aasta\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: peab olema heaks kiidetud\n      blank: on täitmata\n      confirmation: ei vasta kinnitusele\n      empty: on tühi\n      equal_to: peab olema võrdne arvuga %{count}\n      even: peab olema paarisarv\n      exclusion: on reserveeritud\n      greater_than: peab olema suurem kui %{count}\n      greater_than_or_equal_to: peab olema suurem või võrdne arvuga %{count}\n      inclusion: ei leidu nimekirjas\n      invalid: ei ole korrektne\n      less_than: peab olema vähem kui %{count}\n      less_than_or_equal_to: peab olema vähem või võrdne arvuga %{count}\n      model_invalid: 'Valideerimine ebaõnnestus: %{errors}'\n      not_a_number: ei ole number\n      not_an_integer: peab olema täisarv\n      odd: peab olema paaritu arv\n      other_than: peab olema midagi muud kui %{count}\n      present: peab olema täitmata\n      required: peab olemas olema\n      taken: on juba võetud\n      too_long: on liiga pikk (maksimum on %{count} tähemärki)\n      too_short: on liiga lühike (miinimum on %{count} tähemärki)\n      wrong_length: on vale pikkusega (peab olema %{count} tähemärki)\n    template:\n      body: 'Probleeme ilmnes järgmiste väljadega:'\n      header:\n        one: Üks viga takistas objekti %{model} salvestamist\n        other: \"%{count} viga takistasid objekti %{model} salvestamist\"\n  helpers:\n    select:\n      prompt: Palun vali\n    submit:\n      create: Loo uus %{model}\n      submit: Salvesta %{model}\n      update: Uuenda objekti %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \" \"\n      precision: 2\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: miljard\n          million: miljon\n          quadrillion: kvadriljon\n          thousand: tuhat\n          trillion: triljon\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: bait\n            other: baiti\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" ja \"\n      two_words_connector: \" ja \"\n      words_connector: \", \"\n  time:\n    am: enne lõunat\n    formats:\n      default: \"%d. %B %Y, %H:%M\"\n      long: \"%a, %d. %b %Y, %H:%M:%S %z\"\n      short: \"%d.%m.%y, %H:%M\"\n    pm: pärast lõunat\n"
  },
  {
    "path": "config/locales/defaults/eu.yml",
    "content": "---\neu:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Balioztatze arazoa: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Ezin da erregistroa ezabat menpeko %{record}ak daudelako\n          has_one: Ezin da erregistroa ezabatu menpeko %{record} bat dagoelako\n  date:\n    abbr_day_names:\n    - Igan\n    - Astel\n    - Astear\n    - Asteaz\n    - Oste\n    - Osti\n    - Lar\n    abbr_month_names:\n    -\n    - Urt\n    - Ots\n    - Mar\n    - Api\n    - Mai\n    - Eka\n    - Uzt\n    - Abu\n    - Ira\n    - Urr\n    - Aza\n    - Aben\n    day_names:\n    - Igandea\n    - Astelehena\n    - Asteartea\n    - Asteazkena\n    - Osteguna\n    - Ostirala\n    - Larunbata\n    formats:\n      default: \"%Y/%m/%e\"\n      long: \"%Y(e)ko %Bk %e\"\n      short: \"%b %e\"\n    month_names:\n    -\n    - Urtarrila\n    - Otsaila\n    - Martxoa\n    - Apirila\n    - Maiatza\n    - Ekaina\n    - Uztaila\n    - Abuztua\n    - Iraila\n    - Urria\n    - Azaroa\n    - Abendua\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ordu bat inguru\n        other: \"%{count} ordu inguru\"\n      about_x_months:\n        one: hilabete bat inguru\n        other: \"%{count} hilabete inguru\"\n      about_x_years:\n        one: urte bat inguru\n        other: \"%{count} urte inguru\"\n      almost_x_years:\n        one: ia urte bat\n        other: ia %{count} urte\n      half_a_minute: minutu erdi\n      less_than_x_minutes:\n        one: \"%{count} minutu bat baino gutxiago\"\n        other: \"%{count} minutu baino gutxiago\"\n      less_than_x_seconds:\n        one: segundu bat baino gutxiago\n        other: \"%{count} segundu baino gutxiago\"\n      over_x_years:\n        one: urte bat baino gehiago\n        other: \"%{count} urte baino gehiago\"\n      x_days:\n        one: egun bat\n        other: \"%{count} egun\"\n      x_minutes:\n        one: minutu bat\n        other: \"%{count} minutu\"\n      x_months:\n        one: hilabete bat\n        other: \"%{count} hilabete\"\n      x_seconds:\n        one: segundu bat\n        other: \"%{count} segundu\"\n    prompts:\n      day: Egun\n      hour: Ordu\n      minute: Minutu\n      month: Hilabete\n      second: Segundu\n      year: Urte\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: onartuta izan behar da\n      blank: ezin da zuriz utzi\n      confirmation: ez dator bat konfirmazioarekin\n      empty: ezin da hutsik egon\n      equal_to: \"%{count} izan behar da\"\n      even: bikoitia izan behar du\n      exclusion: erreserbatuta dago\n      greater_than: \"%{count} baino handiagoa izan behar da\"\n      greater_than_or_equal_to: \"%{count} baino handiago edo berdin izan behar da\"\n      inclusion: ez da zerrendako aukera bat\n      invalid: ez da zuzena\n      less_than: \"%{count} baino txikiago izan behar da\"\n      less_than_or_equal_to: \"%{count} baino txikiago edo berdin izan behar da\"\n      model_invalid: 'Baieztatzeak huts egin du: %{errors}'\n      not_a_number: ez da zenbaki bat\n      not_an_integer: zenbaki osoa izan behar da\n      odd: bakoitia izan behar du\n      other_than: \"%{count}ren ezberdina izan behar du\"\n      present: zuriz egon behar da\n      required: egon behar du\n      taken: hartuta dago\n      too_long:\n        one: luzeegia da (karaktere %{count} gehienez)\n        other: luzeegia da (%{count} karaktere gehienez)\n      too_short:\n        one: laburregia da (karaktere %{count} gutxienez)\n        other: laburregia da (%{count} karaktere gutxienez)\n      wrong_length:\n        one: ez du luzeera zuzena (karaktere %{count} izan behar ditu)\n        other: ez du luzeera zuzena (%{count} karaktere izan behar ditu)\n    template:\n      body: 'Arazoak egon dira ondoko eremuekin:'\n      header:\n        one: Errore batek ezinezkoa egin du %{model} hau gordetzea\n        other: \"%{count} errorek ezinezkoa egiten dute %{model} hau gordetzea\"\n  helpers:\n    select:\n      prompt: Mesedez, aukeratu\n    submit:\n      create: \"%{model}a eratu\"\n      submit: \"%{model}a gorde\"\n      update: \"%{model}a eguneratu\"\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Mila milioi\n          million: Milioi\n          quadrillion: Kuatrilioi\n          thousand: Mila\n          trillion: Trilioi\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Byte\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n %\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" eta \"\n      two_words_connector: \" eta \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A, %Y(e)ko %Bren %e %H:%M:%S %z\"\n      long: \"%Y(e)ko %Bren %e,  %H:%M\"\n      short: \"%b %e, %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/fa.yml",
    "content": "---\nfa:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: رکورد نامعتبر است %{errors}\n        restrict_dependent_destroy:\n          has_many: نمی توان رکورد را حذف کرد بخاطر اینکه %{record} وابسته وجود دارد\n          has_one: نمی توان رکورد را حذف کرد بخاطر اینکه یک %{record} وابسته وجود\n            دارد\n  date:\n    abbr_day_names:\n    - ی\n    - د\n    - س\n    - چ\n    - پ\n    - ج\n    - ش\n    abbr_month_names:\n    -\n    - ژانویه\n    - فوریه\n    - مارس\n    - آوریل\n    - مه\n    - ژوئن\n    - ژوئیه\n    - اوت\n    - سپتامبر\n    - اکتبر\n    - نوامبر\n    - دسامبر\n    day_names:\n    - یکشنبه\n    - دوشنبه\n    - سه‌شنبه\n    - چهارشنبه\n    - پنج‌شنبه\n    - جمعه\n    - شنبه\n    formats:\n      default: \"%Y/%m/%d\"\n      long: \"%e %B %Y\"\n      short: \"%m/%d\"\n    month_names:\n    -\n    - ژانویه\n    - فوریه\n    - مارس\n    - آوریل\n    - مه\n    - ژوئن\n    - ژوئیه\n    - اوت\n    - سپتامبر\n    - اکتبر\n    - نوامبر\n    - دسامبر\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: حدود یک ساعت\n        other: حدود %{count} ساعت\n      about_x_months:\n        one: حدود یک ماه\n        other: حدود %{count} ماه\n      about_x_years:\n        one: حدود یک سال\n        other: حدود %{count} سال\n      almost_x_years:\n        one: حدود یک سال\n        other: حدود %{count} سال\n      half_a_minute: نیم دقیقه\n      less_than_x_minutes:\n        one: کمتر از یک دقیقه\n        other: کمتر از %{count} دقیقه\n      less_than_x_seconds:\n        one: یک ثانیه\n        other: کمتر  از %{count} ثانیه\n      over_x_years:\n        one: بیش از یک سال\n        other: بیش از %{count} سال\n      x_days:\n        one: یک روز\n        other: \"%{count} روز\"\n      x_minutes:\n        one: یک دقیقه\n        other: \"%{count} دقیقه\"\n      x_months:\n        one: یک ماه\n        other: \"%{count} ماه\"\n      x_seconds:\n        one: یک ثانیه\n        other: \"%{count} ثانیه\"\n      x_years:\n        one: \"%{count} سال\"\n        other: \"%{count} سال\"\n    prompts:\n      day: روز\n      hour: ساعت\n      minute: دقیقه\n      month: ماه\n      second: ثانیه\n      year: سال\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: باید پذیرفته شود\n      blank: نباید خالی باشد\n      confirmation: با تایید نمی‌خواند\n      empty: نمی‌تواند خالی باشد\n      equal_to: باید برابر %{count} باشد\n      even: باید زوج باشد\n      exclusion: رزرو است\n      greater_than: باید بزرگتر از %{count} باشد\n      greater_than_or_equal_to: باید بزرگتر یا برابر %{count} باشد\n      inclusion: در لیست موجود نیست\n      invalid: نامعتبر است\n      less_than: باید کمتر از %{count} باشد\n      less_than_or_equal_to: باید کمتر یا برابر %{count} باشد\n      model_invalid: رکورد نامعتبر است %{errors}\n      not_a_number: عدد نیست\n      not_an_integer: عدد صحیح نیست\n      odd: باید فرد باشد\n      other_than: باید غیر از %{count} باشد\n      present: باید خالی باشد\n      required: باید وجود داشته باشد\n      taken: پیشتر گرفته شده\n      too_long: بلند است (حداکثر %{count} کاراکتر)\n      too_short: کوتاه است (حداقل %{count} کاراکتر)\n      wrong_length: نااندازه است (باید %{count} کاراکتر باشد)\n    template:\n      body: 'موارد زیر مشکل داشت:'\n      header:\n        one: \"%{count} خطا جلوی ذخیره این %{model} را گرفت\"\n        other: \"%{count} خطا جلوی ذخیره این %{model} را گرفت\"\n  helpers:\n    select:\n      prompt: لطفا انتخاب کنید\n    submit:\n      create: ایجاد %{model}\n      submit: ذخیره‌ی %{model}\n      update: بروز رسانی %{model}\n  number:\n    currency:\n      format:\n        delimiter: \"٬\"\n        format: \"%n %u\"\n        precision: 0\n        separator: \"٫\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"﷼\"\n    format:\n      delimiter: \"٬\"\n      precision: 2\n      separator: \"٫\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: میلیارد\n          million: میلیون\n          quadrillion: کادریلیون\n          thousand: هزار\n          trillion: تریلیون\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: بایت\n            other: بایت\n          gb: گیگابایت\n          kb: کیلوبایت\n          mb: مگابایت\n          tb: ترابایت\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" و \"\n      two_words_connector: \" و \"\n      words_connector: \"، \"\n  time:\n    am: قبل از ظهر\n    formats:\n      default: \"%A، %e %B %Y، ساعت %H:%M:%S (%Z)\"\n      long: \"%e %B %Y، ساعت %H:%M\"\n      short: \"%e %B، ساعت %H:%M\"\n    pm: بعد از ظهر\n"
  },
  {
    "path": "config/locales/defaults/fi.yml",
    "content": "---\nfi:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validointi epäonnistui: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Ei voida poistaa mallia koska tästä riippuvaisia %{record} löytyy\n          has_one: Ei voida poistaa mallia koska tästä riippuvainen %{record} löytyy\n  date:\n    abbr_day_names:\n    - su\n    - ma\n    - ti\n    - ke\n    - to\n    - pe\n    - la\n    abbr_month_names:\n    -\n    - tammi\n    - helmi\n    - maalis\n    - huhti\n    - touko\n    - kesä\n    - heinä\n    - elo\n    - syys\n    - loka\n    - marras\n    - joulu\n    day_names:\n    - sunnuntai\n    - maanantai\n    - tiistai\n    - keskiviikko\n    - torstai\n    - perjantai\n    - lauantai\n    formats:\n      default: \"%-d.%-m.%Y\"\n      long: \"%d. %Bta %Y\"\n      short: \"%d. %b\"\n    month_names:\n    -\n    - tammikuu\n    - helmikuu\n    - maaliskuu\n    - huhtikuu\n    - toukokuu\n    - kesäkuu\n    - heinäkuu\n    - elokuu\n    - syyskuu\n    - lokakuu\n    - marraskuu\n    - joulukuu\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: noin tunti\n        other: noin %{count} tuntia\n      about_x_months:\n        one: noin kuukausi\n        other: noin %{count} kuukautta\n      about_x_years:\n        one: vuosi\n        other: noin %{count} vuotta\n      almost_x_years:\n        one: melkein yksi vuosi\n        other: melkein %{count} vuotta\n      half_a_minute: puoli minuuttia\n      less_than_x_minutes:\n        one: alle minuutti\n        other: alle %{count} minuuttia\n      less_than_x_seconds:\n        one: alle sekunti\n        other: alle %{count} sekuntia\n      over_x_years:\n        one: yli vuosi\n        other: yli %{count} vuotta\n      x_days:\n        one: päivä\n        other: \"%{count} päivää\"\n      x_minutes:\n        one: minuutti\n        other: \"%{count} minuuttia\"\n      x_months:\n        one: kuukausi\n        other: \"%{count} kuukautta\"\n      x_seconds:\n        one: sekunti\n        other: \"%{count} sekuntia\"\n      x_years:\n        one: vuosi\n        other: \"%{count} vuotta\"\n    prompts:\n      day: Päivä\n      hour: Tunti\n      minute: Minuutti\n      month: Kuukausi\n      second: Sekunti\n      year: Vuosi\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: täytyy olla hyväksytty\n      blank: ei voi olla tyhjä\n      confirmation: ei vastaa varmennusta\n      empty: ei voi olla tyhjä\n      equal_to: täytyy olla yhtä suuri kuin %{count}\n      even: täytyy olla parillinen\n      exclusion: on varattu\n      greater_than: täytyy olla suurempi kuin %{count}\n      greater_than_or_equal_to: täytyy olla suurempi tai yhtä suuri kuin %{count}\n      inclusion: ei löydy listasta\n      invalid: on virheellinen\n      less_than: täytyy olla pienempi kuin %{count}\n      less_than_or_equal_to: täytyy olla pienempi tai yhtä suuri kuin %{count}\n      model_invalid: 'Validointi epäonnistui: %{errors}'\n      not_a_number: ei ole luku\n      not_an_integer: täytyy olla kokonaisluku\n      odd: täytyy olla pariton\n      present: täytyy olla sisällötön\n      required: täytyy olla\n      taken: on jo käytössä\n      too_long:\n        one: on liian pitkä (saa olla enintään %{count} merkki)\n        other: on liian pitkä (saa olla enintään %{count} merkkiä)\n      too_short:\n        one: on liian lyhyt (oltava vähintään %{count} merkki)\n        other: on liian lyhyt (oltava vähintään %{count} merkkiä)\n      wrong_length:\n        one: on väärän pituinen (täytyy olla täsmälleen %{count} merkki)\n        other: on väärän pituinen (täytyy olla täsmälleen %{count} merkkiä)\n    template:\n      body: 'Seuraavat kentät aiheuttivat ongelmia:'\n      header:\n        one: Virhe syötteessä esti mallin %{model} tallentamisen\n        other: \"%{count} virhettä esti mallin %{model} tallentamisen\"\n  helpers:\n    select:\n      prompt: Valitse\n    submit:\n      create: Luo %{model}\n      submit: Tallenna %{model}\n      update: Päivitä %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: miljardia\n          million: miljoonaa\n          quadrillion: tuhatta biljoonaa\n          thousand: tuhatta\n          trillion: biljoonaa\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: tavu\n            other: tavua\n          gb: Gt\n          kb: kt\n          mb: Mt\n          tb: Tt\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" ja \"\n      two_words_connector: \" ja \"\n      words_connector: \", \"\n  time:\n    am: aamupäivä\n    formats:\n      default: \"%A %e. %Bta %Y %H:%M:%S %z\"\n      long: \"%e. %Bta %Y %H.%M\"\n      short: \"%e.%m. %H.%M\"\n    pm: iltapäivä\n"
  },
  {
    "path": "config/locales/defaults/fr-CA.yml",
    "content": "---\nfr-CA:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validation a échoué : %{errors}'\n        restrict_dependent_destroy:\n          has_many: Vous ne pouvez pas supprimer l'enregistrement parce que les %{record}\n            dépendants existent\n          has_one: Vous ne pouvez pas supprimer l'enregistrement car un(e) %{record}\n            dépendant(e) existe\n  date:\n    abbr_day_names:\n    - dim\n    - lun\n    - mar\n    - mer\n    - jeu\n    - ven\n    - sam\n    abbr_month_names:\n    -\n    - jan.\n    - fév.\n    - mars\n    - avr.\n    - mai\n    - juin\n    - juil.\n    - août\n    - sept.\n    - oct.\n    - nov.\n    - déc.\n    day_names:\n    - dimanche\n    - lundi\n    - mardi\n    - mercredi\n    - jeudi\n    - vendredi\n    - samedi\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%d %B %Y\"\n      short: \"%y-%m-%d\"\n    month_names:\n    -\n    - janvier\n    - février\n    - mars\n    - avril\n    - mai\n    - juin\n    - juillet\n    - août\n    - septembre\n    - octobre\n    - novembre\n    - décembre\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: environ une heure\n        other: environ %{count} heures\n      about_x_months:\n        one: environ un mois\n        other: environ %{count} mois\n      about_x_years:\n        one: environ un an\n        other: environ %{count} ans\n      almost_x_years:\n        one: presque un an\n        other: presque %{count} ans\n      half_a_minute: une demi-minute\n      less_than_x_minutes:\n        one: moins d'une minute\n        other: moins de %{count} minutes\n        zero: moins d'une minute\n      less_than_x_seconds:\n        one: moins d'une seconde\n        other: moins de %{count} secondes\n        zero: moins d'une seconde\n      over_x_years:\n        one: plus d'un an\n        other: plus de %{count} ans\n      x_days:\n        one: \"%{count} jour\"\n        other: \"%{count} jours\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} mois\"\n        other: \"%{count} mois\"\n      x_seconds:\n        one: \"%{count} seconde\"\n        other: \"%{count} secondes\"\n      x_years:\n        one: \"%{count} an\"\n        other: \"%{count} ans\"\n    prompts:\n      day: Jour\n      hour: Heure\n      minute: Minute\n      month: Mois\n      second: Seconde\n      year: Année\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: doit être accepté(e)\n      blank: doit être rempli(e)\n      confirmation: ne concorde pas avec %{attribute}\n      empty: doit être rempli(e)\n      equal_to: doit être égal à %{count}\n      even: doit être pair\n      exclusion: n'est pas disponible\n      greater_than: doit être supérieur à %{count}\n      greater_than_or_equal_to: doit être supérieur ou égal à %{count}\n      in: doit être dans l'intervalle %{count}\n      inclusion: n'est pas inclus(e) dans la liste\n      invalid: n'est pas valide\n      less_than: doit être inférieur à %{count}\n      less_than_or_equal_to: doit être inférieur ou égal à %{count}\n      model_invalid: 'Validation échouée : %{errors}'\n      not_a_number: n'est pas un nombre\n      not_an_integer: doit être un nombre entier\n      odd: doit être impair\n      other_than: doit être différent de %{count}\n      present: doit être vide\n      required: doit exister\n      taken: n'est pas disponible\n      too_long:\n        one: est trop long (pas plus d'un caractère)\n        other: est trop long (pas plus de %{count} caractères)\n      too_short:\n        one: est trop court (au moins un caractère)\n        other: est trop court (au moins %{count} caractères)\n      wrong_length:\n        one: ne fait pas la bonne longueur (doit comporter un seul caractère)\n        other: ne fait pas la bonne longueur (doit comporter %{count} caractères)\n    template:\n      body: 'Veuillez vérifier les champs suivants : '\n      header:\n        one: 'Impossible d''enregistrer ce(tte) %{model} : %{count} erreur'\n        other: 'Impossible d''enregistrer ce(tte) %{model} : %{count} erreurs'\n  helpers:\n    select:\n      prompt: Veuillez sélectionner\n    submit:\n      create: Créer un(e) %{model}\n      submit: Enregistrer ce(tte) %{model}\n      update: Modifier ce(tte) %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \" \"\n      precision: 3\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: milliard\n          million: million\n          quadrillion: million de milliards\n          thousand: millier\n          trillion: billion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: octet\n            other: octets\n          eb: Eo\n          gb: Go\n          kb: ko\n          mb: Mo\n          pb: Po\n          tb: To\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" et \"\n      two_words_connector: \" et \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%d %B %Y %H h %M min %S s\"\n      long: \"%A %d %B %Y %H h %M\"\n      short: \"%d %b %H h %M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/fr-CH.yml",
    "content": "---\nfr-CH:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validation a échoué : %{errors}'\n        restrict_dependent_destroy:\n          has_many: Vous ne pouvez pas supprimer l'enregistrement parce que les %{record}\n            dépendants existent\n          has_one: Vous ne pouvez pas supprimer l'enregistrement car un(e) %{record}\n            dépendant(e) existe\n  date:\n    abbr_day_names:\n    - dim\n    - lun\n    - mar\n    - mer\n    - jeu\n    - ven\n    - sam\n    abbr_month_names:\n    -\n    - jan.\n    - fév.\n    - mars\n    - avr.\n    - mai\n    - juin\n    - juil.\n    - août\n    - sept.\n    - oct.\n    - nov.\n    - déc.\n    day_names:\n    - dimanche\n    - lundi\n    - mardi\n    - mercredi\n    - jeudi\n    - vendredi\n    - samedi\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%-d %B %Y\"\n      short: \"%-d %b\"\n    month_names:\n    -\n    - janvier\n    - février\n    - mars\n    - avril\n    - mai\n    - juin\n    - juillet\n    - août\n    - septembre\n    - octobre\n    - novembre\n    - décembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: environ une heure\n        other: environ %{count} heures\n      about_x_months:\n        one: environ un mois\n        other: environ %{count} mois\n      about_x_years:\n        one: environ un an\n        other: environ %{count} ans\n      almost_x_years:\n        one: presque un an\n        other: presque %{count} ans\n      half_a_minute: une demi-minute\n      less_than_x_minutes:\n        one: moins d'une minute\n        other: moins de %{count} minutes\n        zero: moins d'une minute\n      less_than_x_seconds:\n        one: moins d'une seconde\n        other: moins de %{count} secondes\n        zero: moins d'une seconde\n      over_x_years:\n        one: plus d'un an\n        other: plus de %{count} ans\n      x_days:\n        one: \"%{count} jour\"\n        other: \"%{count} jours\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} mois\"\n        other: \"%{count} mois\"\n      x_seconds:\n        one: \"%{count} seconde\"\n        other: \"%{count} secondes\"\n      x_years:\n        one: \"%{count} an\"\n        other: \"%{count} ans\"\n    prompts:\n      day: Jour\n      hour: Heure\n      minute: Minute\n      month: Mois\n      second: Seconde\n      year: Année\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: doit être accepté(e)\n      blank: doit être rempli(e)\n      confirmation: ne concorde pas avec %{attribute}\n      empty: doit être rempli(e)\n      equal_to: doit être égal à %{count}\n      even: doit être pair\n      exclusion: n'est pas disponible\n      greater_than: doit être supérieur à %{count}\n      greater_than_or_equal_to: doit être supérieur ou égal à %{count}\n      in: doit être dans l'intervalle %{count}\n      inclusion: n'est pas inclus(e) dans la liste\n      invalid: n'est pas valide\n      less_than: doit être inférieur à %{count}\n      less_than_or_equal_to: doit être inférieur ou égal à %{count}\n      model_invalid: 'Validation échouée : %{errors}'\n      not_a_number: n'est pas un nombre\n      not_an_integer: doit être un nombre entier\n      odd: doit être impair\n      other_than: doit être différent de %{count}\n      present: doit être vide\n      required: doit exister\n      taken: n'est pas disponible\n      too_long:\n        one: est trop long (pas plus d'un caractère)\n        other: est trop long (pas plus de %{count} caractères)\n      too_short:\n        one: est trop court (au moins un caractère)\n        other: est trop court (au moins %{count} caractères)\n      wrong_length:\n        one: ne fait pas la bonne longueur (doit comporter un seul caractère)\n        other: ne fait pas la bonne longueur (doit comporter %{count} caractères)\n    template:\n      body: 'Veuillez vérifier les champs suivants : '\n      header:\n        one: 'Impossible d''enregistrer ce(tte) %{model} : %{count} erreur'\n        other: 'Impossible d''enregistrer ce(tte) %{model} : %{count} erreurs'\n  helpers:\n    select:\n      prompt: Veuillez sélectionner\n    submit:\n      create: Créer un(e) %{model}\n      submit: Enregistrer ce(tte) %{model}\n      update: Modifier ce(tte) %{model}\n  number:\n    currency:\n      format:\n        delimiter: \"'\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: sFr.\n    format:\n      delimiter: \"'\"\n      precision: 3\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: milliard\n          million: million\n          quadrillion: million de milliards\n          thousand: millier\n          trillion: billion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: octet\n            other: octets\n          eb: Eo\n          gb: Go\n          kb: ko\n          mb: Mo\n          pb: Po\n          tb: To\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" et \"\n      two_words_connector: \" et \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%d. %B %Y %H h %M\"\n      long: \"%A, %d %B %Y %H h %M min %S s %Z\"\n      short: \"%d %b %H h %M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/fr-FR.yml",
    "content": "---\nfr-FR:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validation a échoué : %{errors}'\n        restrict_dependent_destroy:\n          has_many: Vous ne pouvez pas supprimer l'enregistrement parce que les %{record}\n            dépendants existent\n          has_one: Vous ne pouvez pas supprimer l'enregistrement car un(e) %{record}\n            dépendant(e) existe\n  date:\n    abbr_day_names:\n    - dim\n    - lun\n    - mar\n    - mer\n    - jeu\n    - ven\n    - sam\n    abbr_month_names:\n    -\n    - jan.\n    - fév.\n    - mars\n    - avr.\n    - mai\n    - juin\n    - juil.\n    - août\n    - sept.\n    - oct.\n    - nov.\n    - déc.\n    day_names:\n    - dimanche\n    - lundi\n    - mardi\n    - mercredi\n    - jeudi\n    - vendredi\n    - samedi\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%-d %B %Y\"\n      short: \"%-d %b\"\n    month_names:\n    -\n    - janvier\n    - février\n    - mars\n    - avril\n    - mai\n    - juin\n    - juillet\n    - août\n    - septembre\n    - octobre\n    - novembre\n    - décembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: environ une heure\n        other: environ %{count} heures\n      about_x_months:\n        one: environ un mois\n        other: environ %{count} mois\n      about_x_years:\n        one: environ un an\n        other: environ %{count} ans\n      almost_x_years:\n        one: presque un an\n        other: presque %{count} ans\n      half_a_minute: une demi-minute\n      less_than_x_minutes:\n        one: moins d'une minute\n        other: moins de %{count} minutes\n        zero: moins d'une minute\n      less_than_x_seconds:\n        one: moins d'une seconde\n        other: moins de %{count} secondes\n        zero: moins d'une seconde\n      over_x_years:\n        one: plus d'un an\n        other: plus de %{count} ans\n      x_days:\n        one: \"%{count} jour\"\n        other: \"%{count} jours\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} mois\"\n        other: \"%{count} mois\"\n      x_seconds:\n        one: \"%{count} seconde\"\n        other: \"%{count} secondes\"\n      x_years:\n        one: \"%{count} an\"\n        other: \"%{count} ans\"\n    prompts:\n      day: Jour\n      hour: Heure\n      minute: Minute\n      month: Mois\n      second: Seconde\n      year: Année\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: doit être accepté(e)\n      blank: doit être rempli(e)\n      confirmation: ne concorde pas avec %{attribute}\n      empty: doit être rempli(e)\n      equal_to: doit être égal à %{count}\n      even: doit être pair\n      exclusion: n'est pas disponible\n      greater_than: doit être supérieur à %{count}\n      greater_than_or_equal_to: doit être supérieur ou égal à %{count}\n      in: doit être dans l'intervalle %{count}\n      inclusion: n'est pas inclus(e) dans la liste\n      invalid: n'est pas valide\n      less_than: doit être inférieur à %{count}\n      less_than_or_equal_to: doit être inférieur ou égal à %{count}\n      model_invalid: 'Validation échouée : %{errors}'\n      not_a_number: n'est pas un nombre\n      not_an_integer: doit être un nombre entier\n      odd: doit être impair\n      other_than: doit être différent de %{count}\n      present: doit être vide\n      required: doit exister\n      taken: n'est pas disponible\n      too_long:\n        one: est trop long (pas plus d'un caractère)\n        other: est trop long (pas plus de %{count} caractères)\n      too_short:\n        one: est trop court (au moins un caractère)\n        other: est trop court (au moins %{count} caractères)\n      wrong_length:\n        one: ne fait pas la bonne longueur (doit comporter un seul caractère)\n        other: ne fait pas la bonne longueur (doit comporter %{count} caractères)\n    template:\n      body: 'Veuillez vérifier les champs suivants : '\n      header:\n        one: 'Impossible d''enregistrer ce(tte) %{model} : %{count} erreur'\n        other: 'Impossible d''enregistrer ce(tte) %{model} : %{count} erreurs'\n  helpers:\n    select:\n      prompt: Veuillez sélectionner\n    submit:\n      create: Créer un(e) %{model}\n      submit: Enregistrer ce(tte) %{model}\n      update: Modifier ce(tte) %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \" \"\n      precision: 3\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: milliard\n          million: million\n          quadrillion: million de milliards\n          thousand: millier\n          trillion: billion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: octet\n            other: octets\n          eb: Eo\n          gb: Go\n          kb: ko\n          mb: Mo\n          pb: Po\n          tb: To\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" et \"\n      two_words_connector: \" et \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%d %B %Y %Hh %Mmin %Ss\"\n      long: \"%A %d %B %Y %Hh%M\"\n      short: \"%d %b %Hh%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/fr.yml",
    "content": "---\nfr:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'La validation a échoué : %{errors}'\n        restrict_dependent_destroy:\n          has_many: Vous ne pouvez pas supprimer l'enregistrement parce que les %{record}\n            dépendants existent\n          has_one: Vous ne pouvez pas supprimer l'enregistrement car un(e) %{record}\n            dépendant(e) existe\n  date:\n    abbr_day_names:\n    - dim\n    - lun\n    - mar\n    - mer\n    - jeu\n    - ven\n    - sam\n    abbr_month_names:\n    -\n    - jan.\n    - fév.\n    - mars\n    - avr.\n    - mai\n    - juin\n    - juil.\n    - août\n    - sept.\n    - oct.\n    - nov.\n    - déc.\n    day_names:\n    - dimanche\n    - lundi\n    - mardi\n    - mercredi\n    - jeudi\n    - vendredi\n    - samedi\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%-d %B %Y\"\n      short: \"%-d %b\"\n    month_names:\n    -\n    - janvier\n    - février\n    - mars\n    - avril\n    - mai\n    - juin\n    - juillet\n    - août\n    - septembre\n    - octobre\n    - novembre\n    - décembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: environ une heure\n        other: environ %{count} heures\n      about_x_months:\n        one: environ un mois\n        other: environ %{count} mois\n      about_x_years:\n        one: environ un an\n        other: environ %{count} ans\n      almost_x_years:\n        one: presque un an\n        other: presque %{count} ans\n      half_a_minute: une demi‑minute\n      less_than_x_minutes:\n        one: moins d'une minute\n        other: moins de %{count} minutes\n        zero: moins d'une minute\n      less_than_x_seconds:\n        one: moins d'une seconde\n        other: moins de %{count} secondes\n        zero: moins d'une seconde\n      over_x_years:\n        one: plus d'un an\n        other: plus de %{count} ans\n      x_days:\n        one: \"%{count} jour\"\n        other: \"%{count} jours\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} mois\"\n        other: \"%{count} mois\"\n      x_seconds:\n        one: \"%{count} seconde\"\n        other: \"%{count} secondes\"\n      x_years:\n        one: \"%{count} an\"\n        other: \"%{count} ans\"\n    prompts:\n      day: Jour\n      hour: Heure\n      minute: Minute\n      month: Mois\n      second: Seconde\n      year: Année\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: doit être accepté(e)\n      blank: doit être rempli(e)\n      confirmation: ne concorde pas avec %{attribute}\n      empty: doit être rempli(e)\n      equal_to: doit être égal à %{count}\n      even: doit être pair\n      exclusion: n'est pas disponible\n      greater_than: doit être supérieur à %{count}\n      greater_than_or_equal_to: doit être supérieur ou égal à %{count}\n      in: doit être dans l'intervalle %{count}\n      inclusion: n'est pas inclus(e) dans la liste\n      invalid: n'est pas valide\n      less_than: doit être inférieur à %{count}\n      less_than_or_equal_to: doit être inférieur ou égal à %{count}\n      model_invalid: 'Validation échouée : %{errors}'\n      not_a_number: n'est pas un nombre\n      not_an_integer: doit être un nombre entier\n      odd: doit être impair\n      other_than: doit être différent de %{count}\n      present: doit être vide\n      required: doit exister\n      taken: est déjà utilisé(e)\n      too_long:\n        one: est trop long (pas plus d'un caractère)\n        other: est trop long (pas plus de %{count} caractères)\n      too_short:\n        one: est trop court (au moins un caractère)\n        other: est trop court (au moins %{count} caractères)\n      wrong_length:\n        one: ne fait pas la bonne longueur (doit comporter un seul caractère)\n        other: ne fait pas la bonne longueur (doit comporter %{count} caractères)\n    template:\n      body: 'Veuillez vérifier les champs suivants : '\n      header:\n        one: 'Impossible d''enregistrer ce(tte) %{model} : %{count} erreur'\n        other: 'Impossible d''enregistrer ce(tte) %{model} : %{count} erreurs'\n  helpers:\n    select:\n      prompt: Veuillez sélectionner\n    submit:\n      create: Créer un(e) %{model}\n      submit: Enregistrer ce(tte) %{model}\n      update: Modifier ce(tte) %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \" \"\n      precision: 3\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: milliard\n          million: million\n          quadrillion: million de milliards\n          thousand: millier\n          trillion: billion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: octet\n            other: octets\n          eb: Eo\n          gb: Go\n          kb: ko\n          mb: Mo\n          pb: Po\n          tb: To\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" et \"\n      two_words_connector: \" et \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%d %B %Y %Hh %Mmin %Ss\"\n      long: \"%A %d %B %Y %Hh%M\"\n      short: \"%d %b %Hh%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/fy.yml",
    "content": "---\nfy:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Falidaasje mislearre: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Kin de registraasje net wiskje om't der %{record} ôfhinklik binne\n          has_one: Kin de registraasje net wiskje om't der in %{record} ôfhinklik\n            is\n  date:\n    abbr_day_names:\n    - si\n    - mo\n    - ti\n    - wo\n    - to\n    - fr\n    - so\n    abbr_month_names:\n    -\n    - jan\n    - feb\n    - mrt\n    - apr\n    - mai\n    - jun\n    - jul\n    - aug\n    - sep\n    - okt\n    - nov\n    - des\n    day_names:\n    - snein\n    - moandei\n    - tiisdei\n    - woansdei\n    - tongersdei\n    - freed\n    - sneon\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%e %B %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - jannewaris\n    - febrewaris\n    - maart\n    - april\n    - maaie\n    - juny\n    - july\n    - augustus\n    - septimber\n    - oktober\n    - novimber\n    - desimber\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: likernôch 1 oere\n        other: likernôch %{count} oeren\n      about_x_months:\n        one: likernôch 1 moanne\n        other: likernôch %{count} moannen\n      about_x_years:\n        one: likernôch 1 jier\n        other: likernôch %{count} jier\n      almost_x_years:\n        one: hast 1 jier\n        other: hast %{count} jier\n      half_a_minute: in heale minút\n      less_than_x_minutes:\n        one: minder as in minút\n        other: minder as %{count} minuten\n      less_than_x_seconds:\n        one: minder as 1 sekonde\n        other: minder as %{count} sekonden\n      over_x_years:\n        one: mear as 1 jier\n        other: mear as %{count} jier\n      x_days:\n        one: 1 dei\n        other: \"%{count} dagen\"\n      x_minutes:\n        one: 1 minút\n        other: \"%{count} minuten\"\n      x_months:\n        one: 1 moanne\n        other: \"%{count} moannen\"\n      x_seconds:\n        one: 1 sekonde\n        other: \"%{count} sekonden\"\n      x_years:\n        one: 1 jier\n        other: \"%{count} jier\"\n    prompts:\n      day: dei\n      hour: oere\n      minute: minút\n      month: moanne\n      second: sekonde\n      year: jier\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: moat akseptearre wurde\n      blank: kin net blanko wêze\n      confirmation: komt net oerien mei %{attribute}\n      empty: kin net leech wêze\n      equal_to: moat lyk wêze oan %{count}\n      even: moat even wêze\n      exclusion: is reservearre\n      greater_than: moat grutter wêze as %{count}\n      greater_than_or_equal_to: moat grutter wêze as, of lyk oan %{count}\n      inclusion: stiet net yn 'e list\n      invalid: is ûnjildich\n      less_than: moat lytser wêze as %{count}\n      less_than_or_equal_to: moat lytser wêze as, of lyk oan %{count}\n      model_invalid: 'Falidaasje mislearre: %{errors}'\n      not_a_number: is gjin getal\n      not_an_integer: moat in hiel getal wêze\n      odd: moat ûneven wêze\n      other_than: moat oars wêze as %{count}\n      present: moat blanko wêze\n      required: moat der wêze\n      taken: is al ynnommen\n      too_long:\n        one: is te lang (maksimum is 1 teken)\n        other: is te lang (maksimum is %{count} tekens)\n      too_short:\n        one: is te koart (minimum is 1 teken)\n        other: is te koart (minimum is %{count} tekens)\n      wrong_length:\n        one: hat de ferkearde lingte (moat 1 teken wêze)\n        other: hat de ferkearde lingte (moat %{count} tekens wêze)\n    template:\n      body: 'Der binne problemen mei de neikommende fjilden:'\n      header:\n        one: 1 flater behinderet it fêstlizzen fan %{model}\n        other: \"%{count} flaters behinderje it fêstlizzen fan %{model}\"\n  helpers:\n    select:\n      prompt: Meitsje in kar\n    submit:\n      create: \"%{model} oanmeitsje\"\n      submit: \"%{model} fêstlizze\"\n      update: \"%{model} bywurkje\"\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%u %n\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: miljard\n          million: miljoen\n          quadrillion: biljard\n          thousand: tûzen\n          trillion: biljoen\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: byte\n            other: bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" en \"\n      two_words_connector: \" en \"\n      words_connector: \", \"\n  time:\n    am: fm.\n    formats:\n      default: \"%a %e %b %Y %H.%M:%S %z\"\n      long: \"%e %B %Y om %H.%M oere\"\n      short: \"%e %b, %H.%M o.\"\n    pm: nm.\n"
  },
  {
    "path": "config/locales/defaults/gd.yml",
    "content": "---\ngd:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Dh’fhàillig leis an dearbhadh: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cha b’ urrainn dhuinn an reacord a sguabadh às on a tha %{record}\n            ann a tha an eisimeil air\n          has_one: Cha b’ urrainn dhuinn an reacord a sguabadh às on a tha %{record}\n            ann a tha an eisimeil air\n  date:\n    abbr_day_names:\n    - DiD\n    - DiL\n    - DiM\n    - DiC\n    - Dia\n    - Dih\n    - DiS\n    abbr_month_names:\n    -\n    - Faoi\n    - Gearr\n    - Màrt\n    - Gibl\n    - Cèit\n    - Ògmh\n    - Iuch\n    - Lùna\n    - Sult\n    - Dàmh\n    - Samh\n    - Dùbh\n    day_names:\n    - DiDòmhnaich\n    - DiLuain\n    - DiMàirt\n    - DiCiadain\n    - DiarDaoin\n    - DihAoine\n    - DiSathairne\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Am Faoilleach\n    - An Gearran\n    - Am Màrt\n    - An Giblean\n    - An Cèitean\n    - An t-Ògmhios\n    - An t-Iuchar\n    - An Lùnastal\n    - An t-Sultain\n    - An Dàmhair\n    - An t-Samhain\n    - An Dùbhlachd\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: mu %{count} uairean a thìde\n        one: mu %{count} uair a thìde\n        other: mu %{count} uair a thìde\n        two: mu %{count} uair a thìde\n      about_x_months:\n        few: mu %{count} mìosan\n        one: mu %{count} mhìos\n        other: mu %{count} mìos\n        two: mu %{count} mhìos\n      about_x_years:\n        few: mu %{count} bliadhnaichean\n        one: mu %{count} bhliadhna\n        other: mu %{count} bliadhna\n        two: mu %{count} bhliadhna\n      almost_x_years:\n        few: cha mhòr %{count} bliadhnaichean\n        one: cha mhòr %{count} bhliadhna\n        other: cha mhòr %{count} bliadhna\n        two: cha mhòr %{count} bhliadhna\n      half_a_minute: leth-mhionaid\n      less_than_x_minutes:\n        few: nas lugha na %{count} mionaidean\n        one: nas lugha na mionaid\n        other: nas lugha na %{count} mionaid\n        two: nas lugha na %{count} mhionaid\n      less_than_x_seconds:\n        few: nas lugha na %{count} diogan\n        one: nas lugha na %{count} diog\n        other: nas lugha na %{count} diog\n        two: nas lugha na %{count} dhiog\n      over_x_years:\n        few: còrr is %{count} bliadhnaichean\n        one: còrr is %{count} bhliadhna\n        other: còrr is %{count} bliadhna\n        two: còrr is %{count} bhliadhna\n      x_days:\n        few: \"%{count} làithean\"\n        one: \"%{count} latha\"\n        other: \"%{count} latha\"\n        two: \"%{count} latha\"\n      x_minutes:\n        few: \"%{count} mionaidean\"\n        one: \"%{count} mhionaid\"\n        other: \"%{count} mionaid\"\n        two: \"%{count} mhionaid\"\n      x_months:\n        few: \"%{count} mìosan\"\n        one: \"%{count} mhìos\"\n        other: \"%{count} mìos\"\n        two: \"%{count} mhìos\"\n      x_seconds:\n        few: \"%{count} diogan\"\n        one: \"%{count} diog\"\n        other: \"%{count} diog\"\n        two: \"%{count} dhiog\"\n      x_years:\n        few: \"%{count} bliadhnaichean\"\n        one: \"%{count} bhliadhna\"\n        other: \"%{count} bliadhna\"\n        two: \"%{count} bhliadhna\"\n    prompts:\n      day: Latha\n      hour: Uair a thìde\n      minute: Mionaid\n      month: Mìos\n      second: Diog\n      year: Bliadhna\n  errors:\n    format: \"%{attribute}: %{message}\"\n    messages:\n      accepted: feumach air aontachadh\n      blank: chan fhaod seo a bhith bàn\n      confirmation: chan eil e ’na mhaids dha %{attribute}\n      empty: chan fhaod seo a bhith falamh\n      equal_to: feumaidh seo a bhith co-ionnan ri %{count}\n      even: feumaidh seo a bhith ’na àireamh chothrom\n      exclusion: tha seo glèidhte\n      greater_than: feumaidh seo a bhith nas motha na %{count}\n      greater_than_or_equal_to: feumaidh seo a bhith nas motha na no co-ionann ri\n        %{count}\n      in: feumaidh seo a bhith am broinn %{count}\n      inclusion: chan eil seo am broinn na liosta\n      invalid: chan eil seo dligheach\n      less_than: feumaidh seo a bhith nas lugha na %{count}\n      less_than_or_equal_to: feumaidh seo a bhith nas lugha na no co-ionann ri %{count}\n      model_invalid: 'Dh’fhàillig leis an dearbhadh: %{errors}'\n      not_a_number: chan eil seo ’na àireamh\n      not_an_integer: feumaidh seo a bhith ’na àireamh shlàn\n      odd: feumaidh seo a bhith ’na àireamh chòrr\n      other_than: chan fhaod seo a bhith %{count}\n      present: feumaidh seo a bhith bàn\n      required: feumaidh seo a bhith ann\n      taken: tha seo aig rud eile mu thràth\n      too_long:\n        few: tha seo ro fhada (tha %{count} caractaran ceadaichte air a char as motha)\n        one: tha seo ro fhada (tha %{count} charactar ceadaichte air a char as motha)\n        other: tha seo ro fhada (tha %{count} caractar ceadaichte air a char as motha)\n        two: tha seo ro fhada (tha %{count} charactar ceadaichte air a char as motha)\n      too_short:\n        few: tha seo ro ghoirid (tha %{count} caractaran ceadaichte air a char as\n          lugha)\n        one: tha seo ro ghoirid (tha %{count} charactar ceadaichte air a char as lugha)\n        other: tha seo ro ghoirid (tha %{count} caractar ceadaichte air a char as\n          lugha)\n        two: tha seo ro ghoirid (tha %{count} charactar ceadaichte air a char as lugha)\n      wrong_length:\n        few: chan eil an fhaide mar bu chòir (bu chòir dha a bhith %{count} caractaran)\n        one: chan eil an fhaide mar bu chòir (bu chòir dha a bhith %{count} charactar)\n        other: chan eil an fhaide mar bu chòir (bu chòir dha a bhith %{count} caractar)\n        two: chan eil an fhaide mar bu chòir (bu chòir dha a bhith %{count} charactar)\n    template:\n      body: 'Bha duilgheadasan ann leis na raointean seo:'\n      header:\n        few: Dh’adhbharaich %{count} mearachdan nach gabh a’ %{model} seo a shàbhaladh\n        one: Dh’adhbharaich %{count} mhearachd nach gabh a’ %{model} seo a shàbhaladh\n        other: Dh’adhbharaich %{count} mearachd nach gabh a’ %{model} seo a shàbhaladh\n        two: Dh’adhbharaich %{count} mhearachd nach gabh a’ %{model} seo a shàbhaladh\n  helpers:\n    select:\n      prompt: Tagh\n    submit:\n      create: Cruthaich %{model}\n      submit: Sàbhail %{model}\n      update: Ùraich %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"£\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: billean\n          million: millean\n          quadrillion: quadrillean\n          thousand: mìle\n          trillion: trillean\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: baidhtean\n            one: bhaidht\n            other: baidht\n            two: bhaidht\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" agus \"\n      two_words_connector: \" agus \"\n      words_connector: \", \"\n  time:\n    am: m\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %b %Y, %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: f\n"
  },
  {
    "path": "config/locales/defaults/gl.yml",
    "content": "---\ngl:\n  date:\n    abbr_day_names:\n    - Dom\n    - Lun\n    - Mar\n    - Mer\n    - Xov\n    - Ven\n    - Sab\n    abbr_month_names:\n    -\n    - Xan\n    - Feb\n    - Mar\n    - Abr\n    - Mai\n    - Xuñ\n    - Xul\n    - Ago\n    - Set\n    - Out\n    - Nov\n    - Dec\n    day_names:\n    - Domingo\n    - Luns\n    - Martes\n    - Mércores\n    - Xoves\n    - Venres\n    - Sábado\n    formats:\n      default: \"%e/%m/%Y\"\n      long: \"%A %e de %B de %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - Xaneiro\n    - Febreiro\n    - Marzo\n    - Abril\n    - Maio\n    - Xuño\n    - Xullo\n    - Agosto\n    - Setembro\n    - Outubro\n    - Novembro\n    - Decembro\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: aproximadamente unha hora\n        other: \"%{count} horas\"\n      about_x_months:\n        one: aproximadamente %{count} mes\n        other: \"%{count} meses\"\n      about_x_years:\n        one: aproximadamente %{count} ano\n        other: \"%{count} anos\"\n      half_a_minute: medio minuto\n      less_than_x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n        zero: menos dun minuto\n      less_than_x_seconds:\n        few: poucos segundos\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n        zero: menos dun segundo\n      over_x_years:\n        one: máis dun ano\n        other: \"%{count} anos\"\n      x_days:\n        one: \"%{count} día\"\n        other: \"%{count} días\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minuto\"\n      x_months:\n        one: \"%{count} mes\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: debe ser aceptado\n      blank: non pode estar en branco\n      confirmation: non coincide coa confirmación\n      empty: non pode estar baleiro\n      equal_to: debe ser igual a %{count}\n      even: debe ser impar\n      exclusion: xa existe\n      greater_than: debe ser maior que %{count}\n      greater_than_or_equal_to: debe ser maior ou igual que %{count}\n      inclusion: non está incluído na lista\n      invalid: non é válido\n      less_than: debe ser menor que %{count}\n      less_than_or_equal_to: debe ser menor ou igual que %{count}\n      not_a_number: non é un número\n      odd: debe ser par\n      taken: non está dispoñible\n      too_long: é demasiado longo (non máis de %{count} carácteres)\n      too_short: é demasiado curto (non menos de %{count} carácteres)\n      wrong_length: non ten a lonxitude correcta (debe ser de %{count} carácteres)\n    template:\n      body: 'Atopáronse os seguintes problemas:'\n      header:\n        one: \"%{count} erro evitou que se puidese gardar o %{model}\"\n        other: \"%{count} erros evitaron que se puidese gardar o %{model}\"\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" e \"\n      two_words_connector: \" e \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A, %e de %B de %Y ás %H:%M\"\n      long: \"%A %e de %B de %Y ás %H:%M\"\n      short: \"%e/%m, %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/he.yml",
    "content": "---\nhe:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'האימות נכשל: %{errors}'\n  date:\n    abbr_day_names:\n    - א\n    - ב\n    - ג\n    - ד\n    - ה\n    - ו\n    - ש\n    abbr_month_names:\n    -\n    - ינו\n    - פבר\n    - מרץ\n    - אפר\n    - מאי\n    - יונ\n    - יול\n    - אוג\n    - ספט\n    - אוק\n    - נוב\n    - דצמ\n    day_names:\n    - ראשון\n    - שני\n    - שלישי\n    - רביעי\n    - חמישי\n    - שישי\n    - שבת\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%e ב%B, %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - ינואר\n    - פברואר\n    - מרץ\n    - אפריל\n    - מאי\n    - יוני\n    - יולי\n    - אוגוסט\n    - ספטמבר\n    - אוקטובר\n    - נובמבר\n    - דצמבר\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: בערך שעה אחת\n        other: בערך %{count} שעות\n      about_x_months:\n        one: בערך חודש אחד\n        other: בערך %{count} חודשים\n      about_x_years:\n        one: בערך שנה אחת\n        other: בערך %{count} שנים\n      almost_x_years:\n        one: כמעט שנה\n        other: כמעט %{count} שנים\n      half_a_minute: חצי דקה\n      less_than_x_minutes:\n        one: פחות מדקה אחת\n        other: פחות מ- %{count} דקות\n        zero: פחות מדקה אחת\n      less_than_x_seconds:\n        one: פחות משניה אחת\n        other: פחות מ- %{count} שניות\n        zero: פחות משניה אחת\n      over_x_years:\n        one: מעל שנה אחת\n        other: מעל %{count} שנים\n      x_days:\n        one: יום אחד\n        other: \"%{count} ימים\"\n      x_minutes:\n        one: דקה אחת\n        other: \"%{count} דקות\"\n      x_months:\n        one: חודש אחד\n        other: \"%{count} חודשים\"\n      x_seconds:\n        one: שניה אחת\n        other: \"%{count} שניות\"\n    prompts:\n      day: יום\n      hour: שעה\n      minute: דקה\n      month: חודש\n      second: שניות\n      year: שנה\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: חייב באישור\n      blank: לא יכול להיות ריק\n      confirmation: לא תואם לאישורו\n      empty: לא יכול להיות ריק\n      equal_to: חייב להיות שווה ל- %{count}\n      even: חייב להיות זוגי\n      exclusion: לא זמין\n      greater_than: חייב להיות גדול מ- %{count}\n      greater_than_or_equal_to: חייב להיות גדול או שווה ל- %{count}\n      inclusion: לא נכלל ברשימה\n      invalid: לא תקין\n      less_than: חייב להיות קטן מ- %{count}\n      less_than_or_equal_to: חייב להיות קטן או שווה ל- %{count}\n      not_a_number: חייב להיות מספר\n      not_an_integer: חייב להיות מספר שלם\n      odd: חייב להיות אי זוגי\n      taken: כבר בשימוש\n      too_long: ארוך מדי (יותר מ- %{count} תווים)\n      too_short: קצר מדי (פחות מ- %{count} תווים)\n      wrong_length: לא באורך הנכון (חייב להיות %{count} תווים)\n    template:\n      body: 'אנא בדוק את השדות הבאים:'\n      header:\n        one: 'לא ניתן לשמור את ה%{model}: שגיאה אחת'\n        other: 'לא ניתן לשמור את ה%{model}: %{count} שגיאות.'\n  helpers:\n    select:\n      prompt: נא לבחור\n    submit:\n      create: יצירת %{model}\n      submit: שמור %{model}\n      update: עדכון %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"₪\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: מיליארד\n          million: מיליון\n          quadrillion: קודריליון\n          thousand: אלף\n          trillion: טריליון\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: בייט\n            other: בתים\n          gb: ג'יגה-בייט\n          kb: קילו-בייט\n          mb: מגה-בייט\n          tb: טרה-בייט\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" ו\"\n      two_words_connector: \" ו\"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a %d %b %H:%M:%S %Z %Y\"\n      long: \"%d ב%B, %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/hi-IN.yml",
    "content": "---\nhi-IN:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'सत्यापन विफल: %{errors}'\n  date:\n    abbr_day_names:\n    - रवि\n    - सोम\n    - मंगल\n    - बुध\n    - गुरु\n    - शुक्र\n    - शनि\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - रविवार\n    - सोमवार\n    - मंगलवार\n    - बुधवार\n    - गुरुवार\n    - शुक्रवार\n    - शनिवार\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - जनवरी\n    - फरवरी\n    - मार्च\n    - अप्रैल\n    - मई\n    - जून\n    - जुलाई\n    - अगस्त\n    - सितंबर\n    - अक्टूबर\n    - नवंबर\n    - दिसंबर\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: लग - भग एक घंटा\n        other: लग - भग %{count} घंटा\n      about_x_months:\n        one: लग - भग %{count} महीना\n        other: लग - भग %{count} महीना\n      about_x_years:\n        one: लग - भग %{count} साल\n        other: लग - भग %{count} साल\n      almost_x_years:\n        one: लग - भग एक साल\n        other: लग - भग %{count} साल\n      half_a_minute: एक आधा मिनट\n      less_than_x_minutes:\n        one: एक मिनट से कम\n        other: \"%{count} मिनट से कम\"\n      less_than_x_seconds:\n        one: एक सेकंड से कम\n        other: \"%{count}  सेकंड से कम\"\n      over_x_years:\n        one: एक साल के ऊपर\n        other: \"%{count} साल के ऊपर\"\n      x_days:\n        one: एक दिन\n        other: \"%{count} दिन\"\n      x_minutes:\n        one: एक मिनट\n        other: \"%{count} मिनट\"\n      x_months:\n        one: एक महीना\n        other: \"%{count} महीना\"\n      x_seconds:\n        one: एक सेकंड\n        other: \"%{count} सेकंड\"\n    prompts:\n      day: दिन\n      hour: घंटा\n      minute: क्षण\n      month: माह\n      second: सेकंड\n      year: वर्ष\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: होना स्वीकार किया जाना आवश्यक\n      blank: खाली नहीं किया जा सकता\n      confirmation: पुष्टिकरण मेल नहीं खाता\n      empty: खाली नहीं किया जा सकता\n      equal_to: \"%{count} के लिए बराबर होना चाहिए\"\n      even: सम होना चाहिए\n      exclusion: आरक्षित है\n      greater_than: \"%{count} से अधिक होना चाहिए\"\n      greater_than_or_equal_to: \"%{count} से बड़ा या बराबर होना आवश्यक है\"\n      inclusion: सूची में शामिल नहीं है\n      invalid: अवैध है\n      less_than: \"%{count} से कम होना चाहिए\"\n      less_than_or_equal_to: \"%{count} से कम या बराबर होना आवश्यक है\"\n      not_a_number: कोई संख्या नहीं है\n      not_an_integer: एक पूर्णांक होना चाहिए\n      odd: विषम होना चाहिए\n      taken: पहले ही ले लिया गया है\n      too_long: बहुत लंबा है (अधिकतम %{count} अक्षरों है)\n      too_short: बहुत छोटा है (न्यूनतम %{count} अक्षरों है)\n      wrong_length: गलत लंबाई है (%{count} वर्ण वाले होने चाहिए)\n    template:\n      body: 'वहाँ निम्नलिखित क्षेत्रों के साथ समस्याओं रहे थे:'\n      header:\n        one: एक त्रुटि सहेजे जाने से इस %{model} को निषिद्ध\n        other: \"%{count} त्रुटियों को सहेजे जाने से इस %{model} निषिद्ध\"\n  helpers:\n    select:\n      prompt: कृपया चुनें\n    submit:\n      create: बनाएँ %{model}\n      submit: सहेजें %{model}\n      update: अद्यतन %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"₹\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: अरब\n          million: मिल्लिओंन\n          quadrillion: करोड़ शंख\n          thousand: हज़ार\n          trillion: खरब\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", और \"\n      two_words_connector: \" और \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/hi.yml",
    "content": "---\nhi:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'सत्यापन विफल: %{errors}'\n  date:\n    abbr_day_names:\n    - रवि\n    - सोम\n    - मंगल\n    - बुध\n    - गुरु\n    - शुक्र\n    - शनि\n    abbr_month_names:\n    -\n    - जन\n    - फर\n    - मार्च\n    - अप्रै\n    - मई\n    - जून\n    - जुला\n    - अग\n    - सितं\n    - अक्टू\n    - नवं\n    - दिस\n    day_names:\n    - रविवार\n    - सोमवार\n    - मंगलवार\n    - बुधवार\n    - गुरुवार\n    - शुक्रवार\n    - शनिवार\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - जनवरी\n    - फरवरी\n    - मार्च\n    - अप्रैल\n    - मई\n    - जून\n    - जुलाई\n    - अगस्त\n    - सितंबर\n    - अक्टूबर\n    - नवंबर\n    - दिसंबर\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: लगभग एक घंटा\n        other: लगभग %{count} घंटा\n      about_x_months:\n        one: लगभग %{count} महीना\n        other: लगभग %{count} महीना\n      about_x_years:\n        one: लगभग %{count} साल\n        other: लगभग %{count} साल\n      almost_x_years:\n        one: लगभग एक साल\n        other: लगभग %{count} साल\n      half_a_minute: एक आधा मिनट\n      less_than_x_minutes:\n        one: एक मिनट से कम\n        other: \"%{count} मिनट से कम\"\n      less_than_x_seconds:\n        one: एक सेकेंड से कम\n        other: \"%{count}  सेकेंड से कम\"\n      over_x_years:\n        one: एक साल के ऊपर\n        other: \"%{count} साल से अधिक\"\n      x_days:\n        one: एक दिन\n        other: \"%{count} दिन\"\n      x_minutes:\n        one: एक मिनट\n        other: \"%{count} मिनट\"\n      x_months:\n        one: एक महीना\n        other: \"%{count} महीना\"\n      x_seconds:\n        one: एक सेकेंड\n        other: \"%{count} सेकेंड\"\n    prompts:\n      day: दिन\n      hour: घंटा\n      minute: मिनट\n      month: माह\n      second: सेकेंड\n      year: वर्ष\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: स्वीकार किया जाना जरूरी\n      blank: खाली नहीं रह सकता है\n      confirmation: पुष्टिकरण मेल नहीं खाता\n      empty: रिक्त नहीं रह सकता है\n      equal_to: \"%{count} के लिए बराबर होना चाहिए\"\n      even: सम होना चाहिए\n      exclusion: आरक्षित है\n      greater_than: \"%{count} से अधिक होना चाहिए\"\n      greater_than_or_equal_to: \"%{count} से बड़ा या बराबर होना आवश्यक है\"\n      inclusion: सूची में शामिल नहीं है\n      invalid: अवैध है\n      less_than: \"%{count} से कम होना चाहिए\"\n      less_than_or_equal_to: \"%{count} से कम या बराबर होना आवश्यक है\"\n      not_a_number: कोई संख्या नहीं है\n      not_an_integer: एक पूर्णांक होना चाहिए\n      odd: विसम होना चाहिए\n      taken: पहले ही ले लिया गया है\n      too_long: अत्यधिक लंबा है (अधिकतम %{count} वर्ण हैं)\n      too_short: अत्यधिक छोटा है (न्यूनतम %{count} वर्ण हैं)\n      wrong_length: गलत लंबाई है (%{count} वर्ण युक्त होना चाहिए)\n    template:\n      body: 'निम्नलिखित क्षेत्रों के साथ समस्या थी:'\n      header:\n        one: इस %{model} को सहेजे जाना एक त्रुटि के कारण नहीं हुआ\n        other: इस %{model} को सहेजे जाना %{count} त्रुटि के कारण नहीं हुआ\n  helpers:\n    select:\n      prompt: कृपया चुनें\n    submit:\n      create: \"%{model} बनाएँ\"\n      submit: \"%{model} सौंपें\"\n      update: \"%{model} अद्यतन\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"₹\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: अरब\n          million: दस करोड़\n          quadrillion: करोड़ शंख\n          thousand: हज़ार\n          trillion: खरब\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", और \"\n      two_words_connector: \" और \"\n      words_connector: \", \"\n  time:\n    am: पूर्वाह्न\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: अपराह्न\n"
  },
  {
    "path": "config/locales/defaults/hr.yml",
    "content": "---\nhr:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validacija nije uspjela: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Nije moguće izbrisati zapis jer postoje ovisni %{record}\n          has_one: Nije moguće izbrisati zapis jer postoji ovisan %{record}\n  date:\n    abbr_day_names:\n    - ned.\n    - pon.\n    - uto.\n    - sri.\n    - čet.\n    - pet.\n    - sub.\n    abbr_month_names:\n    -\n    - sij.\n    - velj.\n    - ožu.\n    - tra.\n    - svi.\n    - lip.\n    - srp.\n    - kol.\n    - ruj.\n    - lis.\n    - stu.\n    - pro.\n    day_names:\n    - nedjelja\n    - ponedjeljak\n    - utorak\n    - srijeda\n    - četvrtak\n    - petak\n    - subota\n    formats:\n      default: \"%d.%m.%Y.\"\n      long: \"%e. %B %Y.\"\n      short: \"%e.%-m.\"\n    month_names:\n    -\n    - siječnja\n    - veljače\n    - ožujka\n    - travnja\n    - svibnja\n    - lipnja\n    - srpnja\n    - kolovoza\n    - rujna\n    - listopada\n    - studenoga\n    - prosinca\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: oko %{count} sata\n        many: oko %{count} sati\n        one: oko %{count} sat\n        other: oko %{count} sati\n      about_x_months:\n        few: oko %{count} mjeseca\n        many: oko %{count} mjeseci\n        one: oko %{count} mjesec\n        other: oko %{count} mjeseci\n      about_x_years:\n        few: oko %{count} godine\n        many: oko %{count} godina\n        one: oko %{count} godine\n        other: oko %{count} godina\n      almost_x_years:\n        few: skoro %{count} godine\n        many: skoro %{count} godina\n        one: skoro %{count} godina\n        other: skoro %{count} godina\n      half_a_minute: pola minute\n      less_than_x_minutes:\n        few: manje od %{count} minute\n        many: manje od %{count} minuta\n        one: manje od %{count} minute\n        other: manje od %{count} minuta\n      less_than_x_seconds:\n        few: manje od %{count} sekunde\n        many: manje od %{count} sekundi\n        one: manje od %{count} sekunde\n        other: manje od %{count} sekundi\n      over_x_years:\n        few: preko %{count} godine\n        many: preko %{count} godina\n        one: preko %{count} godine\n        other: preko %{count} godina\n      x_days:\n        few: \"%{count} dana\"\n        many: \"%{count} dana\"\n        one: \"%{count} dan\"\n        other: \"%{count} dana\"\n      x_minutes:\n        few: \"%{count} minute\"\n        many: \"%{count} minuta\"\n        one: \"%{count} minuta\"\n        other: \"%{count} minuta\"\n      x_months:\n        few: \"%{count} mjeseca\"\n        many: \"%{count} mjeseci\"\n        one: \"%{count} mjesec\"\n        other: \"%{count} mjeseci\"\n      x_seconds:\n        few: \"%{count} sekunde\"\n        many: \"%{count} sekundi\"\n        one: \"%{count} sekunda\"\n        other: \"%{count} sekundi\"\n      x_years:\n        few: \"%{count} godine\"\n        many: \"%{count} godina\"\n        one: \"%{count} godina\"\n        other: \"%{count} godina\"\n    prompts:\n      day: Dan\n      hour: Sat\n      minute: Minuta\n      month: Mjesec\n      second: Sekunde\n      year: Godina\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: mora biti prihvaćen\n      blank: ne smije biti prazan\n      confirmation: se ne slaže sa svojom potvrdom\n      empty: ne smije biti prazan\n      equal_to: mora biti jednak %{count}\n      even: mora biti paran\n      exclusion: je rezerviran\n      greater_than: mora biti veći od %{count}\n      greater_than_or_equal_to: mora biti veći ili jednak %{count}\n      inclusion: nije u listi\n      invalid: nije ispravan\n      less_than: mora biti manji od %{count}\n      less_than_or_equal_to: mora biti manji ili jednak %{count}\n      model_invalid: 'Validacija nije uspjela: %{errors}'\n      not_a_number: nije broj\n      not_an_integer: nije cijeli broj\n      odd: mora biti neparan\n      other_than: mora biti različit od %{count}\n      present: mora biti prazan\n      required: mora biti unesen\n      taken: je već zauzet\n      too_long:\n        few: je predugačak (maksimum je %{count} znaka)\n        many: je predugačak (maksimum je %{count} znakova)\n        one: je predugačak (maksimum je %{count} znak)\n        other: je predugačak (maksimum je %{count} znakova)\n      too_short:\n        few: je prekratak (minimum je %{count} znaka)\n        many: je prekratak (minimum je %{count} znakova)\n        one: je prekratak (minimum je %{count} znak)\n        other: je prekratak (minimum je %{count} znakova)\n      wrong_length:\n        few: nije odgovarajuće duljine (treba biti %{count} znaka)\n        many: nije odgovarajuće duljine (treba biti %{count} znakova)\n        one: nije odgovarajuće duljine (treba biti %{count} znak)\n        other: nije odgovarajuće duljine (treba biti %{count} znakova)\n    template:\n      body: 'Sljedeća polja su neispravno popunjena:'\n      header:\n        few: \"%{count} greške su spriječile da se spremi %{model}\"\n        many: \"%{count} grešaka je spriječilo da se spremi %{model}\"\n        one: \"%{count} greška je spriječila da se spremi %{model}\"\n        other: \"%{count} grešaka je spriječilo da se spremi %{model}\"\n  helpers:\n    select:\n      prompt: Izaberite\n    submit:\n      create: Stvori %{model}\n      submit: Spremi %{model}\n      update: Izmijeni %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: kn\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: milijarda\n          million: milijun\n          quadrillion: bilijarda\n          thousand: tisuća\n          trillion: bilijun\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: bajta\n            many: bajtova\n            one: bajt\n            other: bajtova\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" i \"\n      two_words_connector: \" i \"\n      words_connector: \", \"\n  time:\n    am: AM\n    formats:\n      default: \"%d.%m.%Y. %H:%M:%S\"\n      long: \"%e. %B %Y. %H:%M\"\n      short: \"%e.%-m. %k:%M\"\n    pm: PM\n"
  },
  {
    "path": "config/locales/defaults/hu.yml",
    "content": "---\nhu:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: Sikertelen validálás %{errors}\n  date:\n    abbr_day_names:\n    - v.\n    - h.\n    - k.\n    - sze.\n    - cs.\n    - p.\n    - szo.\n    abbr_month_names:\n    -\n    - jan.\n    - febr.\n    - márc.\n    - ápr.\n    - máj.\n    - jún.\n    - júl.\n    - aug.\n    - szept.\n    - okt.\n    - nov.\n    - dec.\n    day_names:\n    - vasárnap\n    - hétfő\n    - kedd\n    - szerda\n    - csütörtök\n    - péntek\n    - szombat\n    formats:\n      default: \"%Y.%m.%d.\"\n      long: \"%Y. %B %e.\"\n      short: \"%b %e.\"\n    month_names:\n    -\n    - január\n    - február\n    - március\n    - április\n    - május\n    - június\n    - július\n    - augusztus\n    - szeptember\n    - október\n    - november\n    - december\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: kb. %{count} órája\n        other: kb. %{count} órája\n      about_x_months:\n        one: kb. %{count} hónapja\n        other: kb. %{count} hónapja\n      about_x_years:\n        one: kb. %{count} éve\n        other: kb. %{count} éve\n      almost_x_years:\n        one: majdnem %{count} éve\n        other: majdnem %{count} éve\n      half_a_minute: fél perce\n      less_than_x_minutes:\n        one: kevesebb, mint %{count} perce\n        other: kevesebb, mint %{count} perce\n      less_than_x_seconds:\n        one: kevesebb, mint %{count} másodperce\n        other: kevesebb, mint %{count} másodperce\n      over_x_years:\n        one: több, mint %{count} éve\n        other: több, mint %{count} éve\n      x_days:\n        one: \"%{count} napja\"\n        other: \"%{count} napja\"\n      x_minutes:\n        one: \"%{count} perce\"\n        other: \"%{count} perce\"\n      x_months:\n        one: \"%{count} hónapja\"\n        other: \"%{count} hónapja\"\n      x_seconds:\n        one: \"%{count} másodperce\"\n        other: \"%{count} másodperce\"\n    prompts:\n      day: Nap\n      hour: Óra\n      minute: Perc\n      month: Hónap\n      second: Másodperc\n      year: Év\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: nincs elfogadva\n      blank: nincs megadva\n      confirmation: nem egyezik\n      empty: nincs megadva\n      equal_to: pontosan %{count} kell legyen\n      even: páros kell legyen\n      exclusion: nem elérhető\n      greater_than: nagyobb kell legyen, mint %{count}\n      greater_than_or_equal_to: legalább %{count} kell legyen\n      inclusion: nincs a listában\n      invalid: nem megfelelő\n      less_than: kevesebb, mint %{count} kell legyen\n      less_than_or_equal_to: legfeljebb %{count} lehet\n      not_a_number: nem szám\n      not_an_integer: egész számnak kell lennie\n      odd: páratlan kell legyen\n      taken: már foglalt\n      too_long: túl hosszú (nem lehet több %{count} karakternél)\n      too_short: túl rövid (legalább %{count} karakter kell legyen)\n      wrong_length: nem megfelelő hosszúságú (%{count} karakter szükséges)\n    template:\n      body: 'Problémás mezők:'\n      header:\n        one: \"%{count} hiba miatt nem menthető a következő: %{model}\"\n        other: \"%{count} hiba miatt nem menthető a következő: %{model}\"\n  helpers:\n    select:\n      prompt: Válasszon\n    submit:\n      create: Új %{model}\n      submit: \"%{model} mentése\"\n      update: \"%{model} módosítása\"\n  number:\n    currency:\n      format:\n        delimiter: ''\n        format: \"%n %u\"\n        precision: 0\n        separator: \",\"\n        significant: true\n        strip_insignificant_zeros: true\n        unit: Ft\n    format:\n      delimiter: \" \"\n      precision: 2\n      separator: \",\"\n      significant: true\n      strip_insignificant_zeros: true\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: milliárd\n          million: millió\n          quadrillion: billiárd\n          thousand: ezer\n          trillion: billió\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: bájt\n            other: bájt\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" és \"\n      two_words_connector: \" és \"\n      words_connector: \", \"\n  time:\n    am: de.\n    formats:\n      default: \"%Y. %b %e., %H:%M\"\n      long: \"%Y. %B %e., %A, %H:%M\"\n      short: \"%b %e., %H:%M\"\n    pm: du.\n"
  },
  {
    "path": "config/locales/defaults/id.yml",
    "content": "---\nid:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validasi gagal: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Tidak bisa menghapus record karena terdapat %{record} yang bergantung\n          has_one: Tidak bisa menghapus record karena terdapat satu %{record} yang\n            bergantung\n  date:\n    abbr_day_names:\n    - Min\n    - Sen\n    - Sel\n    - Rab\n    - Kam\n    - Jum\n    - Sab\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - Mei\n    - Jun\n    - Jul\n    - Agu\n    - Sep\n    - Okt\n    - Nov\n    - Des\n    day_names:\n    - Minggu\n    - Senin\n    - Selasa\n    - Rabu\n    - Kamis\n    - Jumat\n    - Sabtu\n    formats:\n      default: \"%d %B %Y\"\n      long: \"%A, %d %B %Y\"\n      short: \"%d.%m.%Y\"\n    month_names:\n    -\n    - Januari\n    - Februari\n    - Maret\n    - April\n    - Mei\n    - Juni\n    - Juli\n    - Agustus\n    - September\n    - Oktober\n    - November\n    - Desember\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: sekitar satu jam\n        other: sekitar %{count} jam\n      about_x_months:\n        one: sekitar sebulan\n        other: sekitar %{count} bulan\n      about_x_years:\n        one: sekitar setahun\n        other: sekitar %{count} tahun\n      almost_x_years:\n        one: hampir setahun\n        other: hampir %{count} tahun\n      half_a_minute: setengah menit\n      less_than_x_minutes:\n        one: kurang dari %{count} menit\n        other: kurang dari %{count} menit\n        zero: kurang dari 1 menit\n      less_than_x_seconds:\n        one: kurang dari %{count} detik\n        other: kurang dari %{count} detik\n        zero: kurang dari 1 detik\n      over_x_years:\n        one: lebih dari setahun\n        other: lebih dari %{count} tahun\n      x_days:\n        one: sehari\n        other: \"%{count} hari\"\n      x_minutes:\n        one: satu menit\n        other: \"%{count} menit\"\n      x_months:\n        one: sebulan\n        other: \"%{count} bulan\"\n      x_seconds:\n        one: satu detik\n        other: \"%{count} detik\"\n    prompts:\n      day: Hari\n      hour: Jam\n      minute: Menit\n      month: Bulan\n      second: Detik\n      year: Tahun\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: harus diterima\n      blank: tidak boleh kosong\n      confirmation: tidak sesuai dengan %{attribute}\n      empty: tidak boleh kosong\n      equal_to: harus sama dengan %{count}\n      even: harus genap\n      exclusion: sudah digunakan\n      greater_than: harus lebih besar dari %{count}\n      greater_than_or_equal_to: harus sama atau lebih besar dari %{count}\n      inclusion: tidak termasuk\n      invalid: tidak valid\n      less_than: harus lebih kecil dari %{count}\n      less_than_or_equal_to: harus sama atau lebih kecil dari %{count}\n      model_invalid: 'Validasi gagal: %{errors}'\n      not_a_number: bukan angka\n      not_an_integer: harus bilangan bulat\n      odd: harus ganjil\n      other_than: harus selain %{count}\n      present: harus kosong\n      required: harus ada\n      taken: sudah digunakan\n      too_long:\n        one: terlalu panjang (maksimum %{count} karakter)\n        other: terlalu panjang (maksimum %{count} karakter)\n      too_short:\n        one: terlalu pendek (minimum %{count} karakter)\n        other: terlalu pendek (minimum %{count} karakter)\n      wrong_length:\n        one: jumlah karakter salah (seharusnya %{count} karakter)\n        other: jumlah karakter salah (seharusnya %{count} karakter)\n    template:\n      body: 'Ada masalah dengan field berikut:'\n      header:\n        one: \"%{count} kesalahan mengakibatkan %{model} ini tidak bisa disimpan\"\n        other: \"%{count} kesalahan mengakibatkan %{model} ini tidak bisa disimpan\"\n  helpers:\n    select:\n      prompt: Silakan pilih\n    submit:\n      create: Buat %{model}\n      submit: Simpan %{model}\n      update: Perbarui %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: Rp\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Miliar\n          million: Juta\n          quadrillion: Quadriliun\n          thousand: Ribu\n          trillion: Triliun\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Byte\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", dan \"\n      two_words_connector: \" dan \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H.%M.%S %z\"\n      long: \"%d %B %Y %H.%M\"\n      short: \"%d %b %H.%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/is.yml",
    "content": "---\nis:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Villur: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Ekki hægt að eyða færslur því %{record} sem hún er háð er til\n          has_one: Ekki hægt að eyða færslu því %{record} sem hún er háð er til\n  date:\n    abbr_day_names:\n    - sun\n    - mán\n    - þri\n    - mið\n    - fim\n    - fös\n    - lau\n    abbr_month_names:\n    -\n    - jan\n    - feb\n    - mar\n    - apr\n    - maí\n    - jún\n    - júl\n    - ágú\n    - sep\n    - okt\n    - nóv\n    - des\n    day_names:\n    - sunnudaginn\n    - mánudaginn\n    - þriðjudaginn\n    - miðvikudaginn\n    - fimmtudaginn\n    - föstudaginn\n    - laugardaginn\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%e. %B %Y\"\n      short: \"%e. %b\"\n    month_names:\n    -\n    - janúar\n    - febrúar\n    - mars\n    - apríl\n    - maí\n    - júní\n    - júlí\n    - ágúst\n    - september\n    - október\n    - nóvember\n    - desember\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: u.þ.b. %{count} klukkustund\n        other: u.þ.b. %{count} klukkustundir\n      about_x_months:\n        one: u.þ.b. %{count} mánuður\n        other: u.þ.b. %{count} mánuðir\n      about_x_years:\n        one: u.þ.b. %{count} ár\n        other: u.þ.b. %{count} ár\n      almost_x_years:\n        one: næstum %{count} ár\n        other: næstum %{count} ár\n      half_a_minute: hálf mínúta\n      less_than_x_minutes:\n        one: minna en %{count} mínúta\n        other: minna en %{count} mínútur\n      less_than_x_seconds:\n        one: minna en %{count} sekúnda\n        other: minna en %{count} sekúndur\n      over_x_years:\n        one: meira en %{count} ár\n        other: meira en %{count} ár\n      x_days:\n        one: \"%{count} dagur\"\n        other: \"%{count} dagar\"\n      x_minutes:\n        one: \"%{count} mínúta\"\n        other: \"%{count} mínútur\"\n      x_months:\n        one: \"%{count} mánuður\"\n        other: \"%{count} mánuðir\"\n      x_seconds:\n        one: \"%{count} sekúnda\"\n        other: \"%{count} sekúndur\"\n    prompts:\n      day: Dagur\n      hour: Klukkustund\n      minute: Mínúta\n      month: Mánuður\n      second: Sekúnda\n      year: Ár\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: þarf að vera tekið gilt\n      blank: má ekki vera autt\n      confirmation: er ekki jafngilt staðfestingunni\n      empty: má ekki vera tómt\n      equal_to: þarf að vera jafngilt %{count}\n      even: þarf að vera slétt tala\n      exclusion: er frátekið\n      greater_than: þarf að vera stærri en %{count}\n      greater_than_or_equal_to: þarf að vera stærri en eða jafngilt %{count}\n      inclusion: er ekki í listanum\n      invalid: er ógilt\n      less_than: þarf að vera minna en %{count}\n      less_than_or_equal_to: þarf að vera minna en eða jafngilt %{count}\n      not_a_number: er ekki tala\n      not_an_integer: þarf að vera heiltala\n      odd: þarf að vera oddatala\n      other_than: verður að vera annað en %{count}\n      present: verður að vera autt\n      taken: er þegar í notkun\n      too_long:\n        one: er of langt (má mest vera %{count} stafur)\n        other: er of langt (má mest vera %{count} stafir)\n      too_short:\n        one: er of stutt (má minnst vera %{count} stafur)\n        other: er of stutt (má minnst vera %{count} stafir)\n      wrong_length:\n        one: er af rangri lengd (má mest vera %{count} stafur)\n        other: er af rangri lengd (má mest vera %{count} stafir)\n    template:\n      body: 'Villur fundust í eftirfarandi dálkum:'\n      header:\n        one: Ekki var hægt að vista %{model} vegna einnar villu.\n        other: Ekki var hægt að vista %{model} vegna %{count} villna.\n  helpers:\n    select:\n      prompt: Veldu\n    submit:\n      create: Búa til %{model}\n      submit: Vista %{model}\n      update: Uppfæra %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: kr.\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: milljarður\n            other: milljarðar\n          million:\n            one: milljón\n            other: milljónir\n          quadrillion:\n            one: billjarður\n            other: billjarðar\n          thousand: þúsund\n          trillion:\n            one: billjón\n            other: billjónir\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: bæti\n            other: bæti\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" og \"\n      two_words_connector: \" og \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A %e. %B %Y kl. %H:%M\"\n      long: \"%A %e. %B %Y kl. %H:%M\"\n      short: \"%e. %B kl. %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/it-CH.yml",
    "content": "---\nit-CH:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validazione fallita: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Il record non può essere cancellato perchè esistono %{record}\n            dipendenti\n          has_one: Il record non può essere cancellato perchè esiste un %{record}\n            dipendente\n  date:\n    abbr_day_names:\n    - Dom\n    - Lun\n    - Mar\n    - Mer\n    - Gio\n    - Ven\n    - Sab\n    abbr_month_names:\n    -\n    - Gen\n    - Feb\n    - Mar\n    - Apr\n    - Mag\n    - Giu\n    - Lug\n    - Ago\n    - Set\n    - Ott\n    - Nov\n    - Dic\n    day_names:\n    - Domenica\n    - Lunedì\n    - Martedì\n    - Mercoledì\n    - Giovedì\n    - Venerdì\n    - Sabato\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Gennaio\n    - Febbraio\n    - Marzo\n    - Aprile\n    - Maggio\n    - Giugno\n    - Luglio\n    - Agosto\n    - Settembre\n    - Ottobre\n    - Novembre\n    - Dicembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: circa un'ora\n        other: circa %{count} ore\n      about_x_months:\n        one: circa un mese\n        other: circa %{count} mesi\n      about_x_years:\n        one: circa un anno\n        other: circa %{count} anni\n      almost_x_years:\n        one: circa %{count} anno\n        other: circa %{count} anni\n      half_a_minute: mezzo minuto\n      less_than_x_minutes:\n        one: meno di un minuto\n        other: meno di %{count} minuti\n      less_than_x_seconds:\n        one: meno di un secondo\n        other: meno di %{count} secondi\n      over_x_years:\n        one: oltre un anno\n        other: oltre %{count} anni\n      x_days:\n        one: \"%{count} giorno\"\n        other: \"%{count} giorni\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minuti\"\n      x_months:\n        one: \"%{count} mese\"\n        other: \"%{count} mesi\"\n      x_seconds:\n        one: \"%{count} secondo\"\n        other: \"%{count} secondi\"\n      x_years:\n        one: \"%{count} anno\"\n        other: \"%{count} anni\"\n    prompts:\n      day: Giorno\n      hour: Ora\n      minute: Minuto\n      month: Mese\n      second: Secondi\n      year: Anno\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: deve essere accettata\n      blank: non può essere lasciato in bianco\n      confirmation: non coincide con %{attribute}\n      empty: non può essere vuoto\n      equal_to: deve essere uguale a %{count}\n      even: deve essere pari\n      exclusion: è riservato\n      greater_than: deve essere superiore a %{count}\n      greater_than_or_equal_to: deve essere superiore o uguale a %{count}\n      inclusion: non è incluso nella lista\n      invalid: non è valido\n      less_than: deve essere meno di %{count}\n      less_than_or_equal_to: deve essere meno o uguale a %{count}\n      model_invalid: 'Validazione fallita: %{errors}'\n      not_a_number: non è un numero\n      not_an_integer: non è un intero\n      odd: deve essere dispari\n      other_than: devono essere di numero diverso da %{count}\n      present: deve essere lasciato in bianco\n      required: deve esistere\n      taken: è già presente\n      too_long:\n        one: è troppo lungo (il massimo è %{count} carattere)\n        other: è troppo lungo (il massimo è %{count} caratteri)\n      too_short:\n        one: è troppo corto (il minimo è %{count} carattere)\n        other: è troppo corto (il minimo è %{count} caratteri)\n      wrong_length:\n        one: è della lunghezza sbagliata (deve essere di %{count} carattere)\n        other: è della lunghezza sbagliata (deve essere di %{count} caratteri)\n    template:\n      body: 'Per favore ricontrolla i seguenti campi:'\n      header:\n        one: 'Non posso salvare questo %{model}: %{count} errore'\n        other: 'Non posso salvare questo %{model}: %{count} errori.'\n  helpers:\n    select:\n      prompt: Per favore, seleziona\n    submit:\n      create: Crea %{model}\n      submit: Invia %{model}\n      update: Aggiorna %{model}\n  number:\n    currency:\n      format:\n        delimiter: \"'\"\n        format: \"%u %n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: CHF\n    format:\n      delimiter: \",\"\n      precision: 2\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Miliardi\n          million: Milioni\n          quadrillion: Biliardi\n          thousand: Mila\n          trillion: Bilioni\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Byte\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" e \"\n      two_words_connector: \" e \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a %d %b %Y, %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/it.yml",
    "content": "---\nit:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validazione fallita: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Il record non può essere cancellato perchè esistono %{record}\n            dipendenti\n          has_one: Il record non può essere cancellato perchè esiste un %{record}\n            dipendente\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mer\n    - gio\n    - ven\n    - sab\n    abbr_month_names:\n    -\n    - gen\n    - feb\n    - mar\n    - apr\n    - mag\n    - giu\n    - lug\n    - ago\n    - set\n    - ott\n    - nov\n    - dic\n    day_names:\n    - domenica\n    - lunedì\n    - martedì\n    - mercoledì\n    - giovedì\n    - venerdì\n    - sabato\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - gennaio\n    - febbraio\n    - marzo\n    - aprile\n    - maggio\n    - giugno\n    - luglio\n    - agosto\n    - settembre\n    - ottobre\n    - novembre\n    - dicembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: circa un'ora\n        other: circa %{count} ore\n      about_x_months:\n        one: circa un mese\n        other: circa %{count} mesi\n      about_x_years:\n        one: circa un anno\n        other: circa %{count} anni\n      almost_x_years:\n        one: quasi un anno\n        other: quasi %{count} anni\n      half_a_minute: mezzo minuto\n      less_than_x_minutes:\n        one: meno di un minuto\n        other: meno di %{count} minuti\n      less_than_x_seconds:\n        one: meno di un secondo\n        other: meno di %{count} secondi\n      over_x_years:\n        one: oltre un anno\n        other: oltre %{count} anni\n      x_days:\n        one: \"%{count} giorno\"\n        other: \"%{count} giorni\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minuti\"\n      x_months:\n        one: \"%{count} mese\"\n        other: \"%{count} mesi\"\n      x_seconds:\n        one: \"%{count} secondo\"\n        other: \"%{count} secondi\"\n      x_years:\n        one: \"%{count} anno\"\n        other: \"%{count} anni\"\n    prompts:\n      day: Giorno\n      hour: Ora\n      minute: Minuto\n      month: Mese\n      second: Secondo\n      year: Anno\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: deve essere accettata\n      blank: non può essere lasciato in bianco\n      confirmation: non coincide con %{attribute}\n      empty: non può essere vuoto\n      equal_to: deve essere uguale a %{count}\n      even: deve essere pari\n      exclusion: è riservato\n      greater_than: deve essere maggiore di %{count}\n      greater_than_or_equal_to: deve essere maggiore o uguale a %{count}\n      inclusion: non è compreso tra le opzioni disponibili\n      invalid: non è valido\n      less_than: deve essere minore di %{count}\n      less_than_or_equal_to: deve essere minore o uguale a %{count}\n      model_invalid: 'Validazione fallita: %{errors}'\n      not_a_number: non è un numero\n      not_an_integer: non è un numero intero\n      odd: deve essere dispari\n      other_than: devono essere di numero diverso da %{count}\n      present: deve essere lasciato in bianco\n      required: deve esistere\n      taken: è già presente\n      too_long:\n        one: è troppo lungo (il massimo è %{count} carattere)\n        other: è troppo lungo (il massimo è %{count} caratteri)\n      too_short:\n        one: è troppo corto (il minimo è %{count} carattere)\n        other: è troppo corto (il minimo è %{count} caratteri)\n      wrong_length:\n        one: è della lunghezza sbagliata (deve essere di %{count} carattere)\n        other: è della lunghezza sbagliata (deve essere di %{count} caratteri)\n    template:\n      body: 'Ricontrolla i seguenti campi:'\n      header:\n        one: 'Non posso salvare questo %{model}: %{count} errore'\n        other: 'Non posso salvare questo %{model}: %{count} errori.'\n  helpers:\n    select:\n      prompt: Seleziona...\n    submit:\n      create: Crea %{model}\n      submit: Invia %{model}\n      update: Aggiorna %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Miliardi\n          million: Milioni\n          quadrillion: Biliardi\n          thousand: Mila\n          trillion: Bilioni\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Byte\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" e \"\n      two_words_connector: \" e \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a %d %b %Y, %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/ja.yml",
    "content": "---\nja:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'バリデーションに失敗しました: %{errors}'\n        restrict_dependent_destroy:\n          has_many: \"%{record}が存在しているので削除できません\"\n          has_one: \"%{record}が存在しているので削除できません\"\n  date:\n    abbr_day_names:\n    - 日\n    - 月\n    - 火\n    - 水\n    - 木\n    - 金\n    - 土\n    abbr_month_names:\n    -\n    - 1月\n    - 2月\n    - 3月\n    - 4月\n    - 5月\n    - 6月\n    - 7月\n    - 8月\n    - 9月\n    - 10月\n    - 11月\n    - 12月\n    day_names:\n    - 日曜日\n    - 月曜日\n    - 火曜日\n    - 水曜日\n    - 木曜日\n    - 金曜日\n    - 土曜日\n    formats:\n      default: \"%Y/%m/%d\"\n      long: \"%Y年%m月%d日(%a)\"\n      short: \"%m/%d\"\n    month_names:\n    -\n    - 1月\n    - 2月\n    - 3月\n    - 4月\n    - 5月\n    - 6月\n    - 7月\n    - 8月\n    - 9月\n    - 10月\n    - 11月\n    - 12月\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours: 約%{count}時間\n      about_x_months: 約%{count}ヶ月\n      about_x_years: 約%{count}年\n      almost_x_years: \"%{count}年弱\"\n      half_a_minute: 30秒前後\n      less_than_x_minutes: \"%{count}分未満\"\n      less_than_x_seconds: \"%{count}秒未満\"\n      over_x_years: \"%{count}年以上\"\n      x_days: \"%{count}日\"\n      x_minutes: \"%{count}分\"\n      x_months: \"%{count}ヶ月\"\n      x_seconds: \"%{count}秒\"\n      x_years: \"%{count}年\"\n    prompts:\n      day: 日\n      hour: 時\n      minute: 分\n      month: 月\n      second: 秒\n      year: 年\n  errors:\n    format: \"%{attribute}%{message}\"\n    messages:\n      accepted: を受諾してください\n      blank: を入力してください\n      confirmation: と%{attribute}の入力が一致しません\n      empty: を入力してください\n      equal_to: は%{count}にしてください\n      even: は偶数にしてください\n      exclusion: は予約されています\n      greater_than: は%{count}より大きい値にしてください\n      greater_than_or_equal_to: は%{count}以上の値にしてください\n      in: は%{count}の範囲に含めてください\n      inclusion: は一覧にありません\n      invalid: は不正な値です\n      less_than: は%{count}より小さい値にしてください\n      less_than_or_equal_to: は%{count}以下の値にしてください\n      model_invalid: 'バリデーションに失敗しました: %{errors}'\n      not_a_number: は数値で入力してください\n      not_an_integer: は整数で入力してください\n      odd: は奇数にしてください\n      other_than: は%{count}以外の値にしてください\n      present: は入力しないでください\n      required: を入力してください\n      taken: はすでに存在します\n      too_long: は%{count}文字以内で入力してください\n      too_short: は%{count}文字以上で入力してください\n      wrong_length: は%{count}文字で入力してください\n    template:\n      body: 次の項目を確認してください\n      header: \"%{model}に%{count}個のエラーが発生しました\"\n  helpers:\n    select:\n      prompt: 選択してください\n    submit:\n      create: 登録する\n      submit: 保存する\n      update: 更新する\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%n%u\"\n        precision: 0\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: 円\n    format:\n      delimiter: \",\"\n      precision: 3\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: 十億\n          million: 百万\n          quadrillion: 千兆\n          thousand: 千\n          trillion: 兆\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n%u\"\n        units:\n          byte: バイト\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \"、\"\n      two_words_connector: \"、\"\n      words_connector: \"、\"\n  time:\n    am: 午前\n    formats:\n      default: \"%Y年%m月%d日(%a) %H時%M分%S秒 %z\"\n      long: \"%Y/%m/%d %H:%M\"\n      short: \"%m/%d %H:%M\"\n    pm: 午後\n"
  },
  {
    "path": "config/locales/defaults/ka.yml",
    "content": "---\nka:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'დადასტურება წარუმატებელია: %{errors}'\n  date:\n    abbr_day_names:\n    - კვ\n    - ორშ\n    - სამშ\n    - ოთხშ\n    - ხუთშ\n    - პარ\n    - შაბ\n    abbr_month_names:\n    -\n    - იანვ\n    - თებ\n    - მარტი\n    - აპრ\n    - მაისი\n    - ივნ\n    - ივლ\n    - აგვ\n    - სექტ\n    - ოქტ\n    - ნოემბ\n    - დეკ\n    day_names:\n    - კვირა\n    - ორშაბათი\n    - სამშაბათი\n    - ოთხშაბათი\n    - ხუთშაბათი\n    - პარასკევი\n    - შაბათი\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - იანვარი\n    - თებერვალი\n    - მარტი\n    - აპრილი\n    - მაისი\n    - ივნისი\n    - ივლისი\n    - აგვისტო\n    - სექტემბერი\n    - ოქტომბერი\n    - ნოემბერი\n    - დეკემბერი\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: დაახლოებით %{count} საათი\n        other: დაახლოებით %{count} საათი\n      about_x_months:\n        one: დაახლოებით %{count} თვე\n        other: დაახლოებით %{count} თვე\n      about_x_years:\n        one: დაახლოებით %{count} წელი\n        other: დაახლოებით %{count} წელი\n      almost_x_years:\n        one: თითქმის %{count} წელი\n        other: თითქმის %{count} წელი\n      half_a_minute: ნახევარი წუთი\n      less_than_x_minutes:\n        one: \"%{count} წუთზე ნაკლები\"\n        other: \"%{count} წუთზე ნაკლები\"\n      less_than_x_seconds:\n        one: \"%{count} წამზე ნაკლები\"\n        other: \"%{count} წამზე ნაკლები\"\n      over_x_years:\n        one: \"%{count} წელზე მეტი\"\n        other: \"%{count} წელზე მეტი\"\n      x_days:\n        one: \"%{count} დღე\"\n        other: \"%{count} დღე\"\n      x_minutes:\n        one: \"%{count} წუთი\"\n        other: \"%{count} წუთი\"\n      x_months:\n        one: \"%{count} თვე\"\n        other: \"%{count} თვე\"\n      x_seconds:\n        one: \"%{count} წამი\"\n        other: \"%{count} წამი\"\n      x_years:\n        one: \"%{count} წელი\"\n        other: \"%{count} წელი\"\n    prompts:\n      day: დღე\n      hour: საათი\n      minute: წუთი\n      month: თვე\n      second: წამი\n      year: წელი\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: უნდა იყოს დადასტურებული\n      blank: არ შეიძლება იყოს ცარიელი\n      confirmation: ველი %{attribute}-ს არ ემთხვევა\n      empty: არ შეიძლება იყოს ცარიელი\n      equal_to: უნდა უდრიდეს %{count}-ს\n      even: უნდა იყოს ლუწი\n      exclusion: რეზერვირებულია\n      greater_than: უნდა იყოს %{count}-ზე მეტი\n      greater_than_or_equal_to: უნდა იყოს %{count}-ზე მეტი ან ტოლი\n      inclusion: არ არის სიაში\n      invalid: არასწორია\n      less_than: უნდა იყოს %{count}-ზე ნაკლები\n      less_than_or_equal_to: უნდა იყოს %{count}-ზე ნაკლები ან ტოლი\n      model_invalid: 'დადასტურება წარუმატებელია: %{errors}'\n      not_a_number: არ არის რიცხვი\n      not_an_integer: არ არის მთელი რიცხვი\n      odd: უნდა იყოს კენტი\n      other_than: უნდა განსხვავდებოდეს %{count}-გან\n      present: უნდა იყოს ცარიელი\n      required: უნდა არსებობდეს\n      taken: უკვე დაკავებულია\n      too_long:\n        one: არის ძალიან გრძელი (მაქსიმუმია %{count} სიმბოლო)\n        other: არის მეტისმეტად დიდი სიგრძის (მაქსიმუმია %{count} სიმბოლო)\n      too_short:\n        one: არის ძალიან მოკლე (მინიმუმია %{count} სიმბოლო)\n        other: არის ძალიან მოკლე (მინიმუმია %{count} სიმბოლო)\n      wrong_length:\n        one: არის არასწორი სიგრძის (უნდა იყოს ზუსტად %{count} სიბმოლო)\n        other: არის არასწორი სიგრძის (უნდა იყოს ზუსტად %{count} სიბმოლო)\n    template:\n      body: 'შეიქმნა პრობლემები შემდეგ ველებთან დაკავშირებით:'\n      header:\n        one: \"%{model}-ს შენახვა ვერ განხორციელდა %{count} შეცდომის გამო\"\n        other: \"%{model}-ს შენახვა ვერ განხორციელდა %{count} შეცდომის გამო\"\n  helpers:\n    select:\n      prompt: გთხოვთ აირჩიოთ\n    submit:\n      create: \"%{model}-ს შექმნა\"\n      submit: \"%{model}-ს დამახსოვრება\"\n      update: \"%{model}-ს განახლება\"\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: ლ\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: მილიარდი\n          million: მილიონი\n          quadrillion: კვადრილიონი\n          thousand: ათასი\n          trillion: ტრილიონი\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: ბაიტი\n            other: ბაიტები\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", და \"\n      two_words_connector: \" და \"\n      words_connector: \", \"\n  time:\n    am: დილის\n    formats:\n      default: \"%a, %d %b %Y, %H:%M:%S %z\"\n      long: \"%d %B %Y, %H:%M\"\n      short: \"%d %b, %H:%M\"\n    pm: საღამოს\n"
  },
  {
    "path": "config/locales/defaults/kk.yml",
    "content": "---\nkk:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Тексеру сәтсіз аяқталды: %{errors}'\n        restrict_dependent_destroy:\n          has_many: 'Жазбаны жою мүмкін емес, тәуелділіктер бар: %{record}'\n          has_one: 'Жазбаны жою мүмкін емес, тәуелділік бар: %{record}'\n  date:\n    abbr_day_names:\n    - Жс\n    - Дс\n    - Сс\n    - Ср\n    - Бс\n    - Жм\n    - Сб\n    abbr_month_names:\n    -\n    - қаң.\n    - ақп.\n    - нау.\n    - сәу.\n    - мам.\n    - мау.\n    - шіл.\n    - там.\n    - қыр.\n    - қаз.\n    - қар.\n    - жел.\n    day_names:\n    - жексенбі\n    - дүйсенбі\n    - сейсенбі\n    - сәрсенбі\n    - бейсенбі\n    - жұма\n    - сенбі\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%-d %B %Y\"\n      short: \"%-d %b\"\n    month_names:\n    -\n    - каңтар\n    - ақпан\n    - наурыз\n    - сәуір\n    - мамыр\n    - маусым\n    - шілде\n    - тамыз\n    - қыркүйек\n    - қазан\n    - қараша\n    - желтоқсан\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: шамамен %{count} сағат\n        other: шамамен %{count} сағат\n      about_x_months:\n        one: шамамен %{count} ай\n        other: шамамен %{count} ай\n      about_x_years:\n        one: шамамен %{count} жыл\n        other: шамамен %{count} жыл\n      almost_x_years:\n        one: \"%{count} жылға жуық\"\n        other: \"%{count} жылға жуық\"\n      half_a_minute: жарты минут\n      less_than_x_minutes:\n        one: \"%{count} минуттан аз\"\n        other: \"%{count} минуттан аз\"\n      less_than_x_seconds:\n        one: \"%{count} секундтан аз\"\n        other: \"%{count} секундтан аз\"\n      over_x_years:\n        one: \"%{count} жылдан астам\"\n        other: \"%{count} жылдан астам\"\n      x_days:\n        one: \"%{count} күн\"\n        other: \"%{count} күн\"\n      x_minutes:\n        one: \"%{count} минут\"\n        other: \"%{count} минут\"\n      x_months:\n        one: \"%{count} ай\"\n        other: \"%{count} ай\"\n      x_seconds:\n        one: \"%{count} секунд\"\n        other: \"%{count} секунд\"\n      x_years:\n        one: \"%{count} жыл\"\n        other: \"%{count} жыл\"\n    prompts:\n      day: Күн\n      hour: Сағат\n      minute: Минут\n      month: Ай\n      second: Секунд\n      year: Жыл\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: расталуы тиіс\n      blank: бос болуы мүмкін емес\n      confirmation: \"%{attribute} мәніне сәйкес келмейді\"\n      empty: бос болуы мүмкін емес\n      equal_to: тек %{count} тең мәнге ие болуы мүмкін\n      even: тек жұп мәнге ие бола алады\n      exclusion: сақталған мағына\n      greater_than: мәні %{count} санынан жоғары болуы керек\n      greater_than_or_equal_to: мәні %{count} санынан жоғары немесе тең болуы керек\n      inclusion: күтпеген мағына\n      invalid: қате мағына\n      less_than: мәні %{count} санынан кем болуы керек\n      less_than_or_equal_to: мәні %{count} санынан кем немесе тең болуы керек\n      model_invalid: 'Тексеру сәтсіз аяқталды: %{errors}'\n      not_a_number: сан емес\n      not_an_integer: бүтін сан емес\n      odd: тек тақ мәнге ие бола алады\n      other_than: \"%{count} санынан өзгеше болуы тиіс\"\n      present: бос қалуы тиіс\n      required: жоқ болуы мүмкін емес\n      taken: бұрыннан бар\n      too_long:\n        one: тым ұзын (%{count} таңбадан асырмаңыз)\n        other: тым ұзын (%{count} таңбадан асырмаңыз)\n      too_short:\n        one: тым қысқа (%{count} таңбадан көп немесе тең болуы тиіс)\n        other: тым қысқа (%{count} таңбадан көп немесе тең болуы тиіс)\n      wrong_length:\n        one: ұзындығы дұрыс емес (дәл %{count} таңба болуы тиіс)\n        other: ұзындығы дұрыс емес (дәл %{count} таңба болуы тиіс)\n    template:\n      body: 'Келесі өрістермен проблемалар туындады:'\n      header:\n        one: \"%{model}: %{count} қатеге байланысты сақтау сәтсіз аяқталды\"\n        other: \"%{model}: %{count} қатеге байланысты сақтау сәтсіз аяқталды\"\n  helpers:\n    select:\n      prompt: 'Таңдаңыз: '\n    submit:\n      create: Құру %{model}\n      submit: Сақтау %{model}\n      update: Өзгерту %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: теңге\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: миллиард\n            other: миллиард\n          million:\n            one: миллион\n            other: миллион\n          quadrillion:\n            one: квадриллион\n            other: квадриллион\n          thousand:\n            one: мың\n            other: мың\n          trillion:\n            one: триллион\n            other: триллион\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: байт\n            other: байт\n          eb: ЭБ\n          gb: ГБ\n          kb: КБ\n          mb: МБ\n          pb: ПБ\n          tb: ТБ\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" және \"\n      two_words_connector: \" және \"\n      words_connector: \", \"\n  time:\n    am: таңғы\n    formats:\n      default: \"%a, %d %b %Y, %H:%M:%S %z\"\n      long: \"%d %B %Y, %H:%M\"\n      short: \"%d %b, %H:%M\"\n    pm: кешкі\n"
  },
  {
    "path": "config/locales/defaults/km.yml",
    "content": "---\nkm:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: មិនមានសុពលភាព៖ %{errors}\n        restrict_dependent_destroy:\n          has_many: មិនអាចលុបបានទេពីព្រោះមាន %{record} នៅឡើយ\n          has_one: មិនអាចលុបបានទេពីព្រោះមាន %{record} នៅឡើយ\n  date:\n    abbr_day_names:\n    - អា\n    - ច\n    - អ\n    - ពុ\n    - ព្រហ\n    - សុ\n    - ស\n    abbr_month_names:\n    -\n    - មករា\n    - កុម្ភៈ\n    - មិនា\n    - មេសា\n    - ឧសភា\n    - មិថុនា\n    - កក្កដា\n    - សីហា\n    - កញ្ញា\n    - តុលា\n    - វិច្ឆិកា\n    - ធ្នូ\n    day_names:\n    - អាទិត្យ\n    - ចន្ទ\n    - អង្គារ\n    - ពុធ\n    - ព្រហស្បតិ៍\n    - សុក្រ\n    - សៅរ៍\n    formats:\n      default: \"%d %B %Y\"\n      long: ថ្ងៃ%A ទី%e ខែ%B ឆ្នាំ%Y\n      short: \"%d %b\"\n    month_names:\n    -\n    - មករា\n    - កុម្ភៈ\n    - មិនា\n    - មេសា\n    - ឧសភា\n    - មិថុនា\n    - កក្កដា\n    - សីហា\n    - កញ្ញា\n    - តុលា\n    - វិច្ឆិកា\n    - ធ្នូ\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours: ប្រមាណ %{count} ម៉ោង\n      about_x_months: ប្រមាណ %{count} ខែ\n      about_x_years: ប្រមាណ %{count} ឆ្នាំ\n      almost_x_years: ជិត %{count} ឆ្នាំ\n      half_a_minute: កន្លះនាទី\n      less_than_x_minutes: តិចជាង %{count} នាទី\n      less_than_x_seconds: តិចជាង %{count} វិនាទី\n      over_x_years: លើសពី %{count} ឆ្នាំ\n      x_days: \"%{count} ថ្ងៃ\"\n      x_minutes: \"%{count} នាទី\"\n      x_months: \"%{count} ខែ\"\n      x_seconds: \"%{count} វិនាទី\"\n    prompts:\n      day: ថ្ងៃ\n      hour: ម៉ោង\n      minute: នាទី\n      month: ខែ\n      second: វិនាទី\n      year: ឆ្នាំ\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: ត្រូវតែយល់ព្រម\n      blank: មិនអាចរំលង\n      confirmation: ផ្ទៀងផ្ទាត់មិនត្រូវនឹង %{attribute}\n      empty: មិនអាចរំលង\n      equal_to: ត្រូវតែស្មើ %{count}\n      even: ត្រូវតែជាចំនួនគត់\n      exclusion: មិនអនុញ្ញាតឱ្យប្រើ\n      greater_than: ត្រូវតែច្រើនជាង %{count}\n      greater_than_or_equal_to: ត្រូវតែច្រើនជាងឬស្មើ %{count}\n      inclusion: មិនមាននៅក្នុងបញ្ជី\n      invalid: មិនត្រឹមត្រូវ\n      less_than: ត្រូវតែតិចជាង %{count}\n      less_than_or_equal_to: ត្រូវតែតិចជាងឬស្មើ %{count}\n      not_a_number: មិនមែនជាលេខទេ\n      not_an_integer: ត្រូវតែជាចំនួនគត់\n      odd: ត្រូវតែជាចំនួនសេស\n      other_than: ត្រូវតែខុសពី %{count}\n      present: ត្រូវតែរំលង\n      taken: មានរួចហើយ\n      too_long: វែងពេក (យ៉ាងច្រើន %{count} តួ)\n      too_short: ខ្លីពេក (យ៉ាងតិច %{count} តួ)\n      wrong_length: ប្រវែងមិនត្រូវ (គួរតែមាន %{count} តួ)\n    template:\n      body: 'សូមពិនិត្យមើលកំហុសនៅខាងក្រោម:'\n      header: មានកំហុស %{count} ដែលបានបញ្ឈប់ការរក្សាទុក %{model}នេះ\n  helpers:\n    select:\n      prompt: សូមជ្រើសរើស\n    submit:\n      create: បង្កើត%{model}\n      submit: រក្សាទុក%{model}\n      update: ប្តូរ%{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"៛\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: ប៊ីលាន\n          million: លាន\n          quadrillion: ក្វាទ្រីលាន\n          thousand: ពាន់\n          trillion: ទ្រីលាន\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", និង \"\n      two_words_connector: \" និង \"\n      words_connector: \", \"\n  time:\n    am: ព្រឹក\n    formats:\n      default: \"%a %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: \"​ល្ងាច\"\n"
  },
  {
    "path": "config/locales/defaults/kn.yml",
    "content": "---\nkn:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'ತಪ್ಪು ಆಧಾರ: %{errors}'\n  date:\n    abbr_day_names:\n    - ರವಿ\n    - ಸೋಮ\n    - ಮಂಗಳ\n    - ಬುಧ\n    - ಗುರು\n    - ಶುಕ್ರ\n    - ಶನಿ\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - ರವಿವಾರ\n    - ಸೋಮವಾರ\n    - ಮಂಗಳವಾರ\n    - ಬುಧವಾರ\n    - ಗುರುವಾರ\n    - ಶುಕ್ರವಾರ\n    - ಶನಿವಾರ\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - ಜನವರಿ\n    - ಫೆಬ್ರವರಿ\n    - ಮಾರ್ಚ್\n    - ಏಪ್ರಿಲ್\n    - ಮೇ\n    - ಜೂನ್\n    - ಜುಲೈ\n    - ಆಗಸ್ಟ್\n    - ಸೆಪ್ಟೆಂಬರ್\n    - ಅಕ್ಟೋಬರ್\n    - ನವಂಬರ್\n    - ಡಿಸೆಂಬರ್\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ಸುಮಾರು ಒಂದು ಗಂಟೆ\n        other: ಸುಮಾರು %{count} ಗಂಟೆಗಳು\n      about_x_months:\n        one: ಸುಮಾರು ಒಂದು ತಿಂಗಳು\n        other: ಸುಮಾರು %{count} ತಿಂಗಳುಗಳು\n      about_x_years:\n        one: ಸುಮಾರು ಒಂದು ವರುಷ\n        other: ಸುಮಾರು %{count} ವರುಷಗಳು\n      almost_x_years:\n        one: ಸರಿಸುಮಾರು ಒಂದು ವರುಷ\n        other: ಸರಿಸುಮಾರು %{count} ವರುಷಗಳು\n      half_a_minute: ಒಂದು ಅರ್ಧ ನಿಮಿಷ\n      less_than_x_minutes:\n        one: ಒಂದು ನಿಮಿಷಕ್ಕೂ ಕಡಿಮೆ\n        other: \"%{count} ನಿಮಿಷಕ್ಕಿಂತ ಕಡಿಮೆ\"\n      less_than_x_seconds:\n        one: ಒಂದು ಸೆಕೆಂಡಿಗೂ ಕಡಿಮೆ\n        other: \"%{count} ಸೆಕೆಂಡಿಗಿಂತ ಕಡಿಮೆ\"\n      over_x_years:\n        one: ಒಂದು ವರುಷಕ್ಕಿಂತ ಹೆಚ್ಚು\n        other: \"%{count} ವರುಷಗಳಿಗಿಂತ ಹೆಚ್ಚು\"\n      x_days:\n        one: \"%{count} ದಿನ\"\n        other: \"%{count} ದಿನಗಳು\"\n      x_minutes:\n        one: \"%{count} ನಿಮಿಷ\"\n        other: \"%{count} ನಿಮಿಷಗಳು\"\n      x_months:\n        one: \"%{count} ತಿಂಗಳು\"\n        other: \"%{count} ತಿಂಗಳುಗಳು\"\n      x_seconds:\n        one: \"%{count} ಸೆಕೆಂಡ್\"\n        other: \"%{count} ಸೆಕೆಂಡುಗಳು\"\n    prompts:\n      day: ದಿನ\n      hour: ಗಂಟೆ\n      minute: ನಿಮಿಷ\n      month: ತಿಂಗಳು\n      second: ಸೆಕೆಂಡು\n      year: ವರುಷ\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: ಒಪ್ಪಿಕೊಳ್ಳಬೇಕು\n      blank: ಖಾಲಿ ಬಿಡಲು ಸಧ್ಯವಿಲ್ಲ\n      confirmation: ಸಮರ್ಥನೆ ಸರಿಬರಲ್ಲಿಲ್ಲ\n      empty: ಖಾಲಿ ಬಿಡಲು ಸಧ್ಯವಿಲ್ಲ\n      equal_to: \"%{count} ಕ್ಕೆ ಸಮಾನವಾಗಿರಬೇಕು\"\n      even: ಸಮ ಆಗಿರಬೇಕು\n      exclusion: ಕಾಯ್ದಿರಿಸಲಾಗಿದೆ\n      greater_than: \"%{count} ಕ್ಕಿಂತ ಹೆಚ್ಚಿರಬೇಕು\"\n      greater_than_or_equal_to: \"%{count} ಕಿಂತ ಹೆಚ್ಚು ಅಥವಾ ಸಮಾನವಾಗಿರ ಇರಬೇಕು\"\n      inclusion: ಪಟ್ಟಿಯಲ್ಲಿ ಶಾಮೀಲು ಆಗಿಲ್ಲ\n      invalid: ನಿರರ್ಥಕವಾಗಿದೆ\n      less_than: \"%{count} ಕ್ಕಿಂತ ಕಡಿಮೆ ಆಗಿರಬೇಕು\"\n      less_than_or_equal_to: \"%{count} ಕಿಂತ ಕಡಿಮೆ ಅಥವಾ ಸಮಾನವಾಗಿರ ಇರಬೇಕು\"\n      not_a_number: ಸಂಖೆ ಆಗಿಲ್ಲ\n      not_an_integer: ಸಂಖೆ ಆಗಿರಬೇಕು\n      odd: ಬೆಸ ಆಗಿರಬೇಕು\n      taken: ತೆಗೆದುಕೊಂಡಾಗಿದೆ\n      too_long: ಬಹಳ ದೊಡ್ಡದಾಗಿದೆ (ಗರಿಷ್ಟ %{count} ಅಕ್ಷರಗಳು)\n      too_short: ಬಹಳ ಚಿಕ್ಕದಾಗಿದೆ (ಕನಿಷ್ಠ %{count} ಅಕ್ಷರಗಳು)\n      wrong_length: ತಪ್ಪು ಉದ್ದವಿದೆ (%{count} ಅಕ್ಷರಗಳಿರಬೇಕು)\n    template:\n      body: 'ಸಮಸ್ಯೆಗಳಿರುವ ಜಾಗಗಳು:'\n      header:\n        one: \"%{count} ಧೋಷದ ಪರಿಣಾಮ %{model} ಅನ್ನು ರಚಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ\"\n        other: \"%{count} ಧೋಷಗಳ ಪರಿಣಾಮ %{model} ಅನ್ನು ರಚಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ\"\n  helpers:\n    select:\n      prompt: ದಯವಿಟ್ಟು ಆರಿಸಿ\n    submit:\n      create: \"%{model} ರಚಿಸಿ\"\n      submit: \"%{model} ಕಳುಹಿಸು\"\n      update: \"%{model} ರಚಿಸಿ\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: ಲಕ್ಷಕೋಟಿ\n          million: ದಶಲಕ್ಷ\n          quadrillion: ಪದ್ಮ\n          thousand: ಸಾವಿರ\n          trillion: ನೀಲ್\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", ಮತ್ತು  \"\n      two_words_connector: \" ಮತ್ತು  \"\n      words_connector: \", \"\n  time:\n    am: ಪ್ರಾತಃಕಾಲ\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: ಅಪರನ್ನಃ\n"
  },
  {
    "path": "config/locales/defaults/ko.yml",
    "content": "---\nko:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 데이터 검증에 실패하였습니다. %{errors}\n        restrict_dependent_destroy:\n          has_many: \"%{record}(이)가 존재하기 때문에 삭제할 수 없습니다\"\n          has_one: \"%{record}(이)가 존재하기 때문에 삭제할 수 없습니다\"\n  date:\n    abbr_day_names:\n    - 일\n    - 월\n    - 화\n    - 수\n    - 목\n    - 금\n    - 토\n    abbr_month_names:\n    -\n    - 1월\n    - 2월\n    - 3월\n    - 4월\n    - 5월\n    - 6월\n    - 7월\n    - 8월\n    - 9월\n    - 10월\n    - 11월\n    - 12월\n    day_names:\n    - 일요일\n    - 월요일\n    - 화요일\n    - 수요일\n    - 목요일\n    - 금요일\n    - 토요일\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%Y년 %m월 %d일\"\n      short: \"%m월 %d일\"\n    month_names:\n    -\n    - 1월\n    - 2월\n    - 3월\n    - 4월\n    - 5월\n    - 6월\n    - 7월\n    - 8월\n    - 9월\n    - 10월\n    - 11월\n    - 12월\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours: 대략 %{count}시간\n      about_x_months: 대략 %{count}개월\n      about_x_years: 대략 %{count}년\n      almost_x_years: 거의 %{count}년\n      half_a_minute: 30초\n      less_than_x_minutes: \"%{count}분 미만\"\n      less_than_x_seconds: \"%{count}초 미만\"\n      over_x_years: \"%{count}년 초과\"\n      x_days: \"%{count}일\"\n      x_minutes: \"%{count}분\"\n      x_months: \"%{count}개월\"\n      x_seconds: \"%{count}초\"\n      x_years: \"%{count}년\"\n    prompts:\n      day: 일\n      hour: 시\n      minute: 분\n      month: 월\n      second: 초\n      year: 년\n  errors:\n    format: \"%{message}\"\n    messages:\n      accepted: \"%{attribute}을(를) 반드시 확인해야 합니다\"\n      blank: \"%{attribute}에 내용을 입력해 주세요\"\n      confirmation: \"%{attribute}은(는) 서로 일치해야 합니다\"\n      empty: \"%{attribute}에 내용을 입력해 주세요\"\n      equal_to: \"%{attribute}은(는) %{count}와(과) 같아야 합니다\"\n      even: \"%{attribute}에 짝수를 입력해 주세요\"\n      exclusion: \"%{attribute}은(는) 이미 예약되어 있는 값입니다\"\n      greater_than: \"%{attribute}은(는) %{count}보다 커야 합니다\"\n      greater_than_or_equal_to: \"%{attribute}은(는) %{count}보다 크거나 같아야 합니다\"\n      in: \"%{attribute}은(는) %{count}범위 안에 있어야 합니다\"\n      inclusion: \"%{attribute}은(는) 목록에 포함되어 있는 값이 아닙니다\"\n      invalid: \"%{attribute}은(는) 올바르지 않은 값입니다\"\n      less_than: \"%{attribute}은(는) %{count}보다 작아야 합니다\"\n      less_than_or_equal_to: \"%{attribute}은(는) %{count}와(과) 작거나 같아야 합니다\"\n      model_invalid: \"%{attribute}에 대한 데이터 검증에 실패하였습니다: %{errors}\"\n      not_a_number: \"%{attribute}에 숫자를 입력해 주세요\"\n      not_an_integer: \"%{attribute}에 정수를 입력해 주세요\"\n      odd: \"%{attribute}에 홀수를 입력해 주세요\"\n      other_than: \"%{attribute}은(는) %{count}와(과) 달라야 합니다\"\n      present: \"%{attribute}은(는) 비어있어야 합니다\"\n      required: \"%{attribute}은(는) 반드시 있어야 합니다\"\n      taken: \"%{attribute}은(는) 이미 존재합니다\"\n      too_long: \"%{attribute}은(는) %{count}자를 넘을 수 없습니다\"\n      too_short: \"%{attribute}은(는) 적어도 %{count}자를 넘어야 합니다\"\n      wrong_length: \"%{attribute}은(는) %{count}자여야 합니다\"\n    template:\n      body: 아래 문제를 확인해 주세요.\n      header: \"%{count}개의 오류로 인해 %{model}을(를) 저장할 수 없습니다\"\n  helpers:\n    select:\n      prompt: 선택해주세요\n    submit:\n      create: 등록\n      submit: 제출\n      update: 수정\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%n%u\"\n        precision: 0\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: 원\n    format:\n      delimiter: \",\"\n      precision: 3\n      round_mode: default\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n%u\"\n        units:\n          billion: 십억\n          million: 백만\n          quadrillion: 천조\n          thousand: 천\n          trillion: 조\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n%u\"\n        units:\n          byte: 바이트\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", \"\n      two_words_connector: \", \"\n      words_connector: \", \"\n  time:\n    am: 오전\n    formats:\n      default: \"%Y년 %m월 %d일 %A %H시 %M분 %S초 %z\"\n      long: \"%Y년 %m월 %d일 %H시 %M분\"\n      short: \"%m월 %d일 %H:%M\"\n    pm: 오후\n"
  },
  {
    "path": "config/locales/defaults/lb.yml",
    "content": "---\nlb:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validatioun feelgeschlo: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Den Enregistrement kann net geläscht gi wëll et dovun ofhängegt\n            %{record} gëtt\n          has_one: Den Enregistrement kann net geläscht gi wëll et en dovun ofhängegt\n            %{record} gëtt\n  date:\n    abbr_day_names:\n    - Son\n    - Méi\n    - Dën\n    - Mët\n    - Don\n    - Fre\n    - Sam\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mäe\n    - Abr\n    - Mee\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Okt\n    - Nov\n    - Dez\n    day_names:\n    - Sonndeg\n    - Méindeg\n    - Dënschdeg\n    - Mëttwoch\n    - Donneschdeg\n    - Freideg\n    - Samschdeg\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%e. %B %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - Januar\n    - Februar\n    - Mäerz\n    - Abrëll\n    - Mee\n    - Juni\n    - Juli\n    - August\n    - September\n    - Oktober\n    - November\n    - Dezember\n    order:\n    - :Joer\n    - :Mount\n    - :Dag\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ongeféier eng Stonn\n        other: ongeféier %{count} Stonnen\n      about_x_months:\n        one: ongeféier ee Mount\n        other: ongeféier %{count} Méint\n      about_x_years:\n        one: ongeféier ee Joer\n        other: ongeféier %{count} Joer\n      almost_x_years:\n        one: bal ee Joer\n        other: bal %{count} Joer\n      half_a_minute: eng hallef Minutt\n      less_than_x_minutes:\n        one: manner wéi eng Minutt\n        other: manner wéi %{count} Minutten\n      less_than_x_seconds:\n        one: manner wéi eng Sekonn\n        other: manner wéi %{count} Sekonnen\n      over_x_years:\n        one: méi wéi ee Joer\n        other: méi wéi %{count} Joer\n      x_days:\n        one: \"%{count} Dag\"\n        other: \"%{count} Deeg\"\n      x_minutes:\n        one: \"%{count} Minutt\"\n        other: \"%{count} Minutten\"\n      x_months:\n        one: \"%{count} Mount\"\n        other: \"%{count} Méint\"\n      x_seconds:\n        one: \"%{count} Sekonn\"\n        other: \"%{count} Sekonnen\"\n    prompts:\n      day: Dag\n      hour: Stonn\n      minute: Minutt\n      month: Mount\n      second: Sekonnen\n      year: Joer\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: muss akzeptéiert ginn\n      blank: däerf net eidel sinn\n      confirmation: stëmmt net mat %{attribute} iwwerenee\n      empty: däerf net eidel sinn\n      equal_to: muss d'selwecht si wéi %{count}\n      even: muss gerued sinn\n      exclusion: ass reservéiert\n      greater_than: muss méi grouss wéi %{count} sinn\n      greater_than_or_equal_to: muss méi grouss oder gläich si wéi %{count}\n      inclusion: ass net an der Lëscht dran\n      invalid: ass net valabel\n      less_than: muss méi kleng wéi %{count} sinn\n      less_than_or_equal_to: muss méi kleng oder gläich si wéi %{count}\n      not_a_number: ass keng Zuel\n      not_an_integer: muss eng ganz Zuel sinn\n      odd: muss ongerued sinn\n      other_than: muss anescht si wéi %{count}\n      present: muss eidel sinn\n      taken: gouf scho geholl\n      too_long:\n        one: ass ze laang (Maximal %{count} Zeechen)\n        other: ass ze laang (net méi wéi %{count} Zeechen)\n      too_short:\n        one: ass ze kuerz (Mniimal %{count} Zeechen)\n        other: ass ze kuerz (mindestens %{count} Zeechen)\n      wrong_length:\n        one: huet déi falsch Längt (muss genee een Zeeche sinn)\n        other: huet déi falsch Längt (musse genee %{count} Zeeche sinn)\n    template:\n      body: 'Et gouf Problemer mat dëse Felder:'\n      header:\n        one: \"%{count} Feeler verhënnert d'Späichere vu(n) %{model}\"\n        other: \"%{count} Feeler verhënneren d'Späichere vu(n) %{model}\"\n  helpers:\n    select:\n      prompt: Sicht w.e.g. eraus\n    submit:\n      create: uleeën %{model}\n      submit: späicheren %{model}\n      update: aktualiséieren %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \",\"\n      precision: 2\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Milliard\n          million: Millioun\n          quadrillion:\n            one: Billiard\n            other: Billiarden\n          thousand: Dausend\n          trillion: Billioun\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Byten\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", an \"\n      two_words_connector: \" an \"\n      words_connector: \", \"\n  time:\n    am: moies\n    formats:\n      default: \"%A, %d. %B %Y, %H:%M Auer\"\n      long: \"%A, %d. %B %Y, %H:%M Auer\"\n      short: \"%d. %b %H:%M\"\n    pm: mëttes\n"
  },
  {
    "path": "config/locales/defaults/lo.yml",
    "content": "---\nlo:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'ການຢືນຢັນບໍ່ສຳເລັດ : %{errors}'\n  date:\n    abbr_day_names:\n    - ອາທິດ\n    - ຈັນ\n    - ອັງຄານ\n    - ພຸດ\n    - ພະຫັດ\n    - ສຸກ\n    - ເສົາ\n    abbr_month_names:\n    -\n    - ມ.ກ\n    - ກ.ພ\n    - ມ.ນ\n    - ມ.ສ\n    - ພ.ພ\n    - ມິ.ຖ\n    - ກ.ລ\n    - ສ.ຫ\n    - ກ.ຍ\n    - ຕ.ລ\n    - ພ.ຈ\n    - ທ.ວ\n    day_names:\n    - ອາທິດ\n    - ຈັນ\n    - ອັງຄານ\n    - ພຸດ\n    - ພະຫັດ\n    - ສຸກ\n    - ເສົາ\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%e %B %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - ມັງກອນ\n    - ກຸມພາ\n    - ມີນາ\n    - ເມສາ\n    - ພຶດສະພາ\n    - ມິຖຸນາ\n    - ກໍລະກົດ\n    - ສິງຫາ\n    - ກັນຍາ\n    - ຕຸລາ\n    - ພະຈິກ\n    - ທັນວາ\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours: ປະມານ %{count} ຊົ່ວໂມງ\n      about_x_months: ປະມານ %{count} ເດືອນ\n      about_x_years: ປະມານ %{count} ປີ\n      almost_x_years: ເກືອບ %{count} ປີ\n      half_a_minute: ເຄິ່ງນາທີ\n      less_than_x_minutes: ນ້ອຍກວ່າ %{count} ນາທີ\n      less_than_x_seconds: ນ້ອຍກວ່າ %{count} ວິນາທີ\n      over_x_years: ຫຼາຍກວ່າ %{count} ປີ\n      x_days: \"%{count} ມື້\"\n      x_minutes: \"%{count} ນາທີ\"\n      x_months: \"%{count} ເດືອນ\"\n      x_seconds: \"%{count} ວິນາທີ\"\n    prompts:\n      day: ວັນ\n      hour: ຊົ່ວໂມງ\n      minute: ນາທີ\n      month: ເດືອນ\n      second: ວິນາທີ\n      year: ປີ\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: ຕ້ອງຍອມຮັບ\n      blank: ເປົ່າບໍ່ໄດ້\n      confirmation: ບໍ່ຖືກກັບການຢືນຢັນ\n      empty: ວ່າງໄວ້ບໍ່ໄດ້\n      equal_to: ຕ້ອງເທົ່າກັບ %{count}\n      even: ຕ້ອງເປັນເລກຄູ່\n      exclusion: ມີການຈອງໄວ້ແລ້ວ\n      greater_than: ຕ້ອງສູງກວ່າ %{count}\n      greater_than_or_equal_to: ຕ້ອງສູງກວ່າ ຫຼື ເທົ່າກັບ %{count}\n      inclusion: ບໍ່ໄດ້ຮວມຢູ່ໃນບັນຊີລາຍການ\n      invalid: ບໍ່ຖືກ\n      less_than: ຕ້ອງຕຳ່ກວ່າ %{count}\n      less_than_or_equal_to: ຕ້ອງຕຳ່ກວ່າ ຫຼື ເທົ່າກັບ %{count}\n      not_a_number: ບໍ່ແມ່ນຕົວເລກ\n      odd: ຕ້ອງເປັນເລກຄີກ\n      taken: ຮັບເອົາໄປແລ້ວ\n      too_long: ຍາວໂພດ (ສູງສຸດຄື %{count} ຕົວອັກສອນ)\n      too_short: ສັ້ນໂພດ (ຕຳ່ສຸດຄື %{count} ຕົວອັກສອນ)\n      wrong_length: ຄວາມຍາວຜິດ (ຄວນຈະເປັນ %{count} ຕົວອັກສອນ)\n    template:\n      body: 'ກະລຸນາກວດສອບຂໍ້ມູນໃນຫ້ອງຕໍ່ໄປນີ້ :'\n      header: ບໍ່ສາມາດບັນທຶກ %{model} ໄດ້ເນື່ອງຈາກ ເກີດ %{count} ຂໍ້ຜິດພາດ\n  helpers:\n    select:\n      prompt: โปรดเลือก\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: Kip\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: Bytes\n            many: Bytes\n            one: Byte\n            other: Bytes\n            two: Bytes\n            zero: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", ແລະ \"\n      two_words_connector: 'ແລະ '\n      words_connector: \", \"\n  time:\n    am: ເຊົ້າ\n    formats:\n      default: \"%a %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M น.\"\n      short: \"%d %b %H:%M น.\"\n    pm: ແລງ\n"
  },
  {
    "path": "config/locales/defaults/lt.yml",
    "content": "---\nlt:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Nepraeitos patikros: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Negalima ištrinti įrašų nes priklausomi %{record} egzistuoja\n          has_one: Negalima ištrinti įrašo nes priklausomas %{record} egzistuoja\n  date:\n    abbr_day_names:\n    - Sek\n    - Pir\n    - Ant\n    - Tre\n    - Ket\n    - Pen\n    - Šeš\n    abbr_month_names:\n    -\n    - Sau\n    - Vas\n    - Kov\n    - Bal\n    - Geg\n    - Bir\n    - Lie\n    - Rgp\n    - Rgs\n    - Spa\n    - Lap\n    - Grd\n    day_names:\n    - sekmadienis\n    - pirmadienis\n    - antradienis\n    - trečiadienis\n    - ketvirtadienis\n    - penktadienis\n    - šeštadienis\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%Y m. %B %d d.\"\n      short: \"%b %d\"\n    month_names:\n    -\n    - sausio\n    - vasario\n    - kovo\n    - balandžio\n    - gegužės\n    - birželio\n    - liepos\n    - rugpjūčio\n    - rugsėjo\n    - spalio\n    - lapkričio\n    - gruodžio\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: apie %{count} valandas\n        one: apie %{count} valandą\n        other: apie %{count} valandų\n      about_x_months:\n        few: apie %{count} mėnesius\n        one: apie %{count} mėnesį\n        other: apie %{count} mėnesių\n      about_x_years:\n        few: apie %{count} metus\n        one: apie %{count} metus\n        other: apie %{count} metų\n      almost_x_years:\n        few: beveik %{count} metai\n        one: beveik %{count} metai\n        other: beveik %{count} metų\n      half_a_minute: pusė minutės\n      less_than_x_minutes:\n        few: mažiau nei %{count} minutės\n        one: mažiau nei %{count} minutė\n        other: mažiau nei %{count} minučių\n      less_than_x_seconds:\n        few: mažiau nei %{count} sekundės\n        one: mažiau nei %{count} sekundė\n        other: mažiau nei %{count} sekundžių\n      over_x_years:\n        few: virš %{count} metų\n        one: virš %{count} metų\n        other: virš %{count} metų\n      x_days:\n        few: \"%{count} dienos\"\n        one: \"%{count} diena\"\n        other: \"%{count} dienų\"\n      x_minutes:\n        few: \"%{count} minutės\"\n        one: \"%{count} minutė\"\n        other: \"%{count} minučių\"\n      x_months:\n        few: \"%{count} mėnesiai\"\n        one: \"%{count} mėnesis\"\n        other: \"%{count} mėnesių\"\n      x_seconds:\n        few: \"%{count} sekundės\"\n        one: \"%{count} sekundė\"\n        other: \"%{count} sekundžių\"\n      x_years:\n        few: \"%{count} metai\"\n        one: \"%{count} metai\"\n        other: \"%{count} metai\"\n    prompts:\n      day: Diena\n      hour: Valanda\n      minute: Minutė\n      month: Mėnuo\n      second: Sekundės\n      year: Metai\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: turi būti patvirtintas\n      blank: negali būti tuščias\n      confirmation: neteisingai pakartotas\n      empty: negali būti tuščias\n      equal_to: turi būti lygus %{count}\n      even: turi būti lyginis skaičius\n      exclusion: yra rezervuotas\n      greater_than: turi būti didesnis už %{count}\n      greater_than_or_equal_to: turi būti didesnis arba lygus %{count}\n      in: turi būti skaičiuje %{count}\n      inclusion: nenumatyta reikšmė\n      invalid: neteisingas\n      less_than: turi būti mažesnis už %{count}\n      less_than_or_equal_to: turi būti mažesnis arba lygus %{count}\n      model_invalid: 'Tikrinimo klaida: %{errors}'\n      not_a_number: ne skaičius\n      not_an_integer: privalo būti sveikas skaičius\n      odd: turi būti nelyginis skaičius\n      other_than: privalo būti kitoks nei %{count}\n      present: turi būti tuščias\n      required: turi egzistuoti\n      taken: jau užimtas\n      too_long:\n        few: per ilgas (daugiausiai %{count} simboliai)\n        one: per ilgas (daugiausiai %{count} simbolis)\n        other: per ilgas (daugiausiai %{count} simbolių)\n      too_short:\n        few: per trumpas (mažiausiai %{count} simboliai)\n        one: per trumpas (mažiausiai %{count} simbolis)\n        other: per trumpas (mažiausiai %{count} simbolių)\n      wrong_length: neteisingo ilgio (turi būti %{count} simboliai)\n    template:\n      body: 'Šiuose laukuose yra klaidų:'\n      header:\n        few: Išsaugant objektą %{model} rastos %{count} klaidos\n        one: Išsaugant objektą %{model} rasta %{count} klaida\n        other: Išsaugant objektą %{model} rasta %{count} klaidų\n  helpers:\n    select:\n      prompt: Prašom pasirinkti\n    submit:\n      create: Sukurti %{model}\n      submit: Išsaugoti %{model}\n      update: Atnaujinti %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \" \"\n      precision: 3\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Milijardas\n          million: Milijonas\n          quadrillion: Kvadrilijonas\n          thousand: Tūkstantis\n          trillion: Trilijonas\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: Baitai\n            one: Baitas\n            other: Baitų\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" ir \"\n      two_words_connector: \" ir \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%Y %B %d %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/lv.yml",
    "content": "---\nlv:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Pārbaude neizdevās: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Nevar dzēst ierakstu, jo ir atkarīgi %{record}\n          has_one: Nevar dzēst ierakstu, jo ir atkarīgs %{record}\n  date:\n    abbr_day_names:\n    - Sv.\n    - P.\n    - O.\n    - T.\n    - C.\n    - Pk.\n    - S.\n    abbr_month_names:\n    -\n    - Janv\n    - Febr\n    - Marts\n    - Apr\n    - Maijs\n    - Jūn\n    - Jūl\n    - Aug\n    - Sept\n    - Okt\n    - Nov\n    - Dec\n    day_names:\n    - svētdiena\n    - pirmdiena\n    - otrdiena\n    - trešdiena\n    - ceturtdiena\n    - piektdiena\n    - sestdiena\n    formats:\n      default: \"%d.%m.%Y.\"\n      long: \"%Y. gada %e. %B\"\n      short: \"%e. %B\"\n    month_names:\n    -\n    - janvārī\n    - februārī\n    - martā\n    - aprīlī\n    - maijā\n    - jūnijā\n    - jūlijā\n    - augustā\n    - septembrī\n    - oktobrī\n    - novembrī\n    - decembrī\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: apmēram %{count} stunda\n        other: apmēram %{count} stundas\n        zero: apmēram %{count} stundas\n      about_x_months:\n        one: apmēram %{count} mēnesis\n        other: apmēram %{count} mēneši\n        zero: apmēram %{count} mēneši\n      about_x_years:\n        one: apmēram %{count} gads\n        other: apmēram %{count} gadi\n        zero: apmēram %{count} gadi\n      almost_x_years:\n        one: gandrīz %{count} gads\n        other: gandrīz %{count} gadi\n        zero: gandrīz %{count} gadi\n      half_a_minute: pusminūte\n      less_than_x_minutes:\n        one: mazāk par %{count} minūti\n        other: mazāk par %{count} minūtēm\n        zero: mazāk par %{count} minūtēm\n      less_than_x_seconds:\n        one: mazāk par %{count} sekundi\n        other: mazāk par %{count} sekundēm\n        zero: mazāk par %{count} sekundēm\n      over_x_years:\n        one: vairāk kā %{count} gads\n        other: vairāk kā %{count} gadi\n        zero: vairāk kā %{count} gadi\n      x_days:\n        one: \"%{count} diena\"\n        other: \"%{count} dienas\"\n        zero: \"%{count} dienas\"\n      x_minutes:\n        one: \"%{count} minūte\"\n        other: \"%{count} minūtes\"\n        zero: \"%{count} minūtes\"\n      x_months:\n        one: \"%{count} mēnesis\"\n        other: \"%{count} mēneši\"\n        zero: \"%{count} mēneši\"\n      x_seconds:\n        one: \"%{count} sekunde\"\n        other: \"%{count} sekundes\"\n        zero: \"%{count} sekundes\"\n    prompts:\n      day: diena\n      hour: stunda\n      minute: minūte\n      month: mēnesis\n      second: sekunde\n      year: gads\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: ir jāpiekrīt\n      blank: ir jābūt aizpildītam\n      confirmation: nesakrīt ar apstiprinājumu\n      empty: ir jābūt aizpildītam\n      equal_to: ir jābūt vienādam ar %{count}\n      even: ir jābūt pāra skaitlim\n      exclusion: nav pieejams\n      greater_than: ir jābūt lielākam par %{count}\n      greater_than_or_equal_to: ir jābūt lielākam vai vienādam ar %{count}\n      inclusion: nav iekļauts sarakstā\n      invalid: nav derīgs\n      less_than: ir jābūt mazākam par %{count}\n      less_than_or_equal_to: ir jābūt mazākam vai vienādam ar %{count}\n      model_invalid: 'Validācija neizdevās: %{errors}'\n      not_a_number: nav skaitlis\n      not_an_integer: ir jābūt veselam skaitlim\n      odd: ir jābūt nepāra skaitlim\n      other_than: jābūt citam nekā %{count}\n      present: jābūt tukšam\n      required: ir jābūt\n      taken: ir jau aizņemts\n      too_long:\n        one: ir par garu (maksimums ir %{count} simbols)\n        other: ir par garu (maksimums ir %{count} simboli)\n        zero: ir par garu (maksimums ir %{count} simboli)\n      too_short:\n        one: ir par īsu (minimums ir %{count} simbols)\n        other: ir par īsu (minimums ir %{count} simboli)\n        zero: ir par īsu (minimums ir %{count} simboli)\n      wrong_length:\n        one: ir nepareizs garums (jābūt %{count} simbolam)\n        other: ir nepareizs garums (jābūt %{count} simboliem)\n        zero: ir nepareizs garums (jābūt %{count} simboliem)\n    template:\n      body: 'Problēmas ir šajos ievades laukos:'\n      header:\n        one: Dēļ %{count} kļūdas šis %{model} netika saglabāts\n        other: Dēļ %{count} kļūdām šis %{model} netika saglabāts\n        zero: Dēļ %{count} kļūdām šis %{model} netika saglabāts\n  helpers:\n    select:\n      prompt: Lūdzu izvēlies\n    submit:\n      create: Izveidot %{model}\n      submit: Saglabāt %{model}\n      update: Atjaunināt %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: miljards\n            other: miljardi\n            zero: miljardi\n          million:\n            one: miljons\n            other: miljoni\n            zero: miljoni\n          quadrillion:\n            one: kvadriljons\n            other: kvadriljoni\n            zero: kvadriljoni\n          thousand:\n            one: tūkstotis\n            other: tūkstoši\n            zero: tūkstoši\n          trillion:\n            one: triljons\n            other: triljoni\n            zero: triljoni\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: baits\n            other: baiti\n            zero: baiti\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" un \"\n      two_words_connector: \" un \"\n      words_connector: \", \"\n  time:\n    am: priekšpusdiena\n    formats:\n      default: \"%Y. gada %e. %B, %H:%M\"\n      long: \"%Y. gada %e. %B, %H:%M:%S\"\n      short: \"%d.%m.%Y., %H:%M\"\n    pm: pēcpusdiena\n"
  },
  {
    "path": "config/locales/defaults/mg.yml",
    "content": "---\nmg:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Tsy tontosa ny fankatoavana : %{errors}'\n        restrict_dependent_destroy:\n          has_many: Tsy afaka mamafa io andalana io ianao satria misy %{record} betsaka\n            miankina aminy\n          has_one: Tsy afaka mamafa io andalana io ianao satria misy %{record} iray\n            miankina amin'io\n  date:\n    abbr_day_names:\n    - Alah\n    - Alats\n    - Tal\n    - Alar\n    - Alak\n    - Zom\n    - Asab\n    abbr_month_names:\n    -\n    - jan.\n    - feb.\n    - mar.\n    - apr.\n    - may\n    - jona\n    - jolay.\n    - aog\n    - sept.\n    - okt.\n    - nov.\n    - des.\n    day_names:\n    - Alahady\n    - Alatsinainy\n    - Talata\n    - Alarobia\n    - Alakamisy\n    - Zoma\n    - Asabotsy\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%e %B %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - janoary\n    - febroary\n    - martsa\n    - aprily\n    - may\n    - jona\n    - jolay\n    - aogositra\n    - septambra\n    - aktobra\n    - novambra\n    - desambra\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: adiny iray eo ho eo\n        other: adiny %{count} eo ho eo\n      about_x_months:\n        one: iray volana eo ho eo\n        other: volana %{count} eo ho eo\n      about_x_years:\n        one: herintaona eo ho eo\n        other: taona %{count} eo ho eo\n      almost_x_years:\n        one: saika herintaona\n        other: saika %{count} taona\n      half_a_minute: antsasak'adiny\n      less_than_x_minutes:\n        one: latsaky ny iray segondra\n        other: latsaky ny %{count} minitra\n        zero: latsaky ny iray minitra\n      less_than_x_seconds:\n        one: latsaky ny iray segondra\n        other: latsaky ny %{count} segondra\n        zero: latsaky ny iray segondra\n      over_x_years:\n        one: Herintaona mahery\n        other: maherin'ny %{count} taona\n      x_days:\n        one: \"%{count} andro\"\n        other: \"%{count} andro\"\n      x_minutes:\n        one: \"%{count} minitra\"\n        other: \"%{count} minitra\"\n      x_months:\n        one: \"%{count} volana\"\n        other: \"%{count} volana\"\n      x_seconds:\n        one: \"%{count} segondra\"\n        other: \"%{count} segondra\"\n      x_years:\n        one: \"%{count} taona\"\n        other: \"%{count} taona\"\n    prompts:\n      day: Andro\n      hour: Ora\n      minute: Minitra\n      month: Volana\n      second: Segondra\n      year: Taona\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: tsy maintsy ekena\n      blank: tsy maintsy fenoina\n      confirmation: tsy mifanaraka amin'ny %{attribute}\n      empty: tsy maintsy fenoina\n      equal_to: tsy maintsy mitovy amin'ny %{count}\n      even: tsy maintsy miankin-droa\n      exclusion: Tsy misy\n      greater_than: Tsy mihoatra ny %{count}\n      greater_than_or_equal_to: tsy maintsy mihoatra na mitovy amin'ny à %{count}\n      inclusion: tsy ao anaty lisitra\n      invalid: tsy ekena\n      less_than: tsy maintsy kely noho ny %{count}\n      less_than_or_equal_to: tsy kely na mitovy nohon'ny %{count}\n      model_invalid: 'Fangatahana tsy tontosa: %{errors}'\n      not_a_number: tsy isa io\n      not_an_integer: tsy maintsy isa tsotra\n      odd: tsy maintsy impair\n      other_than: tsy maintsy hafa noho ny %{count}\n      present: tsy maintsy banga\n      required: tsy maintsy misy\n      taken: tsy misy\n      too_long:\n        one: dia lava loatra (tsy mihoatra ny litera iray)\n        other: dia lava loatra (tsy mihoatra ny litera %{count})\n      too_short:\n        one: dia fohy loatra (farafahakeliny litera iray)\n        other: dia fohy loatra (farafahakeliny litera %{count})\n      wrong_length:\n        one: tsy amin'ny halavany (tokony ho litera iray)\n        other: tsy amin'ny halavany (tokony ho litera %{count})\n    template:\n      body: 'Hamarino ireo saha manaraka azafady : '\n      header:\n        one: 'Tsy azo tadidiana ity %{model} : olana %{count}'\n        other: 'Tsy azo tadidiana ity %{model} : olana %{count}'\n  helpers:\n    select:\n      prompt: Misafidiana azafady\n    submit:\n      create: Mamorona %{model} iray\n      submit: Tadidiana ity %{model}\n      update: Ovaina ity %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: Ar\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: tapitrisa\n          million: hetsy\n          quadrillion: hetsy tapitrisa\n          thousand: Arivo\n          trillion: arivo tapitrisa\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: octet\n            other: octets\n          eb: Eo\n          gb: Go\n          kb: ko\n          mb: Mo\n          pb: Po\n          tb: To\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" et \"\n      two_words_connector: \" et \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%d %B %Y %Hh %Mmin %Ss\"\n      long: \"%A %d %B %Y %Hh%M\"\n      short: \"%d %b %Hh%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/mk.yml",
    "content": "---\nmk:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'неуспешна валидација: %{errors}'\n  date:\n    abbr_day_names:\n    - Нед\n    - Пон\n    - Вто\n    - Сре\n    - Чет\n    - Пет\n    - Саб\n    abbr_month_names:\n    -\n    - Јан\n    - Фев\n    - Мар\n    - Апр\n    - Мај\n    - Јун\n    - Јул\n    - Авг\n    - Сеп\n    - Окт\n    - Ное\n    - Дек\n    day_names:\n    - Недела\n    - Понеделник\n    - Вторник\n    - Среда\n    - Четврток\n    - Петок\n    - Сабота\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%B %e, %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - Јануари\n    - Февруари\n    - Март\n    - Април\n    - Мај\n    - Јуни\n    - Јули\n    - Август\n    - Септември\n    - Октомври\n    - Ноември\n    - Декември\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: околу %{count} час\n        other: околу %{count} часа\n      about_x_months:\n        one: околу %{count} месец\n        other: околу %{count} месеци\n      about_x_years:\n        one: околу %{count} година\n        other: околу %{count} години\n      almost_x_years:\n        one: скоро %{count} година\n        other: скоро %{count} години\n      half_a_minute: пола минута\n      less_than_x_minutes:\n        one: помалку од %{count} минута\n        other: помалку од %{count} минути\n      less_than_x_seconds:\n        one: помалку од %{count} секунда\n        other: помалку од %{count} секунди\n      over_x_years:\n        one: над %{count} година\n        other: над %{count} години\n      x_days:\n        one: \"%{count} ден\"\n        other: \"%{count} денови\"\n      x_minutes:\n        one: \"%{count} минута\"\n        other: \"%{count} минути\"\n      x_months:\n        one: \"%{count} месец\"\n        other: \"%{count} месеци\"\n      x_seconds:\n        one: \"%{count} секунда\"\n        other: \"%{count} секунди\"\n    prompts:\n      day: Ден\n      hour: Час\n      minute: Минута\n      month: Месец\n      second: Секунди\n      year: Година\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: мора да биде прифатен\n      blank: мора да биде зададен\n      confirmation: не се совпаѓа со својата потврда\n      empty: мора да биде зададен\n      equal_to: мора да биде еднакво на %{count}\n      even: мора да биде парно\n      exclusion: не е достапно\n      greater_than: мора да биде поголемо од %{count}\n      greater_than_or_equal_to: мора да биде поголемо или еднакво на %{count}\n      inclusion: не е во листата\n      invalid: не е исправен\n      less_than: мора да биде помало од %{count}\n      less_than_or_equal_to: мора да биде помало или еднакво на %{count}\n      not_a_number: 'не е број '\n      not_an_integer: не е цел број\n      odd: мора да биде непарно\n      taken: е зафатено\n      too_long:\n        one: е предолг (не повеќе од %{count} карактер)\n        other: е предолг (не повеќе од %{count} карактери)\n      too_short:\n        one: е прекраток (не помалку од %{count} карактер)\n        other: е прекраток (не помалку од %{count} карактери)\n      wrong_length:\n        one: несоодветна должина (мора да имате %{count} карактер)\n        other: несоодветна должина (мора да имате %{count} карактери)\n    template:\n      body: 'Ве молиме проверете ги следните полиња:'\n      header:\n        one: 'Не успеав да го зачувам %{model}: %{count} грешка.'\n        other: 'Не успеав да го зачувам %{model}: %{count} грешки.'\n  helpers:\n    select:\n      prompt: Одберете\n    submit:\n      create: Креира %{model}\n      submit: Зачувај %{model}\n      update: Измени %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: MKD\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: илјади\n          million: милјони\n          quadrillion: милјарди\n          thousand: трилјони\n          trillion: квадрилјони\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: бајт\n            other: бајти\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", и \"\n      two_words_connector: \" и \"\n      words_connector: \", \"\n  time:\n    am: АМ\n    formats:\n      default: \"%a %d %b %Y %H:%M:%S %Z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: ПМ\n"
  },
  {
    "path": "config/locales/defaults/ml.yml",
    "content": "---\nml:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: \"%{record} ആയി ബന്ദം ഉള്ളതിനാൽ നീക്കം ചെയ്യാൻ പറ്റില്ല\"\n          has_one: \"%{record} ആയി ബന്ദം ഉള്ളതിനാൽ നീക്കം ചെയ്യാൻ പറ്റില്ല\"\n  date:\n    abbr_day_names:\n    - ഞാ.\n    - തി.\n    - ചൊ.\n    - ബു.\n    - വ്യാ.\n    - വെ.\n    - ശ.\n    abbr_month_names:\n    -\n    - ജനു.\n    - ഫെബ്ര.\n    - മാർ.\n    - എപ്രി.\n    - മെയ്.\n    - ജൂണ്‍.\n    - ജൂലൈ.\n    - ആഗ.\n    - സെപ്തം.\n    - ഒക്ടോ.\n    - നവം.\n    - ഡിസം.\n    day_names:\n    - ഞായര്‍\n    - തിങ്കള്‍\n    - ചൊവ്വ\n    - ബുധൻ\n    - വ്യാഴം\n    - വെള്ളി\n    - ശനി\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B, %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - ജനുവരി\n    - ഫെബ്രുവരി\n    - മാർച്ച്\n    - ഏപ്രിൽ\n    - മെയ്\n    - ജൂണ്‍\n    - ജൂലൈ\n    - ഓഗസ്റ്റ്‌\n    - സെപ്റ്റംബർ\n    - ഒക്ടോബർ\n    - നവംബർ\n    - ഡിസംബർ\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: എകദേശം %{count} മണിക്കൂർ\n        other: എകദേശം %{count} മണിക്കൂർ\n      about_x_months:\n        one: എകദേശം %{count} മാസം\n        other: എകദേശം %{count} മാസം\n      about_x_years:\n        one: എകദേശം %{count} വർഷം\n        other: എകദേശം %{count} വർഷം\n      almost_x_years:\n        one: ഏതാണ്ട്  %{count} വർഷം\n        other: ഏതാണ്ട്  %{count} വർഷം\n      half_a_minute: അര സൂക്ഷ്മ\n      less_than_x_minutes:\n        one: ഒരു മിനുറ്റിനു ഉള്ളിൽ\n        other: \"%{count} മിനുറ്റിനു ഉള്ളിൽ\"\n      less_than_x_seconds:\n        one: ഒരു നിമിഷത്തിനു ഉള്ളിൽ\n        other: \"%{count} നിമിഷത്തിനു ഉള്ളിൽ\"\n      over_x_years:\n        one: ഒരു വര്ഷത്തിനു മേലെ\n        other: \"%{count} വര്ഷത്തിനു മേലെ\"\n      x_days:\n        one: \"%{count} ദിവസം\"\n        other: \"%{count} ദിവസങ്ങൾ\"\n      x_minutes:\n        one: \"%{count} മിനിറ്റ്\"\n        other: \"%{count} മിനിറ്റ്\"\n      x_months:\n        one: \"%{count} മാസം\"\n        other: \"%{count} മാസം\"\n      x_seconds:\n        one: \"%{count} നിമിഷം\"\n        other: \"%{count} നിമിഷം\"\n    prompts:\n      day: ദിവസം\n      hour: മണിക്കൂർ\n      minute: സുക്ഷ്മ\n      month: സുക്ഷ്മ\n      second: നിമിഷം\n      year: വർഷം\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: സ്വീകരികേണ്ടത്  അത്യാവശ്യം ആണ്\n      blank: ഒഴിവായി കിടക്കുവാൻ പാടുള്ളതല്ല\n      confirmation: \"%{attribute} ആയി സാമ്യം ഇല്ല\"\n      empty: ഒഴിവായി കിടക്കുവാൻ പാടുള്ളതല്ല\n      equal_to: \"%{count} ആയി സാമ്യം വേണം\"\n      even: ഇരട്ട സംഖ്യ ആയിരിക്കണം\n      exclusion: കരുതിവെച്ചിരികുന്നതാണ്\n      greater_than: \"%{count} നേകാൾ വലുതായിരിക്കണം\"\n      greater_than_or_equal_to: \"%{count} നു തുല്യമോ അല്ലെങ്കിൽ വലുതോ ആയിരിക്കണം\"\n      inclusion: ഇതിൽ ഉള്ട്പെട്ടിട്ടില്ല\n      invalid: അസാധുവാണ്\n      less_than: \"%{count} നേകാൾ ചെറുതായിരിക്കണം\"\n      less_than_or_equal_to: \"%{count} നു തുല്യമോ അല്ലെങ്കിൽ ചെറുതോ ആയിരിക്കണം\"\n      model_invalid: 'മൂല്യനിർണ്ണയം പരാജയപ്പെട്ടു: %{errors}'\n      not_a_number: ഒരു അക്കം അല്ല\n      not_an_integer: ഒരു അക്കം ആയിരിക്കണം\n      odd: ഒട്ടസന്ഘ്യ ആയിരിക്കണം\n      other_than: must be other than %{count}\n      present: ഒഴിവായി ഇരിക്കണം\n      required: എന്തായാലും ഉണ്ടായിരിക്കണം\n      taken: ഇതിനു മുൻപേ ഉപയോഗിച്ചിരിക്കുന്നു\n      too_long:\n        one: വളരെ വലുതാണ് (പരമാവധി %{count} പ്രതീകം)\n        other: വളരെ വലുതാണ് (പരമാവധി %{count} പ്രതീകങ്ങൾ)\n      too_short:\n        one: വളരെ ചെറുതാണ് (ഏറ്റവും കുറഞ്ഞത്‌  %{count} പ്രതീകം)\n        other: വളരെ ചെറുതാണ് (ഏറ്റവും കുറഞ്ഞത്‌ %{count} പ്രതീകങ്ങൾ)\n      wrong_length:\n        one: തെറ്റായ നീളം ആണ്  (%{count} പ്രതീകം ആയിരിക്കണം)\n        other: തെറ്റായ നീളം ആണ്  (%{count} പ്രതീകങ്ങൾ ആയിരിക്കണം)\n    template:\n      body: 'താഴെ പറഞ്ഞവയിൽ തെറ്റുകൾ ഉണ്ട്:'\n      header:\n        one: \"%{model} സേവ് ചെയ്യുനത്തിൽ നിന്നും ഒരു തെറ്റ് വിലക്കിയിരിക്കുന്നു\"\n        other: \"%{model} സേവ് ചെയ്യുനത്തിൽ നിന്നും %{count} തെറ്റുകൾ വിലക്കിയിരിക്കുന്നു\"\n  helpers:\n    select:\n      prompt: ദയവായി തിരഞ്ഞെടുക്കുക\n    submit:\n      create: \"%{model} സൃഷ്ടിക്കുക\"\n      submit: \"%{model} സേവ് ചെയ്യുക\"\n      update: \"%{model} തിരുത്തുക്ക\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: നൂറുകോടി\n          million: ദശലക്ഷം\n          quadrillion: ക്വാഡ്രില്യൺ\n          thousand: ആയിരം\n          trillion: ട്രില്യൺ\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: ബൈറ്റ്\n            other: ബൈറ്റുകൾ\n          gb: ജി.ബി\n          kb: കെ.ബി.\n          mb: എം.ബി.\n          tb: ടി.ബി\n    percentage:\n      format:\n        delimiter: ''\n        format: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", ഒപ്പം \"\n      two_words_connector: \" ഒപ്പം \"\n      words_connector: \", \"\n  time:\n    am: രാവിലെ\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%Y, %B %d %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: വൈകിട്ട്\n"
  },
  {
    "path": "config/locales/defaults/mn.yml",
    "content": "---\nmn:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Шалгалт амжилтгүй: %{errors}'\n  date:\n    abbr_day_names:\n    - Ня\n    - Да\n    - Мя\n    - Лх\n    - Пү\n    - Ба\n    - Бя\n    abbr_month_names:\n    -\n    - 1 сар\n    - 2 сар\n    - 3 сар\n    - 4 сар\n    - 5 сар\n    - 6 сар\n    - 7 сар\n    - 8 сар\n    - 9 сар\n    - 10 сар\n    - 11 сар\n    - 12 сар\n    day_names:\n    - Ням\n    - Даваа\n    - Мягмар\n    - Лхагва\n    - Пүрэв\n    - Баасан\n    - Бямба\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%Y %B %d\"\n      short: \"%y-%m-%d\"\n    month_names:\n    -\n    - 1 сар\n    - 2 сар\n    - 3 сар\n    - 4 сар\n    - 5 сар\n    - 6 сар\n    - 7 сар\n    - 8 сар\n    - 9 сар\n    - 10 сар\n    - 11 сар\n    - 12 сар\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: \"%{count} цаг орчим\"\n        other: \"%{count} цаг орчим\"\n      about_x_months:\n        one: \"%{count} сар орчим\"\n        other: \"%{count} сар орчим\"\n      about_x_years:\n        one: \"%{count} жил орчим\"\n        other: \"%{count} жил орчим\"\n      almost_x_years:\n        one: бараг %{count} жил\n        other: бараг %{count} жил\n      half_a_minute: хагас минут\n      less_than_x_minutes:\n        one: \"%{count} минутаас бага\"\n        other: \"%{count} минутаас бага\"\n      less_than_x_seconds:\n        one: \"%{count} секундээс бага\"\n        other: \"%{count} секундээс бага\"\n      over_x_years:\n        one: \"%{count} жилээс илүү\"\n        other: \"%{count} жилээс илүү\"\n      x_days:\n        one: \"%{count} өдөр\"\n        other: \"%{count} өдөр\"\n      x_minutes:\n        one: \"%{count} минут\"\n        other: \"%{count} минут\"\n      x_months:\n        one: \"%{count} сар\"\n        other: \"%{count} сар\"\n      x_seconds:\n        one: \"%{count} секунд\"\n        other: \"%{count} секунд\"\n    prompts:\n      day: Өдөр\n      hour: Цаг\n      minute: Минут\n      month: Сар\n      second: Секунд\n      year: Жил\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: хүлээн зөвшөөрөгдсөн байх ёстой\n      blank: хоосон байж болохгүй\n      confirmation: адилгүй байна\n      empty: байхгүй байж болохгүй\n      equal_to: \"%{count}-тэй тэнцүү байх ёстой\"\n      even: тэгш байх ёстой\n      exclusion: бол ашиглахад хориотой\n      greater_than: \"%{count}-с их байх ёстой\"\n      greater_than_or_equal_to: \"%{count}-с их юмуу эсвэл тэнцүү байх ёстой\"\n      inclusion: жагсаалтанд алга байна\n      invalid: буруу байна\n      less_than: \"%{count}-с бага байх ёстой\"\n      less_than_or_equal_to: \"%{count}-с бага юмуу эсвэл тэнцүү байх ёстой\"\n      not_a_number: тоо биш байна\n      not_an_integer: бүхэл тоо байх ёстой\n      odd: сонгой байх ёстой\n      taken: аль хэдийн авчихсан байна\n      too_long:\n        one: урт байна (хамгийн уртдаа %{count} тэмдэгт)\n        other: урт байна (хамгийн уртдаа %{count} тэмдэгт)\n      too_short:\n        one: богино байна (хамгийн багадаа %{count} тэмдэгт)\n        other: богино байна (хамгийн багадаа %{count} тэмдэгт)\n      wrong_length:\n        one: урт нь буруу байна (%{count} тэмдэгт байх ёстой)\n        other: урт нь буруу байна (%{count} тэмдэгт байх ёстой)\n    template:\n      body: 'Дараах талбарууд дээр алдаа гарлаа:'\n      header:\n        one: \"%{count} алдаа гарсан тул %{model} хадгалагдахгүй байна\"\n        other: \"%{count} алдаа гарсан тул %{model} хадгалагдахгүй байна\"\n  helpers:\n    select:\n      prompt: Сонгоно уу\n    submit:\n      create: Үүсгэх\n      submit: Хадгалах\n      update: Шинэчлэх\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: төг.\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Тэрбум\n          million: Сая\n          quadrillion: Тунамал\n          thousand: Мянга\n          trillion: Их наяд\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Байт\n            other: Байт\n          gb: ГБ\n          kb: КБ\n          mb: МБ\n          tb: ТБ\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" болон \"\n      two_words_connector: \" болон \"\n      words_connector: \", \"\n  time:\n    am: өглөө\n    formats:\n      default: \"%Y-%m-%d %H:%M\"\n      long: \"%Y %B %d, %H:%M:%S\"\n      short: \"%y-%m-%d\"\n    pm: орой\n"
  },
  {
    "path": "config/locales/defaults/mr-IN.yml",
    "content": "---\nmr-IN:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'प्रमाणीकरण अयशस्वी: %{errors}'\n        restrict_dependent_destroy:\n          has_many: अवलंबून %{record} अस्तित्वात असल्याने रेकॉर्ड हटवू शकत नाही\n          has_one: अवलंबून %{record} अस्तित्वात असल्याने रेकॉर्ड हटवू शकत नाही\n  date:\n    abbr_day_names:\n    - सोम\n    - मंगळ\n    - बुध\n    - गुरु\n    - शुक्र\n    - शनि\n    - रवि\n    abbr_month_names:\n    -\n    - जाने\n    - फेब्रु\n    - मार्च\n    - एप्रि\n    - मे\n    - जून\n    - जुलै\n    - ऑग\n    - सेप्टें\n    - ऑक्टोबर\n    - नोव्हें\n    - डिसे\n    day_names:\n    - सोमवार\n    - मंगळवार\n    - बुधवार\n    - गुरुवार\n    - शुक्रवार\n    - शनिवार\n    - रविवार\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - जानेवारी\n    - फेब्रुवारी\n    - मार्च\n    - एप्रिल\n    - मे\n    - जून\n    - जुलै\n    - ऑगस्ट\n    - सप्टेंबर\n    - ऑक्टोबर\n    - नोव्हेंबर\n    - डिसेंबर\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: सुमारे एक तास\n        other: सुमारे %{count} तास\n      about_x_months:\n        one: सुमारे %{count} महीना\n        other: सुमारे %{count} महिना\n      about_x_years:\n        one: सुमारे %{count} वर्ष\n        other: सुमारे %{count} वर्ष\n      almost_x_years:\n        one: जवळजवळ एक वर्ष\n        other: जवळजवळ %{count} वर्ष\n      half_a_minute: अर्धा मिनिट\n      less_than_x_minutes:\n        one: एका मिनिटापेक्षा कमी\n        other: \"%{count} मिनिटापेक्षा कमी\"\n      less_than_x_seconds:\n        one: एक सेकंद पेक्षा कमी\n        other: \"%{count} सेकंद पेक्षा कमी\"\n      over_x_years:\n        one: एका वर्षापेक्षा जास्त काळ\n        other: \"%{count} वर्षापेक्षा जास्त काळ\"\n      x_days:\n        one: एक दिवस\n        other: \"%{count} दिवस\"\n      x_minutes:\n        one: एक मिनिट\n        other: \"%{count} मिनिट\"\n      x_months:\n        one: एक महिना\n        other: \"%{count} महिना\"\n      x_seconds:\n        one: एक सेकंद\n        other: \"%{count} सेकंद\"\n    prompts:\n      day: दिवस\n      hour: तास\n      minute: मिनिट\n      month: महिना\n      second: सेकंद\n      year: वर्ष\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: मान्य केले पाहिजे\n      blank: रिक्त ठेवता येणार नाही\n      confirmation: \"%{attribute} जुळत नाही\"\n      empty: रिक्त असू शकत नाही\n      equal_to: \"%{count} समान असणे आवश्यक\"\n      even: समांक असणे आवश्यक आहे\n      exclusion: राखीव आहे\n      greater_than: \"%{count} पेक्षा जास्त असणे आवश्यक आहे\"\n      greater_than_or_equal_to: \"%{count} पेक्षा मोठे किंवा समान असणे आवश्यक आहे\"\n      inclusion: यादीत समाविष्ट नाही\n      invalid: अवैध आहे\n      less_than: \"%{count} पेक्षा कमी असणे आवश्यक\"\n      less_than_or_equal_to: \"%{count} पेक्षा कमी किंवा समान असणे आवश्यक आहे\"\n      not_a_number: क्रमांक नाही\n      not_an_integer: पूर्णांक असणे आवश्यक आहे\n      odd: विषम संख्या असणे आवश्यक आहे\n      other_than: \"%{count} पेक्षा इतर असणे आवश्यक आहे\"\n      present: रिक्त असणे आवश्यक आहे\n      taken: यापूर्वीच घेतले गेले आहे\n      too_long:\n        one: खूप लांब आहे (जास्तीत जास्त एक वर्ण परवानगी आहे)\n        other: खूप लांब आहे (जास्तीत जास्त %{count} वर्ण परवानगी आहे)\n      too_short:\n        one: खूप लहान आहे (किमान एक वर्ण परवानगी आहे)\n        other: खूप लहान आहे (किमान %{count} वर्ण परवानगी आहे)\n      wrong_length:\n        one: लांबी चुक आहे (एक वर्ण असणे आवश्यक आहे)\n        other: लांबी चुक आहे (%{count} वर्ण असणे आवश्यक आहे)\n    template:\n      body: 'खालील फील्ड सह समस्या होते:'\n      header:\n        one: एक चूक ह्या %{model} ला जतन करण्यापासून प्रतिबंधित करत आहे\n        other: \"%{count} चुका ह्या %{model} ला जतन करण्यापासून प्रतिबंधित करत आहे\"\n  helpers:\n    select:\n      prompt: कृपया निवडा\n    submit:\n      create: \"%{model} निर्माण करा\"\n      submit: \"%{model} जतन करा\"\n      update: \"%{model} अद्यतनित करा\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"₹\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: अब्ज\n          million: दशलक्ष\n          quadrillion: एकावर १५ शून्य इतकी संख्या\n          thousand: हजार\n          trillion: एकावर १२ शून्ये इतकी संख्या\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", आणि \"\n      two_words_connector: \" आणि \"\n      words_connector: \", \"\n  time:\n    am: म.पू.\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: म.नं.\n"
  },
  {
    "path": "config/locales/defaults/ms.yml",
    "content": "---\nms:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Pengesahan gagal: %{errors}'\n  date:\n    abbr_day_names:\n    - Ahd\n    - Isn\n    - Sel\n    - Rab\n    - Kha\n    - Jum\n    - Sab\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mac\n    - Apr\n    - Mei\n    - Jun\n    - Jul\n    - Ogo\n    - Sep\n    - Okt\n    - Nov\n    - Dis\n    day_names:\n    - Ahad\n    - Isnin\n    - Selasa\n    - Rabu\n    - Khamis\n    - Jumaat\n    - Sabtu\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B, %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Januari\n    - Febuari\n    - Mac\n    - April\n    - Mei\n    - Jun\n    - Julai\n    - Ogos\n    - September\n    - Oktober\n    - November\n    - Disember\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: lebih kurang %{count} jam\n        other: lebih kurang %{count} jam\n      about_x_months:\n        one: lebih kurang %{count} bulan\n        other: lebih kurang %{count} bulan\n      about_x_years:\n        one: lebih kurang %{count} tahun\n        other: lebih kurang %{count} tahun\n      almost_x_years:\n        one: hampir %{count} tahun\n        other: hampir %{count} tahun\n      half_a_minute: setengah minit\n      less_than_x_minutes:\n        one: kurang dari satu minit\n        other: kurang dari %{count} minit\n      less_than_x_seconds:\n        one: kurang dari satu saat\n        other: kurang dari %{count} saat\n      over_x_years:\n        one: lebih %{count} tahun\n        other: lebih %{count} tahun\n      x_days:\n        one: \"%{count} hari\"\n        other: \"%{count} hari\"\n      x_minutes:\n        one: \"%{count} minit\"\n        other: \"%{count} minit\"\n      x_months:\n        one: \"%{count} bulan\"\n        other: \"%{count} bulan\"\n      x_seconds:\n        one: \"%{count} saat\"\n        other: \"%{count} saat\"\n    prompts:\n      day: Hari\n      hour: Jam\n      minute: Minit\n      month: Bulan\n      second: Saat\n      year: Tahun\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: wajib diterima\n      blank: tidak boleh dikosongkan\n      confirmation: tidak sama dengan penegasan\n      empty: tidak boleh dikosongkan\n      equal_to: mesti sama dengan %{count}\n      even: mesti genap\n      exclusion: telah terpelihara\n      greater_than: mesti lebih dari %{count}\n      greater_than_or_equal_to: mesti melibihi atau sama dengan %{count}\n      inclusion: tidak termasuk dalam senarai\n      invalid: adalah tidak laku\n      less_than: mesti kurang daripada %{count}\n      less_than_or_equal_to: mesti kurang daripada atat sama dengan %{count}\n      not_a_number: bukan nombor\n      not_an_integer: mestilah integer\n      odd: mesti ganjil\n      taken: telah digunakan\n      too_long:\n        one: terlalu panjang (maksima adalah %{count} karakter)\n        other: terlalu panjang (maksima adalah %{count} karakter)\n      too_short:\n        one: terlalu pendek (minima adalah %{count} karakter)\n        other: terlalu pendek (minima adalah %{count} karakter)\n      wrong_length:\n        one: mempunyai panjang yang salah (sepatutnya %{count} karakter sahaja)\n        other: mempunyai panjang yang salah(sepatutnya %{count} karakter sahaja)\n    template:\n      body: 'Terdapat masalah dengan medan data tersebut:'\n      header:\n        one: \"%{count} ralat menhalang  %{model} ini dari disimpan\"\n        other: \"%{count} ralat menhalang %{model} ini dari disimpan\"\n  helpers:\n    select:\n      prompt: Sila pilih\n    submit:\n      create: Cipta %{model}\n      submit: Simpan %{model}\n      update: Kemaskini %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: RM\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Ribu Juta\n          million: Juta\n          quadrillion: Juta-juta\n          thousand: Ribu\n          trillion: Trilion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Bait\n            other: Bait\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", dan \"\n      two_words_connector: \" dan \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B, %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/nb.yml",
    "content": "---\nnb:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Det oppstod feil: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Kan ikke slette registreringen, fordi %{record} avhenger av denne.\n          has_one: Kan ikke slette registreringen, fordi %{record} avhenger av denne.\n  date:\n    abbr_day_names:\n    - søn\n    - man\n    - tir\n    - ons\n    - tor\n    - fre\n    - lør\n    abbr_month_names:\n    -\n    - jan\n    - feb\n    - mar\n    - apr\n    - mai\n    - jun\n    - jul\n    - aug\n    - sep\n    - okt\n    - nov\n    - des\n    day_names:\n    - søndag\n    - mandag\n    - tirsdag\n    - onsdag\n    - torsdag\n    - fredag\n    - lørdag\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%e. %B %Y\"\n      short: \"%e. %b\"\n    month_names:\n    -\n    - januar\n    - februar\n    - mars\n    - april\n    - mai\n    - juni\n    - juli\n    - august\n    - september\n    - oktober\n    - november\n    - desember\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: rundt %{count} time\n        other: rundt %{count} timer\n      about_x_months:\n        one: rundt %{count} måned\n        other: rundt %{count} måneder\n      about_x_years:\n        one: rundt %{count} år\n        other: rundt %{count} år\n      almost_x_years:\n        one: nesten %{count} år\n        other: nesten %{count} år\n      half_a_minute: et halvt minutt\n      less_than_x_minutes:\n        one: mindre enn %{count} minutt\n        other: mindre enn %{count} minutter\n      less_than_x_seconds:\n        one: mindre enn %{count} sekund\n        other: mindre enn %{count} sekunder\n      over_x_years:\n        one: over %{count} år\n        other: over %{count} år\n      x_days:\n        one: \"%{count} dag\"\n        other: \"%{count} dager\"\n      x_minutes:\n        one: \"%{count} minutt\"\n        other: \"%{count} minutter\"\n      x_months:\n        one: \"%{count} måned\"\n        other: \"%{count} måneder\"\n      x_seconds:\n        one: \"%{count} sekund\"\n        other: \"%{count} sekunder\"\n      x_years:\n        one: \"%{count} år\"\n        other: \"%{count} år\"\n    prompts:\n      day: dag\n      hour: time\n      minute: minutt\n      month: måned\n      second: sekund\n      year: år\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: må være akseptert\n      blank: kan ikke være tom\n      confirmation: er ikke lik %{attribute}\n      empty: kan ikke være tom\n      equal_to: må være lik %{count}\n      even: må være partall\n      exclusion: er reservert\n      greater_than: må være større enn %{count}\n      greater_than_or_equal_to: må være større enn eller lik %{count}\n      inclusion: er ikke inkludert i listen\n      invalid: er ugyldig\n      less_than: må være mindre enn %{count}\n      less_than_or_equal_to: må være mindre enn eller lik %{count}\n      model_invalid: 'Det oppstod feil: %{errors}'\n      not_a_number: er ikke et tall\n      not_an_integer: er ikke et heltall\n      odd: må være oddetall\n      other_than: kan ikke være nøyaktig %{count}\n      present: må være tom\n      required: må eksistere\n      taken: er allerede i bruk\n      too_long:\n        one: er for lang (maksimalt %{count} tegn)\n        other: er for lang (maksimalt %{count} tegn)\n      too_short:\n        one: er for kort (minst %{count} tegn)\n        other: er for kort (minst %{count} tegn)\n      wrong_length:\n        one: har feil lengde (må være %{count} tegn)\n        other: har feil lengde (må være %{count} tegn)\n    template:\n      body: 'Det oppstod problemer med følgende felt:'\n      header:\n        one: Kunne ikke lagre %{model} på grunn av én feil.\n        other: Kunne ikke lagre %{model} på grunn av %{count} feil.\n  helpers:\n    select:\n      prompt: Vennligst velg\n    submit:\n      create: Lag %{model}\n      submit: Lagre %{model}\n      update: Oppdater %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: kr\n    format:\n      delimiter: \" \"\n      precision: 2\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: true\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: milliard\n            other: milliarder\n          million:\n            one: million\n            other: millioner\n          quadrillion:\n            one: billiard\n            other: billiarder\n          thousand: tusen\n          trillion:\n            one: billion\n            other: billioner\n          unit: ''\n      format:\n        delimiter: \" \"\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: kB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" og \"\n      two_words_connector: \" og \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A, %e. %B %Y, %H:%M\"\n      long: \"%A, %e. %B %Y, %H:%M\"\n      short: \"%e. %B, %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/ne.yml",
    "content": "---\nne:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'मान्य भएन: %{errors}'\n        restrict_dependent_destroy:\n          has_many: रेकर्ड मेटाउन सक्दैन किनभने धेरै निर्भर %{record} अवस्थित छन\n          has_one: रेकर्ड मेटाउन सक्दैन किनभने एउटा निर्भर %{record} अवस्थित छ\n  date:\n    abbr_day_names:\n    - आईत\n    - सोम\n    - मंगल\n    - बुध\n    - बिही\n    - शुक्र\n    - शनि\n    abbr_month_names:\n    -\n    - जन.\n    - फेब्रु.\n    - मार्च\n    - अप्रिल\n    - मई\n    - जुन\n    - जुलाई\n    - अगष्ट\n    - सेप्ट.\n    - अक्टो.\n    - नोभ.\n    - डिसे.\n    day_names:\n    - आईतबार\n    - सोमबार\n    - मंगलबार\n    - बुधबार\n    - बिहीबार\n    - शुक्रबार\n    - शनिबार\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - जनवरी\n    - फेब्रुवरी\n    - मार्च\n    - अप्रिल\n    - मई\n    - जुन\n    - जुलाई\n    - अगष्ट\n    - सेप्टेम्बार\n    - अक्टोबर\n    - नोभेम्बर\n    - डिसेम्बर\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: लगभग %{count} घण्टा\n        other: लगभग %{count} घण्टा\n      about_x_months:\n        one: लगभग %{count} महिना\n        other: लगभग %{count} महिना\n      about_x_years:\n        one: लगभग %{count} बर्ष\n        other: लगभग %{count} बर्ष\n      almost_x_years:\n        one: झण्डै %{count} बर्ष\n        other: झण्डै %{count} बर्ष\n      half_a_minute: आधा मिनेट\n      less_than_x_minutes:\n        one: \"%{count} मिनेटभन्दा कम्ति\"\n        other: \"%{count} मिनेटभन्दा कम्ति\"\n      less_than_x_seconds:\n        one: \"%{count} सेकेण्डभन्दा कम्ति\"\n        other: \"%{count} सेकेण्डभन्दा कम्ति\"\n      over_x_years:\n        one: \"%{count} बर्षभन्दा बढी\"\n        other: \"%{count} बर्षभन्दा बेसी\"\n      x_days:\n        one: \"%{count} दिन\"\n        other: \"%{count} दिन\"\n      x_minutes:\n        one: \"%{count} मिनेट\"\n        other: \"%{count} मिनेट\"\n      x_months:\n        one: \"%{count} महिना\"\n        other: \"%{count} महिना\"\n      x_seconds:\n        one: \"%{count} सेकेण्ड\"\n        other: \"%{count} सेकेण्ड\"\n    prompts:\n      day: दिन\n      hour: घण्टा\n      minute: मिनेट\n      month: महिना\n      second: सेकेण्ड\n      year: बर्ष\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: स्वीकार गरिनुपर्नेछ\n      blank: खाली हुन सक्दैन\n      confirmation: पुष्टिकरणसँग मेल खाँदैन\n      empty: रित्तो हुन सक्दैन\n      equal_to: \"%{count} सँग बराबर हुनुपर्नेछ\"\n      even: जोर संख्या हुनुपर्नेछ\n      exclusion: प्रयोगको लागी संरक्षित छ\n      greater_than: \"%{count} भन्दा बेसी हुनुपर्नेछ\"\n      greater_than_or_equal_to: \"%{count} सँग बराबर अथवा बेसी हुनुपर्नेछ\"\n      inclusion: सुचीमा सामेल गरिएको छैन\n      invalid: अमान्य छ\n      less_than: \"%{count} भन्दा कम हुनुपर्नेछ\"\n      less_than_or_equal_to: \"%{count} सँग बराबर अथवा कम हुनुपर्नेछ\"\n      not_a_number: अंक होईन\n      not_an_integer: पुर्णाकं हुनुपर्नेछ\n      odd: बिजोर संख्या हुनुपर्नेछ\n      taken: पहिल्यै प्रयोग गरीएको छ\n      too_long:\n        one: धेरै लामो छ (अधिक्तम %{count} character हो)\n        other: धेरै लामो छ (अधिक्तम %{count} characters हो)\n      too_short:\n        one: धेरै छोटो छ (न्युनत्तम %{count} character हो)\n        other: धेरै छोटो छ (न्युनत्तम %{count} characters हो)\n      wrong_length:\n        one: गलत लम्बाई हो (%{count} character हुनुपर्नेछ)\n        other: गलत लम्बाई हो (%{count} characters हुनुपर्नेछ)\n    template:\n      body: 'त्यहाँ निम्न क्षेत्रहरुमा समस्या देखियो:'\n      header:\n        one: \"%{count} गल्तीले यस %{model} लाई सुरक्षित गर्नबाट रोक्यो\"\n        other: \"%{count} गल्तीले यस %{model} लाई सुरक्षित गर्नबाट रोक्यो\"\n  helpers:\n    select:\n      prompt: कृपया छान्नुहोस्\n    submit:\n      create: नयाँ %{model} बनाउ\n      submit: \"%{model} सुरक्षित गर\"\n      update: \"%{model} सामयिक बनाउ\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: रू\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: अर्ब\n          million: Million\n          quadrillion: Quadrillion\n          thousand: हजार\n          trillion: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: जि.बी.\n          kb: के.बी.\n          mb: एम.बी\n          tb: टि.बी\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", र \"\n      two_words_connector: \" र \"\n      words_connector: \", \"\n  time:\n    am: बिहान\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: बेलुका\n"
  },
  {
    "path": "config/locales/defaults/nl.yml",
    "content": "---\nnl:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validatie mislukt: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Kan item niet verwijderen omdat afhankelijke %{record} bestaan\n          has_one: Kan item niet verwijderen omdat %{record} afhankelijk is\n  date:\n    abbr_day_names:\n    - zo\n    - ma\n    - di\n    - wo\n    - do\n    - vr\n    - za\n    abbr_month_names:\n    -\n    - jan\n    - feb\n    - mrt\n    - apr\n    - mei\n    - jun\n    - jul\n    - aug\n    - sep\n    - okt\n    - nov\n    - dec\n    day_names:\n    - zondag\n    - maandag\n    - dinsdag\n    - woensdag\n    - donderdag\n    - vrijdag\n    - zaterdag\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%e %B %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - januari\n    - februari\n    - maart\n    - april\n    - mei\n    - juni\n    - juli\n    - augustus\n    - september\n    - oktober\n    - november\n    - december\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ongeveer een uur\n        other: ongeveer %{count} uur\n      about_x_months:\n        one: ongeveer een maand\n        other: ongeveer %{count} maanden\n      about_x_years:\n        one: ongeveer een jaar\n        other: ongeveer %{count} jaar\n      almost_x_years:\n        one: bijna een jaar\n        other: bijna %{count} jaar\n      half_a_minute: een halve minuut\n      less_than_x_minutes:\n        one: minder dan een minuut\n        other: minder dan %{count} minuten\n      less_than_x_seconds:\n        one: minder dan een seconde\n        other: minder dan %{count} seconden\n      over_x_years:\n        one: meer dan een jaar\n        other: meer dan %{count} jaar\n      x_days:\n        one: \"%{count} dag\"\n        other: \"%{count} dagen\"\n      x_minutes:\n        one: \"%{count} minuut\"\n        other: \"%{count} minuten\"\n      x_months:\n        one: \"%{count} maand\"\n        other: \"%{count} maanden\"\n      x_seconds:\n        one: \"%{count} seconde\"\n        other: \"%{count} seconden\"\n      x_years:\n        one: \"%{count} jaar\"\n        other: \"%{count} jaar\"\n    prompts:\n      day: dag\n      hour: uur\n      minute: minuut\n      month: maand\n      second: seconde\n      year: jaar\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: moet worden geaccepteerd\n      blank: moet opgegeven zijn\n      confirmation: komt niet overeen met %{attribute}\n      empty: moet opgegeven zijn\n      equal_to: moet gelijk zijn aan %{count}\n      even: moet even zijn\n      exclusion: is gereserveerd\n      greater_than: moet groter zijn dan %{count}\n      greater_than_or_equal_to: moet groter dan of gelijk zijn aan %{count}\n      inclusion: is niet in de lijst opgenomen\n      invalid: is ongeldig\n      less_than: moet minder zijn dan %{count}\n      less_than_or_equal_to: moet minder dan of gelijk zijn aan %{count}\n      model_invalid: 'Validatie mislukt: %{errors}'\n      not_a_number: is geen getal\n      not_an_integer: moet een geheel getal zijn\n      odd: moet oneven zijn\n      other_than: moet anders zijn dan %{count}\n      present: moet leeg zijn\n      required: moet bestaan\n      taken: is al in gebruik\n      too_long:\n        one: is te lang (maximaal %{count} teken)\n        other: is te lang (maximaal %{count} tekens)\n      too_short:\n        one: is te kort (minimaal %{count} teken)\n        other: is te kort (minimaal %{count} tekens)\n      wrong_length:\n        one: heeft onjuiste lengte (moet %{count} teken lang zijn)\n        other: heeft onjuiste lengte (moet %{count} tekens lang zijn)\n    template:\n      body: 'Er zijn problemen met de volgende velden:'\n      header:\n        one: \"%{model} niet opgeslagen: %{count} fout gevonden\"\n        other: \"%{model} niet opgeslagen: %{count} fouten gevonden\"\n  helpers:\n    select:\n      prompt: Maak een keuze\n    submit:\n      create: \"%{model} toevoegen\"\n      submit: \"%{model} opslaan\"\n      update: \"%{model} bijwerken\"\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%u %n\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: miljard\n          million: miljoen\n          quadrillion: biljard\n          thousand: duizend\n          trillion: biljoen\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: byte\n            other: bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" en \"\n      two_words_connector: \" en \"\n      words_connector: \", \"\n  time:\n    am: \"'s ochtends\"\n    formats:\n      default: \"%a %d %b %Y %H:%M:%S %Z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: \"'s middags\"\n"
  },
  {
    "path": "config/locales/defaults/nn.yml",
    "content": "---\nnn:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Valideringa mislukka: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Kan ikkje sletta registreringa, fordi avhengige %{record} finst.\n          has_one: Kan ikkje sletta registreringa, fordi 1 avhengig %{record} finst.\n  date:\n    abbr_day_names:\n    - sun\n    - mån\n    - tys\n    - ons\n    - tor\n    - fre\n    - lau\n    abbr_month_names:\n    -\n    - jan\n    - feb\n    - mar\n    - apr\n    - mai\n    - jun\n    - jul\n    - aug\n    - sep\n    - okt\n    - nov\n    - des\n    day_names:\n    - sundag\n    - måndag\n    - tysdag\n    - onsdag\n    - torsdag\n    - fredag\n    - laurdag\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%e. %B %Y\"\n      short: \"%e. %b\"\n    month_names:\n    -\n    - januar\n    - februar\n    - mars\n    - april\n    - mai\n    - juni\n    - juli\n    - august\n    - september\n    - oktober\n    - november\n    - desember\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: rundt %{count} time\n        other: rundt %{count} timar\n      about_x_months:\n        one: rundt %{count} månad\n        other: rundt %{count} månader\n      about_x_years:\n        one: rundt %{count} år\n        other: rundt %{count} år\n      almost_x_years: nesten %{count} år\n      half_a_minute: eit halvt minutt\n      less_than_x_minutes:\n        one: mindre enn %{count} minutt\n        other: mindre enn %{count} minutt\n      less_than_x_seconds:\n        one: mindre enn %{count} sekund\n        other: mindre enn %{count} sekund\n      over_x_years:\n        one: over %{count} år\n        other: over %{count} år\n      x_days:\n        one: \"%{count} dag\"\n        other: \"%{count} dagar\"\n      x_minutes:\n        one: \"%{count} minutt\"\n        other: \"%{count} minutt\"\n      x_months:\n        one: \"%{count} månad\"\n        other: \"%{count} månader\"\n      x_seconds:\n        one: \"%{count} sekund\"\n        other: \"%{count} sekund\"\n    prompts:\n      day: Dag\n      hour: Time\n      minute: Minutt\n      month: Månad\n      second: Sekund\n      year: År\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: må vera akseptert\n      blank: kan ikkje vera blank\n      confirmation: er ikkje lik %{attribute}\n      empty: kan ikkje vera tom\n      equal_to: må vera lik %{count}\n      even: må vera partal\n      exclusion: er reservert\n      greater_than: må vera større enn %{count}\n      greater_than_or_equal_to: må vera større enn eller lik %{count}\n      inclusion: er ikkje inkludert i lista\n      invalid: er ugyldig\n      less_than: må vera mindre enn %{count}\n      less_than_or_equal_to: må vera mindre enn eller lik %{count}\n      model_invalid: 'Valideringa mislukka: %{errors}'\n      not_a_number: er ikkje eit tal\n      not_an_integer: er ikkje eit heiltal\n      odd: må vera oddetal\n      other_than: må vera noko anna enn %{count}\n      present: må vera blank\n      required: må eksistera\n      taken: er allereie i bruk\n      too_long: er for lang (maksimum %{count} teikn)\n      too_short: er for kort (minimum %{count} teikn)\n      wrong_length: har feil lengde (maksimum %{count} teikn)\n    template:\n      body: 'det oppstod problem i følgjande felt:'\n      header: kunne ikkje lagra %{model} grunna %{count} feil.\n  helpers:\n    select:\n      prompt: Gjer eit val\n    submit:\n      create: Lag %{model}\n      submit: Lagre %{model}\n      update: Oppdater %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: kr\n    format:\n      delimiter: \",\"\n      precision: 2\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: milliard\n            other: milliardar\n          million:\n            one: million\n            other: millionar\n          quadrillion:\n            one: billiard\n            other: billiardar\n          thousand: tusen\n          trillion:\n            one: billion\n            other: billionar\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: kB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" og \"\n      two_words_connector: \" og \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A, %e. %B %Y, %H:%M\"\n      long: \"%A, %e. %B %Y, %H:%M\"\n      short: \"%e. %B, %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/oc.yml",
    "content": "---\noc:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: La validacion a fracassat : %{errors}\n        restrict_dependent_destroy:\n          has_many: Podètz pas suprimir l’enregistrament perque i a %{record} dependéncias\n          has_one: Podètz pas suprimir l’enregistrament perque i a una dependéncia\n  date:\n    abbr_day_names:\n    - dg\n    - dl\n    - dm\n    - dc\n    - dj\n    - dv\n    - ds\n    abbr_month_names:\n    -\n    - gen\n    - feb\n    - març\n    - abr\n    - mai\n    - junh\n    - jul\n    - ago\n    - set\n    - oct\n    - nov\n    - dec\n    day_names:\n    - dimenge\n    - diluns\n    - dimars\n    - dimècres\n    - dijòus\n    - divendres\n    - dissabte\n    formats:\n      default: \"%e/%m/%Y\"\n      long: Lo %e %B de %Y\n      short: \"%e %b\"\n    month_names:\n    -\n    - de genièr\n    - de febrièr\n    - de març\n    - d’abrial\n    - de mai\n    - de junh\n    - de julhet\n    - d’agost\n    - de setembre\n    - d’octòbre\n    - de novembre\n    - de decembre\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: Fa una ora\n        other: Fa %{count} oras\n      about_x_months:\n        one: Fa un mes\n        other: Fa %{count} meses\n      about_x_years:\n        one: Fa un an\n        other: Fa %{count} ans\n      almost_x_years:\n        one: quasi un an\n        other: quasi %{count} ans\n      half_a_minute: mièja minuta\n      less_than_x_minutes:\n        one: mens d’una minuta\n        other: mens de %{count} minutas\n      less_than_x_seconds:\n        one: mens d’una segonda\n        other: mens de %{count} segondas\n      over_x_years:\n        one: mai d’un an\n        other: mai de %{count} ans\n      x_days:\n        one: un jorn\n        other: \"%{count} jorns\"\n      x_minutes:\n        one: una minuta\n        other: \"%{count} minutas\"\n      x_months:\n        one: un mes\n        other: \"%{count} meses\"\n      x_seconds:\n        one: una segonda\n        other: \"%{count} segondas\"\n      x_years:\n        one: un an\n        other: \"%{count} ans\"\n    prompts:\n      day: jorn\n      hour: ora\n      minute: minuta\n      month: mes\n      second: segonda\n      year: an\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: deu èsser acceptat\n      blank: deu èsser garnit\n      confirmation: correspond pas a %{attribute}\n      empty: pòt pas èsser void\n      equal_to: deu èsser egal a %{count}\n      even: deu èsser parelh\n      exclusion: es reservat\n      greater_than: deu èsser superior a %{count}\n      greater_than_or_equal_to: deu èsser mai grand o egal a %{count}\n      inclusion: es pas inclús dins la lista\n      invalid: es pas valid\n      less_than: deu èsser inferior a %{count}\n      less_than_or_equal_to: deu èsser inferior o egal a %{count}\n      model_invalid: Validacion fracassada : %{errors}\n      not_a_number: es pas un nombre\n      not_an_integer: deu èsser un nombre entièr\n      odd: deu èsser un nombre impar\n      other_than: deu èsser diferent de %{count}\n      present: deu èsser void\n      required: deu existir\n      taken: es pas disponible\n      too_long:\n        one: es tròp long (pas mai d’un caractèr)\n        other: es tròp long (%{count} caractèrs al maximum)\n      too_short:\n        one: es tròp cort (almens un caractèr)\n        other: es tròp cort (almens %{count} caractèrs)\n      wrong_length:\n        one: a pas la bona longor (un caractèr solament)\n        other: a pas la bona longor (%{count} caractèrs exactament)\n    template:\n      body: I a agut de problèmas amb los camps seguents : \n      header:\n        one: Impossible d’enregistrar aqueste/a %{model} perque i a %{count} error\n        other: Impossible d’enregistrar aqueste/a %{model} perque i a %{count} errors\n  helpers:\n    select:\n      prompt: Mercés de seleccionar\n    submit:\n      create: Crear %{model}\n      submit: Salvar %{model}\n      update: Actualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: miliard\n          million: milion\n          quadrillion: milion de miliards\n          thousand: mila\n          trillion: bilion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: octet\n            other: octets\n          gb: Go\n          kb: ko\n          mb: Mo\n          tb: To\n    percentage:\n      format:\n        delimiter: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", e \"\n      two_words_connector: \" e \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: Lo %e %b de %Y a %Ho%M %Ss\n      long: Lo %a %e %b de %Y a %Ho%M\n      short: \"%e %b %Ho%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/or.yml",
    "content": "---\nor:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'ସଠିକ୍ ନୁହ: %{errors}'\n  date:\n    abbr_day_names:\n    - ରବି\n    - ସୋମ\n    - ମଂଗଳ\n    - ବୁଧ\n    - ଗୁରୁ\n    - ଶୁକ୍ର\n    - ଶନି\n    abbr_month_names:\n    -\n    - ଜାନୁ\n    - ଫେବରୁ\n    - ମାର\n    - ଏପ୍ର\n    - ମାଈ\n    - ଜୁନ୍\n    - ଜୁଲ୍\n    - ଅଗସ୍ଟ\n    - ସେପ୍ଟ\n    - ଅକ୍ଟ\n    - ନୋଭ\n    - ଡିସ୍\n    day_names:\n    - ରବିବାର\n    - ସୋମବାର\n    - ମଗଂଳବାର\n    - ବୁଧବାର\n    - ଗୁରୁବାର\n    - ଶୁକ୍ରବାର\n    - ଶନିବାର\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%B %d, %Y\"\n      short: \"%b %d\"\n    month_names:\n    -\n    - ଜାନୁୟାରୀ\n    - ଫେବୃୟାରୀ\n    - ମାର୍ଚ଼\n    - ଏପ୍ରଲ\n    - ମାଈ\n    - ଜୁନ୍\n    - ଜୁଲାୟ\n    - ଅଗଷ୍ତ\n    - ସେପ୍ଟମ୍ବର୍\n    - ଅକ୍ଟୋବର୍\n    - ନୋଭେମ୍ବର\n    - ଡିସମ୍ବର\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ପାଖାପାଖି %{count} ଘଣ୍ତ\n        other: ପାଖାପାଖି %{count} ଘଣ୍ତ\n      about_x_months:\n        one: ପାଖାପାଖି %{count} ମାସ\n        other: ପାଖାପାଖି %{count} ମାସ\n      about_x_years:\n        one: ପାଖାପାଖି %{count} year\n        other: ପାଖାପାଖି %{count} years\n      almost_x_years:\n        one: ଅଳ୍ପ ଉଣ %{count} ବର୍ଷ\n        other: ଅଳ୍ପ ଉଣ %{count} ବର୍ଷ\n      half_a_minute: ଦେଢ ମିନଟ୍\n      less_than_x_minutes:\n        one: \"%{count} ମିନଟ ବାକ\"\n        other: \"%{count} ମିନଟ ବାକ\"\n      less_than_x_seconds:\n        one: \"%{count} ସେକଣ୍ଢ ବାକ\"\n        other: \"%{count} ସେକଣ୍ଢ ବାକ\"\n      over_x_years:\n        one: \"%{count} ବର୍ଷରୁ ଅଧିକ\"\n        other: \"%{count} ବର୍ଷରୁ ଅଧିକ\"\n      x_days:\n        one: \"%{count}  ଦିନ\"\n        other: \"%{count} ଦିନ\"\n      x_minutes:\n        one: \"%{count} ମିନଟ\"\n        other: \"%{count} ମିନଟ\"\n      x_months:\n        one: \"%{count} ମାସ\"\n        other: \"%{count} ମାସ\"\n      x_seconds:\n        one: \"%{count} ସେକଣ୍ଢ\"\n        other: \"%{count} ସେକଣ୍ଢ\"\n    prompts:\n      day: ଦିନ\n      hour: ଘଣ୍ତ\n      minute: ମିନଟ\n      month: ମାସ\n      second: ସେକଣ୍ଢ\n      year: ବର୍ଷ\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: ଗ୍ରହଣ କରିବାର ଅଛି\n      blank: ଖାଲି ହେଇ ପାରୀବନ\n      confirmation: ପ୍ରମାଣ ହେଇନି\n      empty: ଖାଲି ହେଇପାରିବନି\n      equal_to: \"%{count} କୁ ସମାନ\"\n      even: ଯୁଗ୍ମ ହେବାର ଅଛି\n      exclusion: ସୁରଖିତ ଅଟେ\n      greater_than: \"%{count} ରୁ ବଡ ହେବାର ଅଛି\"\n      greater_than_or_equal_to: \"%{count} ରୁ ବଡ କିମ୍ବା ସମାନ ହେବାର ଅଛି\"\n      inclusion: ସୁଚୀ ରେ ଅନ୍ତର୍ଭୁକ୍ତ ନୁହେଁ\n      invalid: ଠିକ୍ ନୁହେଁ\n      less_than: \"%{count} ରୁ ଛୋଟ\"\n      less_than_or_equal_to: \"%{count} ରୁ ଛୋଟ କିମ୍ବା ସମାନ ହେବାର ଅଛି\"\n      not_a_number: ସଂଖ୍ଯ ନୁହେଁ\n      not_an_integer: ଗଣନ ସଂଖ୍ଯା ହେବାର ଅଛି\n      odd: ଅଯୁଗ୍ମ ହେବାର ଅଛି\n      taken: ଗ୍ରହଣ  କରା ଯାଇଛି\n      too_long: ଦିର୍ଘତମ ଅଟେ(ଅତ୍ୟଧୀକ %{count} ଅଖ୍ଯର)\n      too_short: ଅତି ସଂଖିପ୍ତ ଅଟେ (ଅତ୍ଯଳ୍ପ %{count} ଅଖ୍ଯର ଅଟେ)\n      wrong_length: ଲମ୍ବା ଭୁଲ ଅଟେ (%{count} ଅଖ୍ଯର ହେବା ଉଚିତ୍)\n    template:\n      body: 'ନିମ୍ନ ଜାଗା ରେ ଅସୁବିଧା ହେଇଛି:'\n      header:\n        one: \"%{count} ଭୁଲ ଯଗୁଁ ନିମ୍ନ %{model} ସୁରଖିତ ହେଇପାରି ନଥିଲା\"\n        other: \"%{count} ଭୁଲ ଯଗୁଁ ଏହି %{model} ସୁରଖିତ ହେଇପାରି ନଥିଲା\"\n  helpers:\n    select:\n      prompt: ପସନ୍ଦ କର\n    submit:\n      create: \"%{model} ବନାଅ\"\n      submit: \"%{model} ସୁରଖିତ କର\"\n      update: \"%{model} ଅାଧୂନିକରଣ କର\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"₹\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: ମିଥ୍ଯା\n      strip_insignificant_zeros: ମିଥ୍ଯା\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: ବିଲିୟନ୍\n          million: ମିଲିୟନ୍\n          quadrillion: ହଜାର ବିଲିୟନ୍\n          thousand: ହଜାର\n          trillion: ଟ୍ରିଲିୟନ୍\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", ଏବଂ \"\n      two_words_connector: \" ଏବଂ \"\n      words_connector: \", \"\n  time:\n    am: ପୁର୍ଵାହ୍ନ\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%B %d, %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: ଅପରାହ୍ନ\n"
  },
  {
    "path": "config/locales/defaults/pa.yml",
    "content": "---\npa:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'ਪਰਮਾਣ ਫ਼ੇਲ ਹੋਇਆ: %{errors}'\n        restrict_dependent_destroy:\n          has_many: ਮਿਟਾ ਨਹੀਂ ਸਕਦੇ ਕਿਉਂਕਿ ਨਿਰਭਰ %{record} ਮੌਜੂਦ ਹਨ\n          has_one: ਮਿਟਾ ਨਹੀਂ ਸਕਦੇ ਕਿਉਂਕਿ ਇੱਕ ਨਿਰਭਰ %{record} ਮੌਜੂਦ ਹੈ\n  date:\n    abbr_day_names:\n    - ਅੈਤ\n    - ਸੋਮ\n    - ਮੰਗਲ\n    - ਬੱੁਧ\n    - ਵੀਰ\n    - ਸ਼ੁੱਕਰ\n    - ਸ਼ਨਿੱਚਰ\n    abbr_month_names:\n    -\n    - ਜਨ\n    - ਫ਼ਰ\n    - ਮਾਰਚ\n    - ਅਪ੍ਰੈ\n    - ਮਈ\n    - ਜੂਨ\n    - ਜੁਲਾ\n    - ਅਗ\n    - ਸਤੰ\n    - ਅਕਤੂ\n    - ਨਵੰ\n    - ਦਸੰ\n    day_names:\n    - ਐਤਵਾਰ\n    - ਸੋਮਵਾਰ\n    - ਮੰਗਲਵਾਰ\n    - ਬੁੱਧਵਾਰ\n    - ਵੀਰਵਾਰ\n    - ਸ਼ੁੱਕਰਵਾਰ\n    - ਸ਼ਨਿੱਚਰਵਾਰ\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - ਜਨਵਰੀ\n    - ਫ਼ਰਵਰੀ\n    - ਮਾਰਚ\n    - ਅਪ੍ਰੈਲ\n    - ਮਈ\n    - ਜੂਨ\n    - ਜੁਲਾਈ\n    - ਅਗਸਤ\n    - ਸਤੰਬਰ\n    - ਅਕਤੂਬਰ\n    - ਨਵੰਬਰ\n    - ਦਸੰਬਰ\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ਲਗਭਗ %{count} ਘੰਟਾ\n        other: ਲਗਭਗ %{count} ਘੰਟੇ\n      about_x_months:\n        one: ਲਗਭਗ %{count} ਮਹੀਨਾ\n        other: ਲਗਭਗ %{count} ਮਹੀਨੇ\n      about_x_years:\n        one: ਲਗਭਗ %{count} ਸਾਲ\n        other: ਲਗਭਗ %{count} ਸਾਲ\n      almost_x_years:\n        one: ਤਕਰੀਬਨ %{count} ਸਾਲ\n        other: ਤਕਰੀਬਨ %{count} ਸਾਲ\n      half_a_minute: ਅੱਧਾ ਮਿੰਟ\n      less_than_x_minutes:\n        one: \"%{count} ਮਿੰਟ ਤੋਂ ਘੱਟ\"\n        other: \"%{count} ਮਿੰਟਾਂ ਤੋਂ ਘੱਟ\"\n      less_than_x_seconds:\n        one: \"%{count} ਸਕਿੰਟ ਤੋਂ ਘੱਟ\"\n        other: \"%{count} ਸਕਿੰਟਾਂ ਤੋਂ ਘੱਟ\"\n      over_x_years:\n        one: \"%{count} ਸਾਲ ਤੋਂ ਵੱਧ\"\n        other: \"%{count} ਸਾਲਾਂ ਤੋਂ ਵੱਧ\"\n      x_days:\n        one: \"%{count} ਦਿਨ\"\n        other: \"%{count} ਦਿਨ\"\n      x_minutes:\n        one: \"%{count} ਮਿੰਟ\"\n        other: \"%{count} ਮਿੰਟ\"\n      x_months:\n        one: \"%{count} ਮਹੀਨਾ\"\n        other: \"%{count} ਮਹੀਨੇ\"\n      x_seconds:\n        one: \"%{count} ਸਕਿੰਟ\"\n        other: \"%{count} ਸਕਿੰਟ\"\n    prompts:\n      day: ਦਿਨ\n      hour: ਘੰਟਾ\n      minute: ਮਿੰਟ\n      month: ਮਹੀਨਾ\n      second: ਸਕਿੰਟ\n      year: ਸਾਲ\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: ਜਰੂਰ ਮੰਜੂਰ ਹੋਵੇ\n      blank: ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ\n      confirmation: \"%{attribute} ਨਹੀਂ ਰਲਦੇ\"\n      empty: ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ\n      equal_to: \"%{count} ਦੇ ਬਰਾਬਰ ਹੋਣਾ ਚਾਹੀਦਾ ਹੈ\"\n      even: ਜਰੂਰੀ ਹੈ ਕੇ ਜੋਟਾ ਹੋਵੇ\n      exclusion: ਮੱਲਿਆ ਹੋਇਆ ਹੈ\n      greater_than: \"%{count} ਤੋਂ ਵਧੇਰੇ ਹੋਣਾ ਜਰੂਰੀ ਹੈ\"\n      greater_than_or_equal_to: \"%{count} ਤੋਂ ਵਧੇਰੇ ਜਾਂ ਬਰਾਬਰ ਹੋਣਾ ਜਰੂਰੀ ਹੈ\"\n      inclusion: ਇਸ ਲਿਸਟ ਵਿੱਚ ਸ਼ਾਮਿਲ ਨਹੀਂ ਹੈ\n      invalid: ਨਾ-ਮੰਜੂਰਸ਼ੁਦਾ ਹੈ\n      less_than: \"%{count} ਤੋਂ ਘੱਟ ਹੋਣਾ ਜਰੂਰੀ ਹੈ\"\n      less_than_or_equal_to: \"%{count} ਤੋਂ ਘੱਟ ਜਾਂ ਬਰਾਬਰ ਹੋਣਾ ਜਰੂਰੀ ਹੈ\"\n      not_a_number: ਸੰਖਿਆ ਨਹੀਂ ਹੈ\n      not_an_integer: ਜਰੂਰੀ ਹੈ ਕੇ ਪੂਰਨ ਅੰਕ ਹੋਵੇ\n      odd: ਜਰੂਰੀ ਹੈ ਕੇ ਕਲ਼ੀ ਹੋਵੇ\n      other_than: \"%{count} ਦੀ ਜਗ੍ਹਾ ਹੋਰ ਹੋਣੀ ਚਾਹੀਦੀ ਹੈ\"\n      present: ਜਰੂਰ ਖਾਲੀ ਹੋਵੇ\n      taken: ਪਹਿਲਾਂ ਹੀ ਮੱਲਿਆ ਹੋਇਆ ਹੈ\n      too_long:\n        one: ਲੰਬਾਈ ਜ਼ਿਆਦਾ ਹੈ (ਵੱਧ ਤੋਂ ਵੱਧ %{count} ਅੱਖਰ)\n        other: ਲੰਬਾਈ ਜ਼ਿਆਦਾ ਹੈ (ਵੱਧ ਤੋਂ ਵੱਧ %{count} ਅੱਖਰ)\n      too_short:\n        one: ਲੰਬਾਈ ਘੱਟ ਹੈ (ਘੱਟ ਤੋਂ ਘੱਟ %{count} ਅੱਖਰ)\n        other: ਲੰਬਾਈ ਘੱਟ ਹੈ (ਘੱਟ ਤੋਂ ਘੱਟ %{count} ਅੱਖਰ)\n      wrong_length:\n        one: ਲੰਬਾਈ ਗਲਤ ਹੈ (%{count} ਅੱਖਰ ਹੋਣੀ ਚਾਹੀਦੀ ਹੈ)\n        other: ਲੰਬਾਈ ਗਲਤ ਹੈ (%{count} ਅੱਖਰ ਹੋਣੀ ਚਾਹੀਦੀ ਹੈ)\n    template:\n      body: 'ਹੇਠਲੇ ਖੇਤਰਾਂ ਵਿੱਚ ਗਲਤੀਆਂ ਹਨ:'\n      header:\n        one: \"%{count} ਗਲਤੀ ਕਰਕੇ ਇਹ %{model} ਸੰਭਾਲਿ਼ਆ ਨਹੀਂ ਗਿਆ\"\n        other: \"%{count} ਗਲਤੀਆਂ ਕਰਕੇ ਇਹ %{model} ਸੰਭਾਲਿ਼ਆ ਨਹੀਂ ਗਿਆ\"\n  helpers:\n    select:\n      prompt: ਕਿਰਪਾ ਕਰਕੇ ਚੁਣੋ\n    submit:\n      create: \"%{model} ਬਣਾਓ\"\n      submit: \"%{model} ਬਚਾਓ\"\n      update: \"%{model} ਬਦਲੋ\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: ਬਿਲੀਅਨ\n          million: ਮਿਲੀਅਨ\n          quadrillion: ਕ੍ਵਾਡਰਿਲੀਅਨ\n          thousand: ਹਜ਼ਾਰ\n          trillion: ਟ੍ਰਿਲੀਅਨ\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: ਬਾਈਟ\n            other: ਬਾਈਟ\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", ਅਤੇ \"\n      two_words_connector: \" ਅਤੇ \"\n      words_connector: \", \"\n  time:\n    am: ਸਵੇਰ\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: ਸ਼ਾਮ\n"
  },
  {
    "path": "config/locales/defaults/pl.yml",
    "content": "---\npl:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Znaleziono błędy: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Nie można usunąć, gdyż istnieją zależne od niego %{record}\n          has_one: Nie można usunąć, gdyż istnieje zależny od niego %{record}\n  date:\n    abbr_day_names:\n    - nie\n    - pon\n    - wto\n    - śro\n    - czw\n    - pią\n    - sob\n    abbr_month_names:\n    -\n    - sty\n    - lut\n    - mar\n    - kwi\n    - maj\n    - cze\n    - lip\n    - sie\n    - wrz\n    - paź\n    - lis\n    - gru\n    day_names:\n    - niedziela\n    - poniedziałek\n    - wtorek\n    - środa\n    - czwartek\n    - piątek\n    - sobota\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%-d %B %Y\"\n      short: \"%-d %b\"\n    month_names:\n    -\n    - stycznia\n    - lutego\n    - marca\n    - kwietnia\n    - maja\n    - czerwca\n    - lipca\n    - sierpnia\n    - września\n    - października\n    - listopada\n    - grudnia\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: około %{count} godziny\n        many: około %{count} godzin\n        one: około godziny\n        other: około %{count} godzin\n      about_x_months:\n        few: około %{count} miesiące\n        many: około %{count} miesięcy\n        one: około miesiąca\n        other: około %{count} miesięcy\n      about_x_years:\n        few: około %{count} lata\n        many: około %{count} lat\n        one: około rok\n        other: około %{count} lat\n      almost_x_years:\n        few: prawie %{count} lata\n        many: prawie %{count} lat\n        one: prawie rok\n        other: prawie %{count} lat\n      half_a_minute: pół minuty\n      less_than_x_minutes:\n        few: mniej niż %{count} minuty\n        many: mniej niż %{count} minut\n        one: mniej niż minutę\n        other: mniej niż %{count} minut\n      less_than_x_seconds:\n        few: mniej niż %{count} sekundy\n        many: mniej niż %{count} sekund\n        one: mniej niż sekundę\n        other: mniej niż %{count} sekund\n      over_x_years:\n        few: ponad %{count} lata\n        many: ponad %{count} lat\n        one: ponad rok\n        other: ponad %{count} lat\n      x_days:\n        few: \"%{count} dni\"\n        many: \"%{count} dni\"\n        one: \"%{count} dzień\"\n        other: \"%{count} dni\"\n      x_minutes:\n        few: \"%{count} minuty\"\n        many: \"%{count} minut\"\n        one: \"%{count} minuta\"\n        other: \"%{count} minut\"\n      x_months:\n        few: \"%{count} miesiące\"\n        many: \"%{count} miesięcy\"\n        one: \"%{count} miesiąc\"\n        other: \"%{count} miesięcy\"\n      x_seconds:\n        few: \"%{count} sekundy\"\n        many: \"%{count} sekund\"\n        one: \"%{count} sekunda\"\n        other: \"%{count} sekund\"\n    prompts:\n      day: Dzień\n      hour: Godzina\n      minute: Minuta\n      month: Miesiąc\n      second: Sekundy\n      year: Rok\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: musi zostać zaakceptowane\n      blank: nie może być puste\n      confirmation: musi być taki sam jak w %{attribute}\n      empty: nie może być puste\n      equal_to: musi być równe %{count}\n      even: musi być parzyste\n      exclusion: jest zarezerwowane\n      greater_than: musi być większe od %{count}\n      greater_than_or_equal_to: musi być większe lub równe %{count}\n      inclusion: nie znajduje się na liście dopuszczalnych wartości\n      invalid: jest nieprawidłowe\n      less_than: musi być mniejsze od %{count}\n      less_than_or_equal_to: musi być mniejsze lub równe %{count}\n      not_a_number: nie jest liczbą\n      not_an_integer: musi być liczbą całkowitą\n      odd: musi być nieparzyste\n      other_than: musi być inne niż %{count}\n      present: musi być puste\n      required: musi istnieć\n      taken: zostało już zajęte\n      too_long:\n        few: jest za długie (maksymalnie %{count} znaki)\n        many: jest za długie (maksymalnie %{count} znaków)\n        one: jest za długie (maksymalnie jeden znak)\n        other: jest za długie (maksymalnie %{count} znaków)\n      too_short:\n        few: jest za krótkie (przynajmniej %{count} znaki)\n        many: jest za krótkie (przynajmniej %{count} znaków)\n        one: jest za krótkie (przynajmniej jeden znak)\n        other: jest za krótkie (przynajmniej %{count} znaków)\n      wrong_length:\n        few: ma nieprawidłową długość (powinna wynosić %{count} znaki)\n        many: ma nieprawidłową długość (powinna wynosić %{count} znaków)\n        one: ma nieprawidłową długość (powinna wynosić jeden znak)\n        other: ma nieprawidłową długość (powinna wynosić %{count} znaków)\n    template:\n      body: 'Błędy dotyczą następujących pól:'\n      header:\n        few: \"%{model} nie został zachowany z powodu %{count} błędów\"\n        many: \"%{model} nie został zachowany z powodu %{count} błędów\"\n        one: \"%{model} nie został zachowany z powodu jednego błędu\"\n        other: \"%{model} nie został zachowany z powodu %{count} błędów\"\n  helpers:\n    select:\n      prompt: Proszę wybrać\n    submit:\n      create: Utwórz %{model}\n      submit: Wyślij %{model}\n      update: Aktualizuj %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: true\n        unit: zł\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Miliard\n          million: Milion\n          quadrillion: Biliard\n          thousand: Tysiąc\n          trillion: Bilion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: bajty\n            many: bajtów\n            one: bajt\n            other: bajty\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" oraz \"\n      two_words_connector: \" i \"\n      words_connector: \", \"\n  time:\n    am: przed południem\n    formats:\n      default: \"%a, %-d %b %Y %H:%M:%S %z\"\n      long: \"%-d %B %Y %H:%M\"\n      short: \"%-d %b %H:%M\"\n    pm: po południu\n"
  },
  {
    "path": "config/locales/defaults/pt-BR.yml",
    "content": "---\npt-BR:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'A validação falhou: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Não é possível excluir o registro pois existem %{record} dependentes\n          has_one: Não é possível excluir o registro pois existe um %{record} dependente\n  date:\n    abbr_day_names:\n    - dom\n    - seg\n    - ter\n    - qua\n    - qui\n    - sex\n    - sáb\n    abbr_month_names:\n    -\n    - jan\n    - fev\n    - mar\n    - abr\n    - mai\n    - jun\n    - jul\n    - ago\n    - set\n    - out\n    - nov\n    - dez\n    day_names:\n    - domingo\n    - segunda-feira\n    - terça-feira\n    - quarta-feira\n    - quinta-feira\n    - sexta-feira\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%d de %B de %Y\"\n      short: \"%d de %B\"\n    month_names:\n    -\n    - janeiro\n    - fevereiro\n    - março\n    - abril\n    - maio\n    - junho\n    - julho\n    - agosto\n    - setembro\n    - outubro\n    - novembro\n    - dezembro\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: aproximadamente %{count} hora\n        other: aproximadamente %{count} horas\n      about_x_months:\n        one: aproximadamente %{count} mês\n        other: aproximadamente %{count} meses\n      about_x_years:\n        one: aproximadamente %{count} ano\n        other: aproximadamente %{count} anos\n      almost_x_years:\n        one: quase %{count} ano\n        other: quase %{count} anos\n      half_a_minute: meio minuto\n      less_than_x_minutes:\n        one: menos de um minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: mais de %{count} ano\n        other: mais de %{count} anos\n      x_days:\n        one: \"%{count} dia\"\n        other: \"%{count} dias\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mês\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} ano\"\n        other: \"%{count} anos\"\n    prompts:\n      day: Dia\n      hour: Hora\n      minute: Minuto\n      month: Mês\n      second: Segundo\n      year: Ano\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: deve ser aceito\n      blank: não pode ficar em branco\n      confirmation: não é igual a %{attribute}\n      empty: não pode ficar vazio\n      equal_to: deve ser igual a %{count}\n      even: deve ser par\n      exclusion: não está disponível\n      greater_than: deve ser maior que %{count}\n      greater_than_or_equal_to: deve ser maior ou igual a %{count}\n      in: deve estar em %{count}\n      inclusion: não está incluído na lista\n      invalid: não é válido\n      less_than: deve ser menor que %{count}\n      less_than_or_equal_to: deve ser menor ou igual a %{count}\n      model_invalid: 'A validação falhou: %{errors}'\n      not_a_number: não é um número\n      not_an_integer: não é um número inteiro\n      odd: deve ser ímpar\n      other_than: deve ser diferente de %{count}\n      present: deve ficar em branco\n      required: é obrigatório(a)\n      taken: já está em uso\n      too_long:\n        one: 'é muito longo (máximo: %{count} caracter)'\n        other: 'é muito longo (máximo: %{count} caracteres)'\n      too_short:\n        one: 'é muito curto (mínimo: %{count} caracter)'\n        other: 'é muito curto (mínimo: %{count} caracteres)'\n      wrong_length:\n        one: não possui o tamanho esperado (%{count} caracter)\n        other: não possui o tamanho esperado (%{count} caracteres)\n    template:\n      body: 'Por favor, verifique o(s) seguinte(s) campo(s):'\n      header:\n        one: 'Não foi possível gravar %{model}: %{count} erro'\n        other: 'Não foi possível gravar %{model}: %{count} erros'\n  helpers:\n    select:\n      prompt: Por favor selecione\n    submit:\n      create: Criar %{model}\n      submit: Salvar %{model}\n      update: Atualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%u %n\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: R$\n    format:\n      delimiter: \".\"\n      precision: 3\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: bilhão\n            other: bilhões\n          million:\n            one: milhão\n            other: milhões\n          quadrillion:\n            one: quatrilhão\n            other: quatrilhões\n          thousand: mil\n          trillion:\n            one: trilhão\n            other: trilhões\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \".\"\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: \".\"\n  support:\n    array:\n      last_word_connector: \" e \"\n      two_words_connector: \" e \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d de %B de %Y, %H:%M:%S %z\"\n      long: \"%d de %B de %Y, %H:%M\"\n      short: \"%d de %B, %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/pt.yml",
    "content": "---\npt:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'A validação falhou: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Não pode ser eliminado por existirem dependências de %{record}\n          has_one: Não pode ser eliminado por existir uma dependência de %{record}\n  date:\n    abbr_day_names:\n    - dom\n    - seg\n    - ter\n    - qua\n    - qui\n    - sex\n    - sáb\n    abbr_month_names:\n    -\n    - jan\n    - fev\n    - mar\n    - abr\n    - mai\n    - jun\n    - jul\n    - ago\n    - set\n    - out\n    - nov\n    - dez\n    day_names:\n    - domingo\n    - segunda-feira\n    - terça-feira\n    - quarta-feira\n    - quinta-feira\n    - sexta-feira\n    - sábado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%d de %B de %Y\"\n      short: \"%d de %B\"\n    month_names:\n    -\n    - janeiro\n    - fevereiro\n    - março\n    - abril\n    - maio\n    - junho\n    - julho\n    - agosto\n    - setembro\n    - outubro\n    - novembro\n    - dezembro\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: aproximadamente %{count} hora\n        other: aproximadamente %{count} horas\n      about_x_months:\n        one: aproximadamente %{count} mês\n        other: aproximadamente %{count} meses\n      about_x_years:\n        one: aproximadamente %{count} ano\n        other: aproximadamente %{count} anos\n      almost_x_years:\n        one: quase %{count} ano\n        other: quase %{count} anos\n      half_a_minute: meio minuto\n      less_than_x_minutes:\n        one: menos de um minuto\n        other: menos de %{count} minutos\n      less_than_x_seconds:\n        one: menos de %{count} segundo\n        other: menos de %{count} segundos\n      over_x_years:\n        one: mais de %{count} ano\n        other: mais de %{count} anos\n      x_days:\n        one: \"%{count} dia\"\n        other: \"%{count} dias\"\n      x_minutes:\n        one: \"%{count} minuto\"\n        other: \"%{count} minutos\"\n      x_months:\n        one: \"%{count} mês\"\n        other: \"%{count} meses\"\n      x_seconds:\n        one: \"%{count} segundo\"\n        other: \"%{count} segundos\"\n      x_years:\n        one: \"%{count} ano\"\n        other: \"%{count} anos\"\n    prompts:\n      day: Dia\n      hour: Hora\n      minute: Minuto\n      month: Mês\n      second: Segundo\n      year: Ano\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: tem de ser aceite\n      blank: não pode estar em branco\n      confirmation: não coincide com a confirmação\n      empty: não pode estar vazio\n      equal_to: tem de ser igual a %{count}\n      even: tem de ser par\n      exclusion: é reservado\n      greater_than: tem de ser maior que %{count}\n      greater_than_or_equal_to: tem de ser maior ou igual a %{count}\n      in: deve estar em %{count}\n      inclusion: não está incluído na lista\n      invalid: é inválido\n      less_than: tem de ser menor que %{count}\n      less_than_or_equal_to: tem de ser menor ou igual a %{count}\n      model_invalid: 'A validação falhou: %{errors}'\n      not_a_number: não é um número\n      not_an_integer: tem de ser um inteiro\n      odd: tem de ser ímpar\n      other_than: tem de ser diferente de %{count}\n      present: não pode estar em branco\n      required: é obrigatório\n      taken: não está disponível\n      too_long: é demasiado grande (o máximo é de %{count} caracteres)\n      too_short: é demasiado pequeno (o mínimo é de %{count} caracteres)\n      wrong_length: comprimento errado (deve ter %{count} caracteres)\n    template:\n      body: 'Por favor, verifique os seguintes campos:'\n      header:\n        one: \"%{count} erro impediu guardar este %{model}\"\n        other: \"%{count} erros impediram guardar este %{model}\"\n  helpers:\n    select:\n      prompt: Por favor seleccione\n    submit:\n      create: Criar %{model}\n      submit: Gravar %{model}\n      update: Atualizar %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 3\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: mil milhões\n            other: mil milhões\n          million:\n            one: milhão\n            other: milhões\n          quadrillion:\n            one: mil biliões\n            other: mil biliões\n          thousand: mil\n          trillion:\n            one: bilião\n            other: biliões\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n %\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" e \"\n      two_words_connector: \" e \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%A, %d de %B de %Y, %H:%Mh\"\n      long: \"%A, %d de %B de %Y, %H:%Mh\"\n      short: \"%d/%m, %H:%M hs\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/rm.yml",
    "content": "---\nrm:\n  date:\n    abbr_day_names:\n    - du\n    - gli\n    - ma\n    - me\n    - gie\n    - ve\n    - so\n    abbr_month_names:\n    -\n    - schan\n    - favr\n    - mars\n    - avr\n    - matg\n    - zercl\n    - fan\n    - avust\n    - sett\n    - oct\n    - nov\n    - dec\n    day_names:\n    - dumengia\n    - glindesdi\n    - mardi\n    - mesemna\n    - gievgia\n    - venderdi\n    - sonda\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%e. %B %Y\"\n      short: \"%e. %b\"\n    month_names:\n    -\n    - schaner\n    - favrer\n    - mars\n    - avrigl\n    - matg\n    - zercladur\n    - fanadur\n    - avust\n    - settember\n    - october\n    - november\n    - december\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: circa %{count} uras\n        many: circa %{count} uras\n        one: circa in'ura\n        other: circa %{count} uras\n        two: circa %{count} uras\n        zero: circa %{count} uras\n      about_x_months:\n        few: circa %{count} mais\n        many: circa %{count} mais\n        one: circa in mais\n        other: circa %{count} mais\n        two: circa %{count} mais\n        zero: circa %{count} mais\n      about_x_years:\n        few: circa %{count} onns\n        many: circa %{count} onns\n        one: circa in onn\n        other: circa %{count} onns\n        two: circa %{count} onns\n        zero: circa %{count} onns\n      half_a_minute: ina mesa minuta\n      less_than_x_minutes:\n        few: main che %{count} minutas\n        many: main che %{count} minutas\n        one: main ch’ina minuta\n        other: main che %{count} minutas\n        two: main che %{count} minutas\n        zero: main che %{count} minutas\n      less_than_x_seconds:\n        few: main che %{count} secundas\n        many: main che %{count} secundas\n        one: main ch’ina secunda\n        other: main che %{count} secundas\n        two: main che %{count} secundas\n        zero: main che %{count} secundas\n      over_x_years:\n        few: dapli che %{count} onns\n        many: dapli che %{count} onns\n        one: dapli ch'in onn\n        other: dapli che %{count} onns\n        two: dapli che %{count} onns\n        zero: dapli che %{count} onns\n      x_days:\n        few: \"%{count} dis\"\n        many: \"%{count} dis\"\n        one: in di\n        other: \"%{count} dis\"\n        two: \"%{count} dis\"\n        zero: \"%{count} dis\"\n      x_minutes:\n        few: \"%{count} minutas\"\n        many: \"%{count} minutas\"\n        one: \"%{count} minuta\"\n        other: \"%{count} minutas\"\n        two: \"%{count} minutas\"\n        zero: \"%{count} minutas\"\n      x_months:\n        few: \"%{count} mais\"\n        many: \"%{count} mais\"\n        one: in mais\n        other: \"%{count} mais\"\n        two: \"%{count} mais\"\n        zero: \"%{count} mais\"\n      x_seconds:\n        few: \"%{count} secundas\"\n        many: \"%{count} secundas\"\n        one: ina secunda\n        other: \"%{count} secundas\"\n        two: \"%{count} secundas\"\n        zero: \"%{count} secundas\"\n    prompts:\n      day: dis\n      hour: uras\n      minute: minutas\n      month: mais\n      second: secundas\n      year: onns\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: sto vegnir acceptà\n      blank: sto vegnir emplenì ora\n      confirmation: na correspunda betg al champ da conferma\n      empty: sto vegnir emplenì ora\n      equal_to: sto esser exact %{count}\n      even: sto esser pèr\n      exclusion: na stat betg a disposiziun\n      greater_than: sto esser pli grond che %{count}\n      greater_than_or_equal_to: sto esser pli grond u medem sco %{count}\n      inclusion: n'è betg sin la glista\n      invalid: n'è betg valid\n      less_than: sto esser pli pitschen che %{count}\n      less_than_or_equal_to: sto esser pli pitschen u medem sco %{count}\n      not_a_number: è betg in dumber\n      odd: sto esser spèr\n      taken: è gia occupà\n      too_long: è memia lung (betg dapli che %{count} caracters)\n      too_short: è memia curt (betg pli pauc che %{count} caracters)\n      wrong_length: ha la fallida lunghezza (sto avair %{count} caracters)\n    template:\n      body: 'Faschai uschè bain e controllai ils suandants champs:'\n      header:\n        few: 'Betg pussaivel da memorisar quest %{model}: %{count} errurs.'\n        many: 'Betg pussaivel da memorisar quest %{model}: %{count} errurs.'\n        one: 'Betg pussaivel da memorisar quest %{model}: %{count} errur.'\n        other: 'Betg pussaivel da memorisar quest %{model}: %{count} errurs.'\n        two: 'Betg pussaivel da memorisar quest %{model}: %{count} errurs.'\n        zero: 'Betg pussaivel da memorisar quest %{model}: %{count} errurs.'\n  number:\n    currency:\n      format:\n        delimiter: \"'\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: CHF\n    format:\n      delimiter: \"'\"\n      precision: 2\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: bytes\n            many: bytes\n            one: byte\n            other: bytes\n            two: bytes\n            zero: bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" e \"\n      two_words_connector: \" e \"\n      words_connector: \", \"\n  time:\n    am: avantmezdi\n    formats:\n      default: \"%A, %d. %B %Y, %H:%M Uhr\"\n      long: \"%A, %d. %B %Y, %H:%M Uhr\"\n      short: \"%d. %B, %H:%M Uhr\"\n    pm: suentermezdi\n"
  },
  {
    "path": "config/locales/defaults/ro.yml",
    "content": "---\nro:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: Validare nereuşită %{errors}\n  date:\n    abbr_day_names:\n    - dum\n    - lun\n    - mar\n    - mie\n    - joi\n    - vin\n    - sâm\n    abbr_month_names:\n    -\n    - ian\n    - feb\n    - mar\n    - apr\n    - mai\n    - iun\n    - iul\n    - aug\n    - sep\n    - oct\n    - noi\n    - dec\n    day_names:\n    - duminică\n    - luni\n    - marți\n    - miercuri\n    - joi\n    - vineri\n    - sâmbătă\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - ianuarie\n    - februarie\n    - martie\n    - aprilie\n    - mai\n    - iunie\n    - iulie\n    - august\n    - septembrie\n    - octombrie\n    - noiembrie\n    - decembrie\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: aproximativ %{count} ore\n        one: aproximativ o oră\n        other: aproximativ %{count} ore\n      about_x_months:\n        few: aproximativ %{count} luni\n        one: aproximativ o lună\n        other: aproximativ %{count} luni\n      about_x_years:\n        few: aproximativ %{count} ani\n        one: aproximativ un an\n        other: aproximativ %{count} ani\n      almost_x_years:\n        few: aproape %{count} ani\n        one: aproape %{count} an\n        other: aproape %{count} ani\n      half_a_minute: jumătate de minut\n      less_than_x_minutes:\n        few: mai puțin de %{count} minute\n        one: mai puțin de un minut\n        other: mai puțin de %{count} minute\n      less_than_x_seconds:\n        few: mai puțin de %{count} secunde\n        one: mai puțin de o secundă\n        other: mai puțin de %{count} secunde\n      over_x_years:\n        few: mai mult de %{count} ani\n        one: mai mult de un an\n        other: mai mult de %{count} ani\n      x_days:\n        few: \"%{count} zile\"\n        one: \"%{count} zi\"\n        other: \"%{count} zile\"\n      x_minutes:\n        few: \"%{count} minute\"\n        one: \"%{count} minut\"\n        other: \"%{count} minute\"\n      x_months:\n        few: \"%{count} luni\"\n        one: \"%{count} lună\"\n        other: \"%{count} luni\"\n      x_seconds:\n        few: \"%{count} secunde\"\n        one: \"%{count} secundă\"\n        other: \"%{count} secunde\"\n    prompts:\n      day: ziua\n      hour: ora\n      minute: minutul\n      month: luna\n      second: secunda\n      year: anul\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: trebuie dat acceptul\n      blank: nu poate fi necompletat\n      confirmation: nu este confirmat\n      empty: nu poate fi necompletat\n      equal_to: trebuie să fie egal cu %{count}\n      even: trebuie să fie impar\n      exclusion: este rezervat\n      greater_than: trebuie să fie mai mare decât %{count}\n      greater_than_or_equal_to: trebuie să fie mai mare sau egal cu %{count}\n      inclusion: nu este inclus în listă\n      invalid: este invalid\n      less_than: trebuie să fie mai mic decât %{count}\n      less_than_or_equal_to: trebuie să fie mai mic sau egal cu %{count}\n      not_a_number: nu este un număr\n      not_an_integer: trebuie să fie un mumăr întreg\n      odd: trebuie să fie par\n      taken: este deja folosit\n      too_long: este prea lung (se pot folosi maximum %{count} caractere)\n      too_short: este prea scurt (minimum de caractere este %{count})\n      wrong_length: nu are lungimea corectă (trebuie să aibă %{count} caractere)\n    template:\n      body: 'Încearcă să corectezi urmatoarele câmpuri:'\n      header:\n        few: 'Nu am putut salva acest %{model}: %{count} erori.'\n        one: 'Nu am putut salva acest %{model}: o eroare'\n        other: 'Nu am putut salva acest %{model}: %{count} erori.'\n  helpers:\n    select:\n      prompt: Alegeți\n    submit:\n      create: Creare %{model}\n      submit: Salvare %{model}\n      update: Modificare %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: RON\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: miliard\n          million: milion\n          quadrillion: quadrilion\n          thousand: mie\n          trillion: trilion\n          unit: ''\n      format:\n        delimiter: \",\"\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: Bytes\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \",\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" și \"\n      two_words_connector: \" și \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a %d %b %Y, %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/ru.yml",
    "content": "---\nru:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Возникли ошибки: %{errors}'\n        restrict_dependent_destroy:\n          has_many: 'Невозможно удалить запись, так как существуют зависимости: %{record}'\n          has_one: 'Невозможно удалить запись, так как существует зависимость: %{record}'\n  date:\n    abbr_day_names:\n    - Вс\n    - Пн\n    - Вт\n    - Ср\n    - Чт\n    - Пт\n    - Сб\n    abbr_month_names:\n    -\n    - янв.\n    - февр.\n    - марта\n    - апр.\n    - мая\n    - июня\n    - июля\n    - авг.\n    - сент.\n    - окт.\n    - нояб.\n    - дек.\n    day_names:\n    - воскресенье\n    - понедельник\n    - вторник\n    - среда\n    - четверг\n    - пятница\n    - суббота\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%-d %B %Y\"\n      short: \"%-d %b\"\n    month_names:\n    -\n    - января\n    - февраля\n    - марта\n    - апреля\n    - мая\n    - июня\n    - июля\n    - августа\n    - сентября\n    - октября\n    - ноября\n    - декабря\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: около %{count} часов\n        many: около %{count} часов\n        one: около %{count} часа\n        other: около %{count} часов\n      about_x_months:\n        few: около %{count} месяцев\n        many: около %{count} месяцев\n        one: около %{count} месяца\n        other: около %{count} месяцев\n      about_x_years:\n        few: около %{count} лет\n        many: около %{count} лет\n        one: около %{count} года\n        other: около %{count} лет\n      almost_x_years:\n        few: почти %{count} года\n        many: почти %{count} лет\n        one: почти %{count} год\n        other: почти %{count} лет\n      half_a_minute: полминуты\n      less_than_x_minutes:\n        few: меньше %{count} минут\n        many: меньше %{count} минут\n        one: меньше %{count} минуты\n        other: меньше %{count} минут\n      less_than_x_seconds:\n        few: меньше %{count} секунд\n        many: меньше %{count} секунд\n        one: меньше %{count} секунды\n        other: меньше %{count} секунд\n      over_x_years:\n        few: больше %{count} лет\n        many: больше %{count} лет\n        one: больше %{count} года\n        other: больше %{count} лет\n      x_days:\n        few: \"%{count} дня\"\n        many: \"%{count} дней\"\n        one: \"%{count} день\"\n        other: \"%{count} дней\"\n      x_minutes:\n        few: \"%{count} минуты\"\n        many: \"%{count} минут\"\n        one: \"%{count} минута\"\n        other: \"%{count} минут\"\n      x_months:\n        few: \"%{count} месяца\"\n        many: \"%{count} месяцев\"\n        one: \"%{count} месяц\"\n        other: \"%{count} месяцев\"\n      x_seconds:\n        few: \"%{count} секунды\"\n        many: \"%{count} секунд\"\n        one: \"%{count} секунда\"\n        other: \"%{count} секунд\"\n      x_years:\n        few: \"%{count} года\"\n        many: \"%{count} лет\"\n        one: \"%{count} год\"\n        other: \"%{count} лет\"\n    prompts:\n      day: День\n      hour: Час\n      minute: Минута\n      month: Месяц\n      second: Секунда\n      year: Год\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: нужно подтвердить\n      blank: не может быть пустым\n      confirmation: не совпадает со значением поля %{attribute}\n      empty: не может быть пустым\n      equal_to: может иметь лишь значение, равное %{count}\n      even: может иметь лишь четное значение\n      exclusion: имеет зарезервированное значение\n      greater_than: может иметь значение большее %{count}\n      greater_than_or_equal_to: может иметь значение большее или равное %{count}\n      in: должно быть в диапазоне %{count}\n      inclusion: имеет непредусмотренное значение\n      invalid: имеет неверное значение\n      less_than: может иметь значение меньшее чем %{count}\n      less_than_or_equal_to: может иметь значение меньшее или равное %{count}\n      model_invalid: 'Возникли ошибки: %{errors}'\n      not_a_number: не является числом\n      not_an_integer: не является целым числом\n      odd: может иметь лишь нечетное значение\n      other_than: должно отличаться от %{count}\n      present: нужно оставить пустым\n      required: не может отсутствовать\n      taken: уже существует\n      too_long:\n        few: слишком большой длины (не может быть больше чем %{count} символа)\n        many: слишком большой длины (не может быть больше чем %{count} символов)\n        one: слишком большой длины (не может быть больше чем %{count} символ)\n        other: слишком большой длины (не может быть больше чем %{count} символа)\n      too_short:\n        few: недостаточной длины (не может быть меньше %{count} символов)\n        many: недостаточной длины (не может быть меньше %{count} символов)\n        one: недостаточной длины (не может быть меньше %{count} символа)\n        other: недостаточной длины (не может быть меньше %{count} символа)\n      wrong_length:\n        few: неверной длины (может быть длиной ровно %{count} символа)\n        many: неверной длины (может быть длиной ровно %{count} символов)\n        one: неверной длины (может быть длиной ровно %{count} символ)\n        other: неверной длины (может быть длиной ровно %{count} символа)\n    template:\n      body: 'Проблемы возникли со следующими полями:'\n      header:\n        few: \"%{model}: сохранение не удалось из-за %{count} ошибок\"\n        many: \"%{model}: сохранение не удалось из-за %{count} ошибок\"\n        one: \"%{model}: сохранение не удалось из-за %{count} ошибки\"\n        other: \"%{model}: сохранение не удалось из-за %{count} ошибки\"\n  helpers:\n    select:\n      prompt: 'Выберите: '\n    submit:\n      create: Создать %{model}\n      submit: Сохранить %{model}\n      update: Сохранить %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: руб.\n    format:\n      delimiter: \" \"\n      precision: 3\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            few: миллиардов\n            many: миллиардов\n            one: миллиард\n            other: миллиардов\n          million:\n            few: миллионов\n            many: миллионов\n            one: миллион\n            other: миллионов\n          quadrillion:\n            few: квадриллионов\n            many: квадриллионов\n            one: квадриллион\n            other: квадриллионов\n          thousand:\n            few: тысяч\n            many: тысяч\n            one: тысяча\n            other: тысяч\n          trillion:\n            few: триллионов\n            many: триллионов\n            one: триллион\n            other: триллионов\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: байта\n            many: байт\n            one: байт\n            other: байта\n          eb: ЭБ\n          gb: ГБ\n          kb: КБ\n          mb: МБ\n          pb: ПБ\n          tb: ТБ\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" и \"\n      two_words_connector: \" и \"\n      words_connector: \", \"\n  time:\n    am: утра\n    formats:\n      default: \"%a, %d %b %Y, %H:%M:%S %z\"\n      long: \"%d %B %Y, %H:%M\"\n      short: \"%d %b, %H:%M\"\n    pm: вечера\n"
  },
  {
    "path": "config/locales/defaults/sc.yml",
    "content": "---\nsc:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validatzione fallida: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Su registru non si podet iscantzellare ca esitint %{record} chi\n            nde dipendent\n          has_one: Su registru non si podet iscantzellare ca esitit unu %{record}\n            chi nde dipendet\n  date:\n    abbr_day_names:\n    - dom\n    - lun\n    - mar\n    - mèr\n    - giò\n    - che\n    - sàb\n    abbr_month_names:\n    -\n    - ghe\n    - fre\n    - mar\n    - abr\n    - maj\n    - làm\n    - trì\n    - aus\n    - cab\n    - stG\n    - stA\n    - nad\n    day_names:\n    - domìniga\n    - lunis\n    - martis\n    - mèrcuris\n    - giòbia\n    - chenàbura\n    - sàbadu\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - ghennàrgiu\n    - freàrgiu\n    - martzu\n    - abrile\n    - maju\n    - làmpadas\n    - trìulas\n    - austu\n    - cabudanni\n    - santugaine\n    - santandria\n    - nadale\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: pagu prus o mancu un'ora\n        other: pagu prus o mancu %{count} oras\n      about_x_months:\n        one: pagu prus o mancu unu mese\n        other: pagu prus o mancu %{count} meses\n      about_x_years:\n        one: pagu prus o mancu un'annu\n        other: pagu prus o mancu %{count} annos\n      almost_x_years:\n        one: belle un'annu\n        other: belle %{count} annos\n      half_a_minute: mesu minutu\n      less_than_x_minutes:\n        one: prus pagu de unu minutu\n        other: prus pagu de %{count} minutos\n      less_than_x_seconds:\n        one: prus pagu de unu segundu\n        other: prus pagu de %{count} segundos\n      over_x_years:\n        one: prus de un'annu\n        other: prus de %{count} annos\n      x_days:\n        one: 1 die\n        other: \"%{count} dies\"\n      x_minutes:\n        one: 1 minutu\n        other: \"%{count} minutos\"\n      x_months:\n        one: 1 mese\n        other: \"%{count} meses\"\n      x_seconds:\n        one: 1 segundu\n        other: \"%{count} segundos\"\n      x_years:\n        one: 1 annu\n        other: \"%{count} annos\"\n    prompts:\n      day: Die\n      hour: Ora\n      minute: Minutu\n      month: Mese\n      second: Segundu\n      year: Annu\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: depet èssere atzetadu\n      blank: non podet èssere lassadu in biancu\n      confirmation: non currispondet cun %{attribute}\n      empty: non pòdet èsser bòidu\n      equal_to: depet èssere uguale a %{count}\n      even: depet èssere pari\n      exclusion: est riservadu\n      greater_than: depet èssere prus mannu de %{count}\n      greater_than_or_equal_to: depet èssere prus mannu o uguale a %{count}\n      in: depet istare intre %{count}\n      inclusion: no est inclùdidu in sa lista\n      invalid: no est vàlidu\n      less_than: depet èssere prus minore de %{count}\n      less_than_or_equal_to: depet èssere prus minore o uguale a %{count}\n      model_invalid: 'Validatzione fallida: %{errors}'\n      not_a_number: no est unu nùmeru\n      not_an_integer: no est unu nùmeru intreu\n      odd: depet èssere dìspari\n      other_than: depet èssere unu nùmeru chi non siat %{count}\n      present: depet èssere lassadu in biancu\n      required: depet esìstere\n      taken: est giai presente\n      too_long:\n        one: est tropu longu (su màssimu est de 1 caràtere)\n        other: est tropu longu (su màssimu est de %{count} caràteres)\n      too_short:\n        one: est tropu curtzu (su mìnimu est de 1 caràtere)\n        other: est tropu curtzu (su mìnimu est de %{count} caràteres)\n      wrong_length:\n        one: est de sa longària isballiada (depet tènnere 1 caràtere)\n        other: est de sa longària isballiada (depet tènnere %{count} caràteres)\n    template:\n      body: 'Bi sunt istados problemas cun sos campos chi sighint:'\n      header:\n        one: 'Non potzo sarvare custu %{model}: 1 errore'\n        other: 'Non potzo sarvare custu %{model}: %{count} errores.'\n  helpers:\n    select:\n      prompt: Seletziona...\n    submit:\n      create: Crea %{model}\n      submit: Imbia %{model}\n      update: Atualiza %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: milliardu\n            other: milliardos\n          million:\n            one: millione\n            other: milliones\n          quadrillion:\n            one: cuadrillione\n            other: cuadrilliones\n          thousand: mìgia\n          trillion: mìgia milliardos\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Byte\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" e \"\n      two_words_connector: \" e \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d de %b de su %Y, %H:%M:%S %z\"\n      long: \"%d de %B de su %Y %H:%M\"\n      short: \"%d de %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/sk.yml",
    "content": "---\nsk:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validácia neúspešná: %{errors}'\n  date:\n    abbr_day_names:\n    - Ne\n    - Po\n    - Ut\n    - St\n    - Št\n    - Pi\n    - So\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - Máj\n    - Jún\n    - Júl\n    - Aug\n    - Sep\n    - Okt\n    - Nov\n    - Dec\n    day_names:\n    - Nedeľa\n    - Pondelok\n    - Utorok\n    - Streda\n    - Štvrtok\n    - Piatok\n    - Sobota\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%d. %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Január\n    - Február\n    - Marec\n    - Apríl\n    - Máj\n    - Jún\n    - Júl\n    - August\n    - September\n    - Október\n    - November\n    - December\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: asi %{count} hodinami\n        one: asi hodinou\n        other: asi %{count} hodinami\n      about_x_months:\n        few: asi %{count} mesiacmi\n        one: asi mesiacom\n        other: asi %{count} mesiacmi\n      about_x_years:\n        few: asi %{count} rokmi\n        one: asi rokom\n        other: asi %{count} rokmi\n      almost_x_years:\n        few: takmer %{count} rokmi\n        one: takmer rokom\n        other: takmer %{count} rokmi\n      half_a_minute: pol minútou\n      less_than_x_minutes:\n        few: necelými %{count} minútami\n        one: necelou minútou\n        other: necelými %{count} minútami\n      less_than_x_seconds:\n        few: necelými %{count} sekundami\n        one: necelou sekundou\n        other: necelými %{count} sekundami\n      over_x_years:\n        few: viac ako %{count} rokmi\n        one: viac ako rokom\n        other: viac ako %{count} rokmi\n      x_days:\n        few: \"%{count} dňami\"\n        one: dňom\n        other: \"%{count} dňami\"\n      x_minutes:\n        few: \"%{count} minútami\"\n        one: minútou\n        other: \"%{count} minútami\"\n      x_months:\n        few: \"%{count} mesiacmi\"\n        one: mesiacom\n        other: \"%{count} mesiacmi\"\n      x_seconds:\n        few: \"%{count} sekundami\"\n        one: sekundou\n        other: \"%{count} sekundami\"\n    prompts:\n      day: Deň\n      hour: Hodina\n      minute: Minúta\n      month: Mesiac\n      second: Sekunda\n      year: Rok\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: musí byť potvrdené\n      blank: je povinná položka\n      confirmation: nebolo potvrdené\n      empty: nesmie byť prázdny/e\n      equal_to: sa musí rovnať %{count}\n      even: musí byť párne číslo\n      exclusion: je vyhradené pre iný účel\n      greater_than: musí byť väčšie ako %{count}\n      greater_than_or_equal_to: musí byť väčšie alebo rovné %{count}\n      inclusion: nie je v zozname povolených hodnôt\n      invalid: nie je platná hodnota\n      less_than: musí byť menšie ako %{count}\n      less_than_or_equal_to: musí byť menšie alebo rovné %{count}\n      not_a_number: nie je číslo\n      not_an_integer: musí byť celé číslo\n      odd: musí byť nepárne číslo\n      required: musí existovať\n      taken: ste už použili\n      too_long: je príliš dlhá/ý (max. %{count} znakov)\n      too_short: je príliš krátky/a (min. %{count} znakov)\n      wrong_length: nemá správnu dĺžku (očakáva sa %{count} znakov)\n    template:\n      body: 'Nasledujúce polia obsahujú chybne vyplnené údaje:'\n      header:\n        few: Pri ukladaní objektu %{model} došlo k %{count} chybám a nebolo ho možné\n          uložiť\n        one: Pri ukladaní objektu %{model} došlo k chybám a nebolo ho možné uložiť\n        other: Pri ukladaní objektu %{model} došlo k %{count} chybám a nebolo ho možné\n          uložiť\n  helpers:\n    select:\n      prompt: Prosím vyberte si\n    submit:\n      create: Vytvoriť %{model}\n      submit: Uložiť %{model}\n      update: Aktualizovať %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Miliarda\n          million: Milión\n          quadrillion: Biliarda\n          thousand: Tisíc\n          trillion: Bilión\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: B\n            one: B\n            other: B\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \" \"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" a \"\n      two_words_connector: \" a \"\n      words_connector: \", \"\n  time:\n    am: dopoludnia\n    formats:\n      default: \"%a %e. %B %Y %H:%M %z\"\n      long: \"%A %e. %B %Y %H:%M\"\n      short: \"%e. %-m. %H:%M\"\n    pm: popoludní\n"
  },
  {
    "path": "config/locales/defaults/sl.yml",
    "content": "---\nsl:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: ''\n  date:\n    abbr_day_names:\n    - ned\n    - pon\n    - tor\n    - sre\n    - čet\n    - pet\n    - sob\n    abbr_month_names:\n    -\n    - jan\n    - feb\n    - mar\n    - apr\n    - maj\n    - jun\n    - jul\n    - avg\n    - sep\n    - okt\n    - nov\n    - dec\n    day_names:\n    - nedelja\n    - ponedeljek\n    - torek\n    - sreda\n    - četrtek\n    - petek\n    - sobota\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%d. %b %Y\"\n      short: \"%d. %b\"\n    month_names:\n    -\n    - januar\n    - februar\n    - marec\n    - april\n    - maj\n    - junij\n    - julij\n    - avgust\n    - september\n    - oktober\n    - november\n    - december\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: okoli %{count} ure\n        one: okoli %{count} ura\n        other: okoli %{count} ur\n        two: okoli 2 uri\n      about_x_months:\n        few: okoli %{count} mesece\n        one: okoli %{count} mesec\n        other: okoli %{count} mesecev\n        two: okoli 2 meseca\n      about_x_years:\n        few: okoli %{count} leta\n        one: okoli %{count} leto\n        other: okoli %{count} let\n        two: okoli 2 leti\n      almost_x_years:\n        few: skoraj %{count} leta\n        one: skoraj %{count} leto\n        other: skoraj %{count} let\n        two: skoraj 2 leti\n      half_a_minute: pol minute\n      less_than_x_minutes:\n        few: manj kot %{count} minute\n        one: manj kot ena minuta\n        other: manj kot %{count} minut\n        two: manj kot dve minuti\n      less_than_x_seconds:\n        few: manj kot %{count} sekunde\n        one: manj kot %{count} sekunda\n        other: manj kot %{count} sekund\n        two: manj kot 2 sekundi\n      over_x_years:\n        few: več kot %{count} leta\n        one: več kot %{count} leto\n        other: več kot %{count} let\n        two: več kot 2 leti\n      x_days:\n        few: \"%{count} dnevi\"\n        one: \"%{count} dan\"\n        other: \"%{count} dni\"\n        two: 2 dneva\n      x_minutes:\n        few: \"%{count} minute\"\n        one: \"%{count} minuta\"\n        other: \"%{count} minut\"\n        two: 2 minuti\n      x_months:\n        few: \"%{count} mesece\"\n        one: \"%{count} mesec\"\n        other: \"%{count} mesecev\"\n        two: 2 meseca\n      x_seconds:\n        few: \"%{count} sekunde\"\n        one: \"%{count} sekunda\"\n        other: \"%{count} sekund\"\n        two: 2 sekundi\n    prompts:\n      day: Dan\n      hour: Ura\n      minute: Minute\n      month: Mesec\n      second: Sekunde\n      year: Leto\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: mora biti sprejeto\n      blank: ne sme biti prazno\n      confirmation: se ne ujema z vrednostjo %{attribute}\n      empty: ne sme biti prazno\n      equal_to: mora biti enako %{count}\n      even: mora biti sodo\n      exclusion: je rezervirano\n      greater_than: mora biti večje kot %{count}\n      greater_than_or_equal_to: mora biti večje ali enako %{count}\n      inclusion: ni vključeno v seznam\n      invalid: je nepravilno\n      less_than: mora biti manj kot %{count}\n      less_than_or_equal_to: mora biti manj ali enako %{count}\n      not_a_number: ni številka\n      odd: mora biti liho\n      taken: je že zasedeno\n      too_long: je predolgo (dovoljeno je do %{count} znakov)\n      too_short: je prekratko (zahtevano je najmanj %{count} znakov)\n      wrong_length: je napačne dolžine (mora biti natančno %{count} znakov)\n    template:\n      body: 'Napačno izpolnjena polja:'\n      header:\n        few: \"%{count} napake preprečujejo, da bi shranili %{model}\"\n        one: Ena napaka preprečuje, da bi shranili %{model}\n        other: \"%{count} napak preprečuje, da bi shranili %{model}\"\n        two: Dve napaki preprečujeta, da bi shranili %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: Bytes\n            one: Byte\n            other: Bytes\n            two: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" in \"\n      two_words_connector: \" in \"\n      words_connector: \", \"\n  time:\n    am: dopoldan\n    formats:\n      default: \"%A, %d %b %Y ob %H:%M:%S\"\n      long: \"%d. %B, %Y ob %H:%M\"\n      short: \"%d. %b ob %H:%M\"\n    pm: popoldan\n"
  },
  {
    "path": "config/locales/defaults/sq.yml",
    "content": "---\nsq:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Vlerësimi dështoi: %{errors}'\n        restrict_dependent_destroy:\n          has_many: S’fshihet dot zëri, ngaqë ekzistojnë %{record} që varen prej tij\n          has_one: S’fshihet dot zëri, ngaqë ekziston një %{record} që varet prej\n            tij\n  date:\n    abbr_day_names:\n    - Die\n    - Hën\n    - Mar\n    - Mër\n    - Enj\n    - Pre\n    - Sht\n    abbr_month_names:\n    -\n    - Jan\n    - Shk\n    - Mar\n    - Pri\n    - Maj\n    - Qer\n    - Kor\n    - Gus\n    - Sht\n    - Tet\n    - Nën\n    - Dhj\n    day_names:\n    - E dielë\n    - E hënë\n    - E martë\n    - E mërkurë\n    - E enjte\n    - E premte\n    - E shtunë\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B, %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Janar\n    - Shkurt\n    - Mars\n    - Prill\n    - Maj\n    - Qershor\n    - Korrik\n    - Gusht\n    - Shtator\n    - Tetor\n    - Nëntor\n    - Dhjetor\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: rreth %{count} orë\n        other: rreth %{count} orë\n      about_x_months:\n        one: rreth %{count} muaj\n        other: rreth %{count} muaj\n      about_x_years:\n        one: rreth %{count} vit\n        other: rreth %{count} vjet\n      almost_x_years:\n        one: gati %{count} vit\n        other: gati %{count} vjet\n      half_a_minute: gjysmë minute\n      less_than_x_minutes:\n        one: më pak se një minutë\n        other: më pak se %{count} minuta\n      less_than_x_seconds:\n        one: më pak se %{count} sekondë\n        other: më pak se %{count} sekonda\n      over_x_years:\n        one: mbi %{count} vit\n        other: mbi %{count} vjet\n      x_days:\n        one: \"%{count} ditë\"\n        other: \"%{count} ditë\"\n      x_minutes:\n        one: \"%{count} minutë\"\n        other: \"%{count} minuta\"\n      x_months:\n        one: \"%{count} muaj\"\n        other: \"%{count} muaj\"\n      x_years:\n        one: \"%{count} vit\"\n        other: \"%{count} vjet\"\n    prompts:\n      day: Ditë\n      hour: Orë\n      minute: Minutë\n      month: Muaj\n      second: Sekondë\n      year: Vit\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: duhet pranuar\n      blank: s’mund të jetë i zbrazët\n      confirmation: s’përputhet me %{attribute}\n      empty: s’mund të jetë i zbrazët\n      equal_to: duhet të jetë baras me %{count}\n      even: duhet të jetë çift\n      exclusion: është i rezervuar\n      greater_than: duhet të jetë më i madh se %{count}\n      greater_than_or_equal_to: duhet të jetë më i madh ose i barabartë me %{count}\n      inclusion: s’përfshihet te lista\n      invalid: është i pavlefshëm\n      less_than: duhet të jetë më i vogël se %{count}\n      less_than_or_equal_to: duhet të jetë më i vogël ose i barabartë me %{count}\n      model_invalid: 'Vlerësimi dështoi: %{errors}'\n      not_a_number: s’është numër\n      not_an_integer: duhet të jetë numër i plotë\n      odd: duhet të jetë tek\n      other_than: duhet të jetë më shumë se %{count}\n      present: duhet të jetë e zbrazët\n      required: duhet të ekzistojë\n      taken: është zënë tashmë\n      too_long:\n        one: është shumë i gjatë (maksimumi është %{count} shenjë)\n        other: është shumë i gjatë (maksimumi është %{count} shenja)\n      too_short:\n        one: është shumë i shkurtër (minimumi është %{count} shenjë)\n        other: është shumë i shkurtër (minimumi është %{count} shenja)\n      wrong_length:\n        one: ka gjatësi të gabuar (duhet të jetë %{count} shenjë)\n        other: ka gjatësi të gabuar (duhet të jetë %{count} shenja)\n    template:\n      body: 'Pati probleme me fushat vijuese:'\n      header:\n        one: Ruajtja e këtij %{model} u pengua nga %{count} gabim\n        other: Ruajtja e këtij %{model} u pengua nga %{count} gabime\n  helpers:\n    select:\n      prompt: Ju lutemi, përzgjidhni\n    submit:\n      create: Krijoje %{model}\n      submit: Ruaje %{model}\n      update: Përditësoje %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Miliard\n          million: Milion\n          quadrillion: Kuadrilion\n          thousand: Mijë\n          trillion: Trilion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Bajt\n            other: Bajte\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", dhe \"\n      two_words_connector: \" dhe \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B, %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/sr.yml",
    "content": "---\nsr:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Валидација није успела: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Није могуће обрисати запис јер постоје зависни %{record}\n          has_one: Није могуће обрисати запис јер постоји зависан %{record}\n  date:\n    abbr_day_names:\n    - Нед\n    - Пон\n    - Уто\n    - Сре\n    - Чет\n    - Пет\n    - Суб\n    abbr_month_names:\n    -\n    - Јан\n    - Феб\n    - Мар\n    - Апр\n    - Мај\n    - Јун\n    - Јул\n    - Авг\n    - Сеп\n    - Окт\n    - Нов\n    - Дец\n    day_names:\n    - Недеља\n    - Понедељак\n    - Уторак\n    - Среда\n    - Четвртак\n    - Петак\n    - Субота\n    formats:\n      default: \"%d/%m/%Y\"\n      long: \"%B %e, %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - Јануар\n    - Фабруар\n    - Март\n    - Април\n    - Мај\n    - Јун\n    - Јул\n    - Август\n    - Септембар\n    - Октобар\n    - Новембар\n    - Децембар\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: око %{count} сата\n        many: око %{count} сати\n        one: око %{count} сат\n        other: око %{count} сати\n      about_x_months:\n        few: око %{count} месеца\n        many: око %{count} месеци\n        one: око %{count} месец\n        other: око %{count} месеци\n      about_x_years:\n        few: око %{count} године\n        many: око %{count} година\n        one: око %{count} године\n        other: око %{count} година\n      almost_x_years:\n        few: скоро %{count} године\n        many: скоро %{count} година\n        one: скоро %{count} година\n        other: скоро %{count} година\n      half_a_minute: пола минуте\n      less_than_x_minutes:\n        few: мање од %{count} минута\n        many: мање од %{count} минута\n        one: мање од %{count} минут\n        other: мање од %{count} минута\n      less_than_x_seconds:\n        few: мање од %{count} секунде\n        many: мање од %{count} секунди\n        one: мање од %{count} секунд\n        other: мање од %{count} секунди\n      over_x_years:\n        few: преко %{count} године\n        many: преко %{count} година\n        one: преко %{count} године\n        other: преко %{count} година\n      x_days:\n        few: \"%{count} дана\"\n        many: \"%{count} дана\"\n        one: \"%{count} дан\"\n        other: \"%{count} дана\"\n      x_minutes:\n        few: \"%{count} минута\"\n        many: \"%{count} минута\"\n        one: \"%{count} минут\"\n        other: \"%{count} минута\"\n      x_months:\n        few: \"%{count} месеца\"\n        many: \"%{count} месеци\"\n        one: \"%{count} месец\"\n        other: \"%{count} месеци\"\n      x_seconds:\n        few: \"%{count} секунде\"\n        many: \"%{count} секунди\"\n        one: \"%{count} секунда\"\n        other: \"%{count} секунди\"\n    prompts:\n      day: Дан\n      hour: Сат\n      minute: Минут\n      month: Месец\n      second: Секунд\n      year: Година\n  errors:\n    format: Поље %{attribute} %{message}\n    messages:\n      accepted: мора бити прихваћено\n      blank: не сме бити празано\n      confirmation: се не слаже са потврдом\n      empty: не сме бити празано\n      equal_to: мора бити једнако %{count}\n      even: мора бити парно\n      exclusion: је резервисано\n      greater_than: мора бити веће од %{count}\n      greater_than_or_equal_to: мора бити веће или једнако %{count}\n      inclusion: није у листи\n      invalid: није исправно\n      less_than: мора бити мање од %{count}\n      less_than_or_equal_to: мора бити мање или једнако %{count}\n      model_invalid: 'Валидација није успела: %{errors}'\n      not_a_number: није број\n      not_an_integer: није цео број\n      odd: мора бити непарно\n      other_than: мора бити различито од %{count}\n      present: мора бити празно\n      required: мора постојати\n      taken: је већ заузето\n      too_long:\n        few: је предугачко (максимум је %{count} знака)\n        many: је предугачко (максимум је %{count} знакова)\n        one: је предугачко (максимум је %{count} знак)\n        other: је предугачко (максимум је %{count} знакова)\n      too_short:\n        few: је прекратко (минимум је %{count} знака)\n        many: је прекратко (минимум је %{count} знакова)\n        one: је прекратко (минимум је %{count} знак)\n        other: је прекратко (минимум је %{count} знакова)\n      wrong_length:\n        few: није одговарајуће дужине (треба бити %{count} знака)\n        many: није одговарајуће дужине (треба бити %{count} знакова)\n        one: није одговарајуће дужине (треба бити %{count} знак)\n        other: није одговарајуће дужине (треба бити %{count} знакова)\n    template:\n      body: 'Молим Вас да проверите следећа поља:'\n      header:\n        few: 'Нисам успео сачувати %{model}: %{count} грешке.'\n        many: 'Нисам успео сачувати %{model}: %{count} грешки.'\n        one: 'Нисам успео сачувати %{model}: %{count} грешка.'\n        other: 'Нисам успео сачувати %{model}: %{count} грешки.'\n  helpers:\n    select:\n      prompt: Изаберите\n    submit:\n      create: Направи %{model}\n      submit: Сачувај %{model}\n      update: Измени %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: ДИН\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Милијарда\n          million: Милион\n          quadrillion: Квадрилион\n          thousand: Хиљаду\n          trillion: Трилион\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: бајта\n            many: бајтова\n            one: бајт\n            other: бајтова\n          gb: ГБ\n          kb: КБ\n          mb: МБ\n          tb: ТБ\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", и \"\n      two_words_connector: \" и \"\n      words_connector: \", \"\n  time:\n    am: АМ\n    formats:\n      default: \"%a %d %b %Y %H:%M:%S %Z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: ПМ\n"
  },
  {
    "path": "config/locales/defaults/st.yml",
    "content": "---\nst:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Netefatso e fosahetse: %{errors}'\n        restrict_dependent_destroy:\n          has_many: E ka se hlakolehe hobane kamano tse ngata li teng le %{record}\n          has_one: E ka se hlakolehe hobane kamano e le ٰngoe teng le %{record}\n  date:\n    abbr_day_names:\n    - Son\n    - ٰMan\n    - Labob\n    - Labor\n    - Labon\n    - Laboh\n    - Moq\n    abbr_month_names:\n    -\n    - Phe\n    - Hlako\n    - Tlha\n    - ٰMes\n    - Motš\n    - Phupt\n    - Phup\n    - Pha\n    - Loe\n    - Mph\n    - Pul\n    - Tši\n    day_names:\n    - Sontaha\n    - ٰMantaha\n    - Labobeli\n    - Laboraro\n    - Labone\n    - Labohlano\n    - Moqebelo\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Pherekhong\n    - Hlakola\n    - Hlakubele\n    - ٰMesa\n    - Motšeanong\n    - Phuptjane\n    - Phupu\n    - Phato\n    - Loetse\n    - Mphalane\n    - Pulungoana\n    - Tšitoe\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ekaba hora e le ٰngoe\n        other: ekaba hora tse %{count}\n      about_x_months:\n        one: ekaba khoeli e le ٰngoe\n        other: ekaba likhoeli tse %{count}\n      about_x_years:\n        one: ekaba selemo se le seng\n        other: ekaba lilemo tse %{count}\n      almost_x_years:\n        one: e tlo ba selemo se le seng\n        other: e tlo ba lilemo tse %{count}\n      half_a_minute: halofo ea motsotso\n      less_than_x_minutes:\n        one: ka tlase ho motsotso\n        other: ka tlase ho metsotso e %{count}\n      less_than_x_seconds:\n        one: ka tlase ho motsotsoana\n        other: ka tlase ho metsotsoana e %{count}\n      over_x_years:\n        one: ka holimo ho selemo\n        other: ka holimo ho lilemo tse %{count}\n      x_days:\n        one: letsatsi le le leng\n        other: matsatsi a %{count}\n      x_minutes:\n        one: motsotso o le mong\n        other: metsotso e %{count}\n      x_months:\n        one: khoeli e le \\'ngoe\n        other: likhoeli tse %{count}\n      x_seconds:\n        one: motsotsoana o le mong\n        other: metsotsoana e %{count}\n      x_years:\n        one: selemo se le seng\n        other: lilemo tse %{count}\n    prompts:\n      day: Letsatsi\n      hour: Hora\n      minute: Motsotso\n      month: Khoeli\n      second: Motsotsoana\n      year: Selemo\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: e tlameha ho amoheloa\n      blank: e ka se be letho\n      confirmation: ha e lumellane le %{attribute}\n      empty: e ka se be letho\n      equal_to: e tlameha ho lekana le %{count}\n      even: e tlameha ho lekana\n      exclusion: e bolokiloe\n      greater_than: e tlameha e be kholo ho %{count}\n      greater_than_or_equal_to: e tlameha e be kholo kapa e lekane le %{count}\n      inclusion: ha ea kenyelletsoa lenaneng\n      invalid: ha e sebetse\n      less_than: e tlameha e be nyane ho %{count}\n      less_than_or_equal_to: e tlameha e be nyane kapa e lekane le %{count}\n      model_invalid: 'Liphoso tsa netefatso: %{errors}'\n      not_a_number: ha se palo\n      not_an_integer: e tšoanetse e be palo e felletseng\n      odd: e tlameha ho makatsa\n      other_than: e tlameha e be kholo ho %{count}\n      present: e tlameha ebe ha e na letho\n      required: e tlameha ho ba teng\n      taken: e sentse e nkuoe\n      too_long:\n        one: e telele haholo (boholo ke semelo se le seng)\n        other: e telele haholo (boholo ke limelo tse %{count})\n      too_short:\n        one: e khutšoane haholo (bonyane ke semelo se le seng)\n        other: e khutšoane haholo (bonyane ke limelo tse %{count})\n      wrong_length:\n        one: ke bolelele bo fosahetseng (e tšoanetse e be semelo se le seng)\n        other: ke bolelele bo fosahetseng (e tšoanetse e be limelo tse %{count})\n    template:\n      body: 'Ho bile le mathata ka a itseng makaleng a latelang:'\n      header:\n        one: phoso e le ٰngoe e thibetse  %{model} ho bolokeha\n        other: liphoso tse %{count} li thibetse %{model} ho bolokeha\n  helpers:\n    select:\n      prompt: Khetha ka kopo\n    submit:\n      create: Bopa %{model}\n      submit: Boloka %{model}\n      update: Ntjhafatsa %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: M\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Bilione\n          million: Milione\n          quadrillion: Libilione tse likete\n          thousand: Sekete\n          trillion: Trilione\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", le \"\n      two_words_connector: \" le \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/sv-FI.yml",
    "content": "---\nsv-FI:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Ett fel uppstod: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Kan inte ta bort poster då beroende %{record} finns\n          has_one: Kan inte ta bort post då beroende %{record} finns\n  date:\n    abbr_day_names:\n    - sön\n    - mån\n    - tis\n    - ons\n    - tor\n    - fre\n    - lör\n    abbr_month_names:\n    -\n    - jan\n    - feb\n    - mar\n    - apr\n    - maj\n    - jun\n    - jul\n    - aug\n    - sep\n    - okt\n    - nov\n    - dec\n    day_names:\n    - söndag\n    - måndag\n    - tisdag\n    - onsdag\n    - torsdag\n    - fredag\n    - lördag\n    formats:\n      default: \"%-d.%-m.%Y\"\n      long: \"%e %B %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - januari\n    - februari\n    - mars\n    - april\n    - maj\n    - juni\n    - juli\n    - augusti\n    - september\n    - oktober\n    - november\n    - december\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ungefär en timme\n        other: ungefär %{count} timmar\n      about_x_months:\n        one: ungefär en månad\n        other: ungefär %{count} månader\n      about_x_years:\n        one: ungefär ett år\n        other: ungefär %{count} år\n      almost_x_years:\n        one: nästan ett år\n        other: nästan %{count} år\n      half_a_minute: en halv minut\n      less_than_x_minutes:\n        one: mindre än en minut\n        other: mindre än %{count} minuter\n      less_than_x_seconds:\n        one: mindre än en sekund\n        other: mindre än %{count} sekunder\n      over_x_years:\n        one: mer än ett år\n        other: mer än %{count} år\n      x_days:\n        one: en dag\n        other: \"%{count} dagar\"\n      x_minutes:\n        one: en minut\n        other: \"%{count} minuter\"\n      x_months:\n        one: en månad\n        other: \"%{count} månader\"\n      x_seconds:\n        one: en sekund\n        other: \"%{count} sekunder\"\n    prompts:\n      day: Dag\n      hour: Timme\n      minute: Minut\n      month: Månad\n      second: Sekund\n      year: År\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: måste vara accepterad\n      blank: måste anges\n      confirmation: stämmer inte överens\n      empty: får ej vara tom\n      equal_to: måste vara lika med %{count}\n      even: måste vara jämnt\n      exclusion: är reserverat\n      greater_than: måste vara större än %{count}\n      greater_than_or_equal_to: måste vara större än eller lika med %{count}\n      inclusion: finns inte i listan\n      invalid: har fel format\n      less_than: måste vara mindre än %{count}\n      less_than_or_equal_to: måste vara mindre än eller lika med %{count}\n      model_invalid: 'Validering misslyckades: %{errors}'\n      not_a_number: är inte ett nummer\n      not_an_integer: måste vara ett heltal\n      odd: måste vara udda\n      other_than: måste vara annat än %{count}\n      present: får inte anges\n      required: måste finnas\n      taken: används redan\n      too_long: är för lång (maximum är %{count} tecken)\n      too_short: är för kort (minimum är %{count} tecken)\n      wrong_length: har fel längd (ska vara %{count} tecken)\n    template:\n      body: 'Det var problem med följande fält:'\n      header:\n        one: Ett fel förhindrade ifrågavarande %{model} från att sparas\n        other: \"%{count} fel förhindrade ifrågavarande %{model} från att sparas\"\n  helpers:\n    select:\n      prompt: Välj\n    submit:\n      create: Skapa %{model}\n      submit: Spara %{model}\n      update: Ändra %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"€\"\n    format:\n      delimiter: \" \"\n      precision: 2\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Miljard\n          million: Miljon\n          quadrillion: Biljard\n          thousand: Tusen\n          trillion: Biljon\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \" \"\n        format: \"%n %\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" och \"\n      two_words_connector: \" och \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %e %b %Y %H:%M:%S %z\"\n      long: \"%e %B %Y %H:%M\"\n      short: \"%e %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/sv-SE.yml",
    "content": "---\nsv-SE:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Ett fel uppstod: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Kan inte ta bort poster då beroende %{record} finns\n          has_one: Kan inte ta bort post då beroende %{record} finns\n  date:\n    abbr_day_names:\n    - sön\n    - mån\n    - tis\n    - ons\n    - tor\n    - fre\n    - lör\n    abbr_month_names:\n    -\n    - jan\n    - feb\n    - mar\n    - apr\n    - maj\n    - jun\n    - jul\n    - aug\n    - sep\n    - okt\n    - nov\n    - dec\n    day_names:\n    - söndag\n    - måndag\n    - tisdag\n    - onsdag\n    - torsdag\n    - fredag\n    - lördag\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%e %B %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - januari\n    - februari\n    - mars\n    - april\n    - maj\n    - juni\n    - juli\n    - augusti\n    - september\n    - oktober\n    - november\n    - december\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ungefär en timme\n        other: ungefär %{count} timmar\n      about_x_months:\n        one: ungefär en månad\n        other: ungefär %{count} månader\n      about_x_years:\n        one: ungefär ett år\n        other: ungefär %{count} år\n      almost_x_years:\n        one: nästan ett år\n        other: nästan %{count} år\n      half_a_minute: en halv minut\n      less_than_x_minutes:\n        one: mindre än en minut\n        other: mindre än %{count} minuter\n      less_than_x_seconds:\n        one: mindre än en sekund\n        other: mindre än %{count} sekunder\n      over_x_years:\n        one: mer än ett år\n        other: mer än %{count} år\n      x_days:\n        one: en dag\n        other: \"%{count} dagar\"\n      x_minutes:\n        one: en minut\n        other: \"%{count} minuter\"\n      x_months:\n        one: en månad\n        other: \"%{count} månader\"\n      x_seconds:\n        one: en sekund\n        other: \"%{count} sekunder\"\n      x_years:\n        one: ett år\n        other: \"%{count} år\"\n    prompts:\n      day: Dag\n      hour: Timme\n      minute: Minut\n      month: Månad\n      second: Sekund\n      year: År\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: måste vara accepterad\n      blank: måste anges\n      confirmation: stämmer inte överens\n      empty: får ej vara tom\n      equal_to: måste vara lika med %{count}\n      even: måste vara jämnt\n      exclusion: är reserverat\n      greater_than: måste vara större än %{count}\n      greater_than_or_equal_to: måste vara större än eller lika med %{count}\n      inclusion: finns inte i listan\n      invalid: har fel format\n      less_than: måste vara mindre än %{count}\n      less_than_or_equal_to: måste vara mindre än eller lika med %{count}\n      model_invalid: 'Validering misslyckades: %{errors}'\n      not_a_number: är inte ett nummer\n      not_an_integer: måste vara ett heltal\n      odd: måste vara udda\n      other_than: måste vara annat än %{count}\n      present: får inte anges\n      required: måste finnas\n      taken: används redan\n      too_long: är för lång (maximum är %{count} tecken)\n      too_short: är för kort (minimum är %{count} tecken)\n      wrong_length: har fel längd (ska vara %{count} tecken)\n    template:\n      body: 'Det var problem med följande fält:'\n      header:\n        one: Ett fel förhindrade denna %{model} från att sparas\n        other: \"%{count} fel förhindrade denna %{model} från att sparas\"\n  helpers:\n    select:\n      prompt: Välj\n    submit:\n      create: Skapa %{model}\n      submit: Spara %{model}\n      update: Ändra %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: kr\n    format:\n      delimiter: \" \"\n      precision: 2\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Miljard\n          million: Miljon\n          quadrillion: Biljard\n          thousand: Tusen\n          trillion: Biljon\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" och \"\n      two_words_connector: \" och \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %e %b %Y %H:%M:%S %z\"\n      long: \"%e %B %Y %H:%M\"\n      short: \"%e %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/sv.yml",
    "content": "---\nsv:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Ett fel uppstod: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Kan inte ta bort poster då beroende %{record} finns\n          has_one: Kan inte ta bort post då beroende %{record} finns\n  date:\n    abbr_day_names:\n    - sön\n    - mån\n    - tis\n    - ons\n    - tor\n    - fre\n    - lör\n    abbr_month_names:\n    -\n    - jan\n    - feb\n    - mar\n    - apr\n    - maj\n    - jun\n    - jul\n    - aug\n    - sep\n    - okt\n    - nov\n    - dec\n    day_names:\n    - söndag\n    - måndag\n    - tisdag\n    - onsdag\n    - torsdag\n    - fredag\n    - lördag\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%e %B %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - januari\n    - februari\n    - mars\n    - april\n    - maj\n    - juni\n    - juli\n    - augusti\n    - september\n    - oktober\n    - november\n    - december\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: ungefär en timme\n        other: ungefär %{count} timmar\n      about_x_months:\n        one: ungefär en månad\n        other: ungefär %{count} månader\n      about_x_years:\n        one: ungefär ett år\n        other: ungefär %{count} år\n      almost_x_years:\n        one: nästan ett år\n        other: nästan %{count} år\n      half_a_minute: en halv minut\n      less_than_x_minutes:\n        one: mindre än en minut\n        other: mindre än %{count} minuter\n      less_than_x_seconds:\n        one: mindre än en sekund\n        other: mindre än %{count} sekunder\n      over_x_years:\n        one: mer än ett år\n        other: mer än %{count} år\n      x_days:\n        one: en dag\n        other: \"%{count} dagar\"\n      x_minutes:\n        one: en minut\n        other: \"%{count} minuter\"\n      x_months:\n        one: en månad\n        other: \"%{count} månader\"\n      x_seconds:\n        one: en sekund\n        other: \"%{count} sekunder\"\n      x_years:\n        one: ett år\n        other: \"%{count} år\"\n    prompts:\n      day: Dag\n      hour: Timme\n      minute: Minut\n      month: Månad\n      second: Sekund\n      year: År\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: måste vara accepterad\n      blank: måste anges\n      confirmation: stämmer inte överens\n      empty: får ej vara tom\n      equal_to: måste vara lika med %{count}\n      even: måste vara jämnt\n      exclusion: är reserverat\n      greater_than: måste vara större än %{count}\n      greater_than_or_equal_to: måste vara större än eller lika med %{count}\n      inclusion: finns inte i listan\n      invalid: har fel format\n      less_than: måste vara mindre än %{count}\n      less_than_or_equal_to: måste vara mindre än eller lika med %{count}\n      model_invalid: 'Validering misslyckades: %{errors}'\n      not_a_number: är inte ett nummer\n      not_an_integer: måste vara ett heltal\n      odd: måste vara udda\n      other_than: måste vara annat än %{count}\n      present: får inte anges\n      required: måste finnas\n      taken: används redan\n      too_long: är för lång (maximum är %{count} tecken)\n      too_short: är för kort (minimum är %{count} tecken)\n      wrong_length: har fel längd (ska vara %{count} tecken)\n    template:\n      body: 'Det var problem med följande fält:'\n      header:\n        one: Ett fel förhindrade ifrågavarande %{model} från att sparas\n        other: \"%{count} fel förhindrade ifrågavarande %{model} från att sparas\"\n  helpers:\n    select:\n      prompt: Välj\n    submit:\n      create: Skapa %{model}\n      submit: Spara %{model}\n      update: Ändra %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: kr\n    format:\n      delimiter: \" \"\n      precision: 2\n      round_mode: default\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Miljard\n          million: Miljon\n          quadrillion: Biljard\n          thousand: Tusen\n          trillion: Biljon\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: \" \"\n        format: \"%n %\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" och \"\n      two_words_connector: \" och \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %e %b %Y %H:%M:%S %z\"\n      long: \"%e %B %Y %H:%M\"\n      short: \"%e %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/sw.yml",
    "content": "---\nsw:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Uhalalishaji umeshindikana: %{errors}'\n  date:\n    abbr_day_names:\n    - J2\n    - J3\n    - J4\n    - J5\n    - Al\n    - Ij\n    - J1\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mac\n    - Apr\n    - Mei\n    - Jun\n    - Jul\n    - Ago\n    - Sep\n    - Okt\n    - Nov\n    - Des\n    day_names:\n    - Jumapili\n    - Jumatatu\n    - Jumanne\n    - Jumatano\n    - Alhamisi\n    - Ijumaa\n    - Jumamosi\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%e %B, %Y\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - Mwezi wa kwanza\n    - Mwezi wa pili\n    - Mwezi wa tatu\n    - Mwezi wa nne\n    - Mwezi wa tano\n    - Mwezi wa sita\n    - Mwezi wa saba\n    - Mwezi wa nane\n    - Mwezi wa tisa\n    - Mwezi wa kumi\n    - Mwezi wa kumi na moja\n    - Mwezi wa kumi na mbili\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: kama saa limoja\n        other: kama masaa %{count}\n      about_x_months:\n        one: kama mwezi %{count}\n        other: kama miezi %{count}\n      about_x_years:\n        one: kama mwaka %{count}\n        other: kama miaka %{count}\n      almost_x_years:\n        one: karibia mwaka\n        other: karibia miaka %{count}\n      half_a_minute: nusu dakika\n      less_than_x_minutes:\n        one: chini ya dakika %{count}\n        other: chini ya dakika %{count}\n      less_than_x_seconds:\n        one: chini ya sekunde %{count}\n        other: chini ya sekunde %{count}\n      over_x_years:\n        one: zaidi ya mwaka %{count}\n        other: zaidi ya miaka %{count}\n      x_days:\n        one: siku %{count}\n        other: siku %{count}\n      x_minutes:\n        one: dakika %{count}\n        other: dakika %{count}\n      x_months:\n        one: mwezi %{count}\n        other: miezi %{count}\n      x_seconds:\n        one: sekunde %{count}\n        other: sekunde %{count}\n    prompts:\n      day: Siku\n      hour: Saa\n      minute: Dakika\n      month: Mwezi\n      second: Sekunde\n      year: Mwaka\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: lazima ikubaliwe\n      blank: haitakiwi kuwa wazi\n      confirmation: haifanani na hapo chini\n      empty: haitakiwi kuwa tupu\n      equal_to: z/iwe sawa na %{count}\n      even: z/iwe shufwa\n      exclusion: haiwezi kutumika\n      greater_than: z/iwe zaidi ya %{count}\n      greater_than_or_equal_to: z/iwe sawa ama zaidi ya %{count}\n      inclusion: haipo kwenye orodha\n      invalid: haifai\n      less_than: z/isizidi %{count}\n      less_than_or_equal_to: z/iwe sawa na, ama chini ya %{count}\n      not_a_number: inaruhusiwa namba tu\n      not_an_integer: inaruhusiwa namba tu\n      odd: z/iwe witiri\n      taken: imesajiliwa\n      too_long: ndefu sana (isizidi herufi %{count})\n      too_short: fupi mno (isipungue herufi %{count})\n      wrong_length: idadi ya herufi hazilingani (inatakiwa %{count})\n    template:\n      body: 'Tafadhali kagua sehemu zifuatazo:'\n      header:\n        one: \"%{model} haikuhifadhiwa kwa sababu moja.\"\n        other: \"%{model} haikuhifadhiwa kwa sababu %{count}.\"\n  helpers:\n    select:\n      prompt: Tafadhali teua\n    submit:\n      create: Unda %{model}\n      submit: Akibisha %{model}\n      update: Sasaisha %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n%u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"/=\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Bilioni\n          million: Milioni\n          quadrillion: Kuadrilioni\n          thousand: Elfu\n          trillion: Trilioni\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte: Baiti\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", na \"\n      two_words_connector: \" na \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S\"\n      long: \"%A, %e. %B %Y, %H:%M:%S\"\n      short: \"%e %b %Y %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/ta.yml",
    "content": "---\nta:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'சரிபார்த்தல் தோல்வியுற்றது: %{errors}'\n        restrict_dependent_destroy:\n          has_many: பதிவை நீக்க முடியாது, ஏனெனில் சார்புகள் %{record} உள்ளது\n          has_one: பதிவை நீக்க முடியாது, ஏனெனில் ஒரு சார்பு %{record} உள்ளது\n  date:\n    abbr_day_names:\n    - ஞாயிறு\n    - திங்கள்\n    - செவ்வாய்\n    - புதன்\n    - வியாழன்\n    - வெள்ளி\n    - சனி\n    abbr_month_names:\n    -\n    - ஜன\n    - பிப்\n    - மார்ச்\n    - ஏப்\n    - மே\n    - ஜூன்\n    - ஜூலை\n    - ஆக\n    - செப்\n    - அக்\n    - நவ\n    - டிச\n    day_names:\n    - ஞாயிற்றுக்கிழமை\n    - திங்கட்கிழமை\n    - செவ்வாய்க்கிழமை\n    - புதன்கிழமை\n    - வியாழக்கிழமை\n    - வெள்ளிக்கிழமை\n    - சனிக்கிழமை\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - ஜனவரி\n    - பிப்ரவரி\n    - மார்ச்\n    - ஏப்ரல்\n    - மே\n    - ஜூன்\n    - ஜூலை\n    - ஆகஸ்ட்\n    - செப்டம்பர்\n    - அக்டோபர்\n    - நவம்பர்\n    - டிசம்பர்\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: சுமார் %{count} மணி நேரம்\n        other: சுமார் %{count} மணி\n      about_x_months:\n        one: சுமார் %{count} மாதம்\n        other: சுமார் %{count} மாதங்களுக்கு\n      about_x_years:\n        one: சுமார் %{count}  ஆண்டு\n        other: சுமார் %{count}  ஆண்டுகள்\n      almost_x_years:\n        one: கிட்டத்தட்ட %{count}  ஆண்டு\n        other: கிட்டத்தட்ட %{count}  ஆண்டுகள்\n      half_a_minute: அரை நிமிடம்\n      less_than_x_minutes:\n        one: ஒரு நிமிடத்திற்கும் குறைவாக\n        other: குறைவாக %{count} நிமிடங்கள்\n      less_than_x_seconds:\n        one: ஒரு வினாடிக்கும் குறைவாக\n        other: குறைவாக %{count} வினாடிகள்\n      over_x_years:\n        one: ஒரு  ஆண்டிற்கு மேலாக\n        other: \"%{count}  ஆண்டிற்கு மேலாக\"\n      x_days:\n        one: \"%{count} நாள்\"\n        other: \"%{count} நாட்கள்\"\n      x_minutes:\n        one: \"%{count} நிமிடம்\"\n        other: \"%{count} நிமிடங்கள்\"\n      x_months:\n        one: \"%{count} மாதம்\"\n        other: \"%{count} மாதங்கள்\"\n      x_seconds:\n        one: \"%{count} வினாடி\"\n        other: \"%{count} விநாடிகள்\"\n    prompts:\n      day: நாள்\n      hour: மணி\n      minute: நிமிடம்\n      month: மாதம்\n      second: விநாடிகள்\n      year: ஆண்டு\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: ஏற்கப்பட வேண்டும்\n      blank: காலியாக இருக்க முடியாது\n      confirmation: \"%{attribute}  பொருந்தவில்லை\"\n      empty: வெறுமையாக இருக்க முடியாது\n      equal_to: \"%{count} சமமாக இருக்க வேண்டும்\"\n      even: இரட்டைப்படை இருக்க வேண்டும்\n      exclusion: ஒதுக்கப்பட்டுள்ளது\n      greater_than: \"%{count} ஐ விட அதிகமாக இருக்க வேண்டும்\"\n      greater_than_or_equal_to: \"%{count}  அதிகமாக அல்லது சமமாக இருக்க வேண்டும்\"\n      inclusion: பட்டியலில் சேர்க்கப்படவில்லை\n      invalid: செல்லுபடியானதல்ல\n      less_than: \"%{count} ஐ விட குறைவாக இருக்க வேண்டும்\"\n      less_than_or_equal_to: \"%{count} குறைவாக அல்லது சமமாக இருக்க வேண்டும்\"\n      not_a_number: ஒரு எண் அல்ல\n      not_an_integer: ஒரு முழு எண்ணாக இருக்க வேண்டும்\n      odd: ஒற்றைப்படை இருக்க வேண்டும்\n      other_than: \"%{count} தவிர வேறு இருக்க வேண்டும்\"\n      present: காலியாக இருக்க வேண்டும்\n      taken: ஏற்கனவே எடுத்துகொள்ள பட்டது\n      too_long:\n        one: மிக நீளமாக உள்ளது (அதிகபட்சமாக ஒரு எழுத்து)\n        other: மிக நீளமாக உள்ளது (அதிகபட்சமாக %{count} எழுத்துக்கள்)\n      too_short:\n        one: மிகவும் குறுகியதாக உள்ளது (குறைந்தபட்சம் ஒரு எழுத்து)\n        other: மிகவும் குறுகியதாக உள்ளது (குறைந்தபட்சம் %{count} எழுத்துக்கள்)\n      wrong_length:\n        one: தவறான நீளம் (%{count} எழுத்து இருக்கவேண்டும்)\n        other: தவறான நீளம் (%{count} எழுத்துக்கள் இருக்கவேண்டும்)\n    template:\n      body: 'பின்வரும் புலங்களில் பிரச்சினைகள் உள்ளது:'\n      header:\n        one: \"%{count} பிழை இந்த %{model} ஐ சேமிக்க தடையாக உள்ளது\"\n        other: \"%{count} பிழைகள் இந்த %{model} ஐ சேமிக்க தடையாக உள்ளது\"\n  helpers:\n    select:\n      prompt: தேர்வு செய்க\n    submit:\n      create: \"%{model} ஐ  உருவாக்கு\"\n      submit: \"%{model} ஐ சேமி\"\n      update: \"%{model} ஐ புதுப்பி\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"₹\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: பில்லியன்\n          million: மில்லியன்\n          quadrillion: குவாட்ரில்லியன்\n          thousand: ஆயிரம்\n          trillion: டிரில்லியன்\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", மற்றும் \"\n      two_words_connector: \" மற்றும் \"\n      words_connector: \", \"\n  time:\n    am: மு.ப.\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: பி.ப.\n"
  },
  {
    "path": "config/locales/defaults/te.yml",
    "content": "---\nte:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'ధ్రువీకరన విఫలమైనది: %{errors}'\n        restrict_dependent_destroy:\n          has_many: రికార్డ్ను తొలగించలేరు, ఎందుకంటే ఆధారపడిన %{record} ఉంది\n          has_one: రికార్డ్ను తొలగించలేరు, ఎందుకంటే ఒక ఆధారపడిన %{record} ఉంది\n  date:\n    abbr_day_names:\n    - ఆది\n    - సోమ\n    - మంగళ\n    - బుధ\n    - గురు\n    - శుక్ర\n    - శని\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - ఆదివారం\n    - సోమవారం\n    - మంగళవారం\n    - బుధవారం\n    - గురువారం\n    - శుక్రవారం\n    - శనివారం\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - జనవరి\n    - ఫిబ్రవరి\n    - మార్చి\n    - ఏప్రిల్\n    - మే\n    - జూన్\n    - జూలై\n    - ఆగస్టు\n    - సెప్టెంబరు\n    - అక్టోబరు\n    - నవంబరు\n    - డిసెంబరు\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: సుమారు ఒక గంట\n        other: సుమారు %{count} గంటలు\n      about_x_months:\n        one: సుమారు ఒక నెల\n        other: సుమారు %{count} నెలలు\n      about_x_years:\n        one: సుమారు ఒక సంవత్సరం\n        other: సుమారు %{count} సంవత్సరాలు\n      almost_x_years:\n        one: దాదాపు ఒక సంవత్సరం\n        other: దాదాపు %{count} సంవత్సరాలు\n      half_a_minute: అర నిమిషం\n      less_than_x_minutes:\n        one: ఒక నిమిషం కన్నా తక్కువ\n        other: \"%{count} నిమిషాల కన్నా తక్కువ\"\n      less_than_x_seconds:\n        one: ఒక క్షణం కన్నా తక్కువ\n        other: \"%{count} క్షణాలు కన్నా తక్కువ\"\n      over_x_years:\n        one: ఒక సంవత్సరం పైగా\n        other: \"%{count} సంవత్సరాల పైగా\"\n      x_days:\n        one: ఒక రోజు\n        other: \"%{count} రోజులు\"\n      x_minutes:\n        one: ఒక నిమిషం\n        other: \"%{count} నిమిషాలు\"\n      x_months:\n        one: ఒక నెల\n        other: \"%{count} నెలలు\"\n      x_seconds:\n        one: ఒక క్షణం\n        other: \"%{count} క్షణాలు\"\n      x_years:\n        one: ఒక సంవత్సరం\n        other: \"%{count} సంవత్సరాలు\"\n    prompts:\n      day: రోజు\n      hour: గంట\n      minute: నిమిషం\n      month: నెల\n      second: క్షణాలు\n      year: సంవత్సరం\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: అంగీకరించి ఉండాలి\n      blank: ఖాళీగా ఉండకూడదు\n      confirmation: \"%{attribute} సరిపోలడం లేదు\"\n      empty: శూన్యంగా ఉండకూడదు\n      equal_to: \"%{count}కు సమానంగా ఉండాలి\"\n      even: సరి సంఖ్య అయి ఉండాలి\n      exclusion: ప్రత్యేకించబడింది\n      greater_than: \"%{count} కంటే ఎక్కువ ఉండాలి\"\n      greater_than_or_equal_to: \"%{count} కంటే ఎక్కువ లేదా సమానంగా ఉండాలి\"\n      inclusion: జాబితాలో చేర్చబడలేదు\n      invalid: చెల్లనిది\n      less_than: \"%{count} కంటే తక్కువ ఉండాలి\"\n      less_than_or_equal_to: \"%{count} నాలుగు కంటే తక్కువగా లేదా సమానంగా ఉండాలి\"\n      model_invalid: 'ధృవీకరణ విఫలమైంది: %{errors}'\n      not_a_number: సంఖ్య కాదు\n      not_an_integer: పూర్ణాంకంగా ఉండాలి\n      odd: బేసి సంఖ్య అయి ఉండాలి\n      other_than: \"%{count} కన్నా వేరు ఉండాలి\"\n      present: ఖాళీగా ఉండాలి\n      required: ఉనికిలో ఉండాలి\n      taken: ఇప్పటికే తీసుకోబడింది\n      too_long:\n        one: చాలా పొడవుగా ఉంది (గరిష్టంగా ఒక అక్షరం)\n        other: చాలా పొడవుగా ఉంది (గరిష్టంగా %{count} అక్షరాలు)\n      too_short:\n        one: చాలా తక్కువగా ఉంది (కనీసం ఒక అక్షరం)\n        other: చాలా చిన్నది (కనీసం %{count} అక్షరాలు)\n      wrong_length:\n        one: తప్పు పొడవు (ఒక అక్షరం ఉండాలి)\n        other: తప్పు పొడవు (%{count} అక్షరాలు ఉండాలి)\n    template:\n      body: 'కింది రంగాలలో సమస్యలు ఉన్నాయి:'\n      header:\n        one: ఒక తప్పు ఈ %{model} ను ఆదా చెయ్యకుండా నిషేదించింది\n        other: \"%{count} తప్పులు ఈ %{model} ను ఆదా చెయ్యకుండా నిషేదించింది\"\n  helpers:\n    select:\n      prompt: దయచేసి ఎంచుకోండి\n    submit:\n      create: \"%{model} ను సృష్టించండి\"\n      submit: \"%{model} ను ఆదా చెయ్యండి\"\n      update: \"%{model} ను నవీకరించండి\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"₹\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: బిలియన్\n          million: మిలియన్\n          quadrillion: క్వాడ్రిలియన్\n          thousand: వెయ్యి\n          trillion: ట్రిలియన్\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: బైట్\n            other: బైట్లు\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", మరియు \"\n      two_words_connector: \" మరియు \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/th.yml",
    "content": "---\nth:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'ไม่ผ่านการตรวจสอบ: %{errors}'\n  date:\n    abbr_day_names:\n    - อา\n    - จ\n    - อ\n    - พ\n    - พฤ\n    - ศ\n    - ส\n    abbr_month_names:\n    -\n    - ม.ค.\n    - ก.พ.\n    - มี.ค.\n    - เม.ย.\n    - พ.ค.\n    - มิ.ย.\n    - ก.ค.\n    - ส.ค.\n    - ก.ย.\n    - ต.ค.\n    - พ.ย.\n    - ธ.ค.\n    day_names:\n    - อาทิตย์\n    - จันทร์\n    - อังคาร\n    - พุธ\n    - พฤหัสบดี\n    - ศุกร์\n    - เสาร์\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - มกราคม\n    - กุมภาพันธ์\n    - มีนาคม\n    - เมษายน\n    - พฤษภาคม\n    - มิถุนายน\n    - กรกฎาคม\n    - สิงหาคม\n    - กันยายน\n    - ตุลาคม\n    - พฤศจิกายน\n    - ธันวาคม\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours: ประมาณ %{count} ชั่วโมง\n      about_x_months: ประมาณ %{count} เดือน\n      about_x_years: ประมาณ %{count} ปี\n      almost_x_years: เกือบ %{count} ปี\n      half_a_minute: ครึ่งนาที\n      less_than_x_minutes: น้อยกว่า %{count} นาที\n      less_than_x_seconds: น้อยกว่า %{count} วินาที\n      over_x_years: มากกว่า %{count} ปี\n      x_days: \"%{count} วัน\"\n      x_minutes: \"%{count} นาที\"\n      x_months: \"%{count} เดือน\"\n      x_seconds: \"%{count} วินาที\"\n    prompts:\n      day: วัน\n      hour: ชั่วโมง\n      minute: นาที\n      month: เดือน\n      second: วินาที\n      year: ปี\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: ต้องถูกยอมรับ\n      blank: ต้องไม่เว้นว่างเอาไว้\n      confirmation: ไม่ตรงกับการยืนยัน\n      empty: ต้องไม่เว้นว่างเอาไว้\n      equal_to: ต้องมีค่าเท่ากับ %{count}\n      even: ต้องเป็นจำนวนคู่\n      exclusion: ไม่ได้รับอนุญาตให้ใช้\n      greater_than: ต้องมากกว่า %{count}\n      greater_than_or_equal_to: ต้องมากกว่าหรือเท่ากับ %{count}\n      inclusion: ไม่ได้อยู่ในรายการ\n      invalid: ไม่ถูกต้อง\n      less_than: ต้องมีค่าน้อยกว่า %{count}\n      less_than_or_equal_to: ต้องมีค่าน้อยกว่าหรือเท่ากับ %{count}\n      not_a_number: ไม่ใช่ตัวเลข\n      not_an_integer: ไม่ใช่จำนวนเต็ม\n      odd: ต้องเป็นจำนวนคี่\n      taken: ถูกใช้ไปแล้ว\n      too_long: ยาวเกินไป (ต้องไม่เกิน %{count} ตัวอักษร)\n      too_short: สั้นเกินไป (ต้องยาวกว่า %{count} ตัวอักษร)\n      wrong_length: มีความยาวไม่ถูกต้อง (ต้องมีความยาว %{count} ตัวอักษร)\n    template:\n      body: 'โปรดตรวจสอบข้อมูลในช่องต่อไปนี้:'\n      header: พบข้อผิดพลาด %{count} ประการ ทำให้ไม่สามารถบันทึก%{model}ได้\n  helpers:\n    select:\n      prompt: โปรดเลือก\n    submit:\n      create: สร้าง%{model}\n      submit: บันทึก%{model}\n      update: ปรับปรุง%{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: บาท\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: พันล้าน\n          million: ล้าน\n          quadrillion: พันล้านล้าน\n          thousand: พัน\n          trillion: ล้านล้าน\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte: ไบต์\n          gb: จิกะไบต์\n          kb: กิโลไบต์\n          mb: เมกะไบต์\n          tb: เทระไบต์\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", และ \"\n      two_words_connector: \" และ \"\n      words_connector: \", \"\n  time:\n    am: ก่อนเที่ยง\n    formats:\n      default: \"%a %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B %Y %H:%M น.\"\n      short: \"%d %b %H:%M น.\"\n    pm: หลังเที่ยง\n"
  },
  {
    "path": "config/locales/defaults/tl.yml",
    "content": "---\ntl:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Nabigo ang pagpapatunay: %{errors}'\n  date:\n    abbr_day_names:\n    - Lin\n    - Lun\n    - Mar\n    - Miy\n    - Huw\n    - Biy\n    - Sab\n    abbr_month_names:\n    -\n    - Ene\n    - Peb\n    - Mar\n    - Abr\n    - May\n    - Hun\n    - Hul\n    - Ago\n    - Set\n    - Okt\n    - Nob\n    - Dis\n    day_names:\n    - Linggo\n    - Lunes\n    - Martes\n    - Miyerkules\n    - Huwebes\n    - Biyernes\n    - Sabado\n    formats:\n      default: \"%d/%m/%Y\"\n      long: ika-%d ng %B, %Y\n      short: ika-%d ng %b\n    month_names:\n    -\n    - Enero\n    - Pebrero\n    - Marso\n    - Abril\n    - Mayo\n    - Hunyo\n    - Hulyo\n    - Agosto\n    - Setyembre\n    - Oktubre\n    - Nobyembre\n    - Disyembre\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: humigit-kumulang isang oras\n        other: humigit-kumulang %{count} oras\n      about_x_months:\n        one: humigit-kumulang isang buwan\n        other: humigit-kumulang %{count} buwan\n      about_x_years:\n        one: humigit-kumulang isang taon\n        other: humigit-kumulang %{count} taon\n      almost_x_years:\n        one: halos isang taon\n        other: halos %{count} taon\n      half_a_minute: kalahating minuto\n      less_than_x_minutes:\n        one: mas mababa sa isang minuto\n        other: mas mababa sa %{count} minuto\n      less_than_x_seconds:\n        one: mas mababa sa isang segundo\n        other: mas mababa sa %{count} segundo\n      over_x_years:\n        one: higit sa isang taon\n        other: higit %{count} taon\n      x_days:\n        one: isang araw\n        other: \"%{count} araw\"\n      x_minutes:\n        one: isang minuto\n        other: \"%{count} minuto\"\n      x_months:\n        one: isang buwan\n        other: \"%{count} buwan\"\n      x_seconds:\n        one: isang segundo\n        other: \"%{count} segundo\"\n    prompts:\n      day: araw\n      hour: oras\n      minute: minuto\n      month: buwan\n      second: segundo\n      year: taon\n  errors:\n    format: \"%{attribute} ay %{message}\"\n    messages:\n      accepted: dapat na tanggapin\n      blank: hindi maaaring walang laman\n      confirmation: hindi tumutugma ang pagpapatunay\n      empty: hindi maaaring walang laman\n      equal_to: dapat na katumba sa %{count}\n      even: dapat maging even\n      exclusion: nakalaan na\n      greater_than: dapat na mas higit sa %{count}\n      greater_than_or_equal_to: dapat na mas higit sa o katumbas ng %{count}\n      inclusion: hindi kasama sa listahan\n      invalid: hindi wasto\n      less_than: dapat na mas mababa sa %{count}\n      less_than_or_equal_to: dapat na mas mababa sa o katumbas ng %{count}\n      not_a_number: hindi isang numero\n      not_an_integer: dapat na isang integer\n      odd: dapat maging odd\n      taken: ginagamit na\n      too_long:\n        one: masyadong mahaba (pinakamadami ay %{count} character)\n        other: masyadong mahaba (pinakamadami ay %{count} character)\n      too_short:\n        one: masyadong maikli (pinakakonti ay %{count} character)\n        other: masyadong maikli (pinakakonti ay %{count} character)\n      wrong_length:\n        one: ang maling haba (ito ay dapat eksaktong %{count} character)\n        other: ang maling haba (ito ay dapat eksaktong %{count} character)\n    template:\n      body: 'May mga problema sa mga sumusunod na patlang:'\n      header:\n        one: hindi maaaring i-save ang %{model} na ito dahil sa isang error\n        other: hindi maaaring i-save ang %{model} na ito dahil sa %{count} error\n  helpers:\n    select:\n      prompt: Mangyaring pumili\n    submit:\n      create: lumikha ng %{model}\n      submit: isumite ang %{model}\n      update: i-update ang %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"₱\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: bilyon\n          million: milyon\n          quadrillion: kuwadrilyon\n          thousand: libo\n          trillion: trilyon\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", at\"\n      two_words_connector: \" at \"\n      words_connector: \",\"\n  time:\n    am: AM\n    formats:\n      default: \"%A, ika-%d ng %B ng %Y %H:%M:%S %z\"\n      long: ika-%d ng %B ng %Y %H:%M\n      short: \"%d ng %b %H:%M\"\n    pm: PM\n"
  },
  {
    "path": "config/locales/defaults/tr.yml",
    "content": "---\ntr:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Doğrulama başarısız oldu: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Bağlı kayıtlar %{record} bulunduğu için kayıt silinemedi\n          has_one: Bağlı bir kayıt %{record} bulunduğu için kayıt silinemedi\n  date:\n    abbr_day_names:\n    - Pzr\n    - Pzt\n    - Sal\n    - Çrş\n    - Prş\n    - Cum\n    - Cts\n    abbr_month_names:\n    -\n    - Oca\n    - Şub\n    - Mar\n    - Nis\n    - May\n    - Haz\n    - Tem\n    - Ağu\n    - Eyl\n    - Eki\n    - Kas\n    - Ara\n    day_names:\n    - Pazar\n    - Pazartesi\n    - Salı\n    - Çarşamba\n    - Perşembe\n    - Cuma\n    - Cumartesi\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%e %B %Y %A\"\n      short: \"%e %b\"\n    month_names:\n    -\n    - Ocak\n    - Şubat\n    - Mart\n    - Nisan\n    - Mayıs\n    - Haziran\n    - Temmuz\n    - Ağustos\n    - Eylül\n    - Ekim\n    - Kasım\n    - Aralık\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: yaklaşık %{count} saat\n        other: yaklaşık %{count} saat\n      about_x_months:\n        one: yaklaşık %{count} ay\n        other: yaklaşık %{count} ay\n      about_x_years:\n        one: yaklaşık %{count} yıl\n        other: yaklaşık %{count} yıl\n      almost_x_years:\n        one: neredeyse %{count} yıl\n        other: neredeyse %{count} yıl\n      half_a_minute: yarım dakika\n      less_than_x_minutes:\n        one: \"%{count} dakikadan az\"\n        other: \"%{count} dakikadan az\"\n      less_than_x_seconds:\n        one: \"%{count} saniyeden az\"\n        other: \"%{count} saniyeden az\"\n      over_x_years:\n        one: \"%{count} yıldan fazla\"\n        other: \"%{count} yıldan fazla\"\n      x_days:\n        one: \"%{count} gün\"\n        other: \"%{count} gün\"\n      x_minutes:\n        one: \"%{count} dakika\"\n        other: \"%{count} dakika\"\n      x_months:\n        one: \"%{count} ay\"\n        other: \"%{count} ay\"\n      x_seconds:\n        one: \"%{count} saniye\"\n        other: \"%{count} saniye\"\n      x_years:\n        one: \"%{count} yıl\"\n        other: \"%{count} yıl\"\n    prompts:\n      day: Gün\n      hour: Saat\n      minute: Dakika\n      month: Ay\n      second: Saniye\n      year: Yıl\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: kabul edilmeli\n      blank: doldurulmalı\n      confirmation: \"%{attribute} teyidiyle uyuşmuyor\"\n      empty: doldurulmalı\n      equal_to: tam olarak %{count} olmalı\n      even: çift olmalı\n      exclusion: kullanılamaz\n      greater_than: \"%{count} sayısından büyük olmalı\"\n      greater_than_or_equal_to: \"%{count} sayısına eşit veya büyük olmalı\"\n      inclusion: kabul edilen bir kelime değil\n      invalid: geçersiz\n      less_than: \"%{count} sayısından küçük olmalı\"\n      less_than_or_equal_to: \"%{count} sayısına eşit veya küçük olmalı\"\n      model_invalid: 'Doğrulama başarısız oldu: %{errors}'\n      not_a_number: geçerli bir sayı değil\n      not_an_integer: tam sayı olmalı\n      odd: tek olmalı\n      other_than: \"%{count} karakterden oluşamaz\"\n      present: boş bırakılmalı\n      required: doldurulmalı\n      taken: hali hazırda kullanılmakta\n      too_long:\n        one: çok uzun (en fazla %{count} karakter)\n        other: çok uzun (en fazla %{count} karakter)\n      too_short:\n        one: çok kısa (en az %{count} karakter)\n        other: çok kısa (en az %{count} karakter)\n      wrong_length:\n        one: hatalı uzunlukta (%{count} karakter olmalı)\n        other: hatalı uzunlukta (%{count} karakter olmalı)\n    template:\n      body: 'Lütfen aşağıdaki hataları düzeltiniz:'\n      header:\n        one: \"%{count} hata oluştuğu için %{model} kaydedilemedi\"\n        other: \"%{count} hata oluştuğu için %{model} kaydedilemedi\"\n  helpers:\n    select:\n      prompt: Lütfen seçiniz\n    submit:\n      create: \"%{model} Ekle\"\n      submit: \"%{model} Kaydet\"\n      update: \"%{model} Güncelle\"\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"₺\"\n    format:\n      delimiter: \".\"\n      precision: 2\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Milyar\n          million: Milyon\n          quadrillion: Katrilyon\n          thousand: Bin\n          trillion: Trilyon\n          unit: ''\n      format:\n        delimiter: \".\"\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Bayt\n            other: Bayt\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%%n\"\n    precision:\n      format:\n        delimiter: \".\"\n  support:\n    array:\n      last_word_connector: \" ve \"\n      two_words_connector: \" ve \"\n      words_connector: \", \"\n  time:\n    am: öğleden önce\n    formats:\n      default: \"%a %d.%b.%y %H:%M\"\n      long: \"%e %B %Y, %A, %H:%M\"\n      short: \"%e %B, %H:%M\"\n    pm: öğleden sonra\n"
  },
  {
    "path": "config/locales/defaults/tt.yml",
    "content": "---\ntt:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Хаталар чыкты: %{errors}'\n        restrict_dependent_destroy:\n          has_many: 'Язмышны бетереп булмады, чөнки бәйлелекләр табылды: %{record}'\n          has_one: 'Язмышны бетереп булмады, чөнки бәйлелек табылды: %{record}'\n  date:\n    abbr_day_names:\n    - Якш\n    - Дүш\n    - Сиш\n    - Чәр\n    - Пән\n    - Җом\n    - Шим\n    abbr_month_names:\n    -\n    - гыйн.\n    - февр.\n    - март\n    - апр.\n    - май\n    - июнь\n    - июль\n    - авг.\n    - сент.\n    - окт.\n    - нояб.\n    - дек.\n    day_names:\n    - якшәмбе\n    - дүшәмбе\n    - сишәмбе\n    - чәршәмбе\n    - пәнҗешәмбе\n    - җомга\n    - шимбә\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%-d %B %Y\"\n      short: \"%-d %b\"\n    month_names:\n    -\n    - гыйнвар\n    - февраль\n    - март\n    - апрель\n    - май\n    - июнь\n    - июль\n    - август\n    - сентябрь\n    - октябрь\n    - ноябрь\n    - декабрь\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: бер сәгать чамасы\n        other: \"%{count} сәгать чамасы\"\n      about_x_months:\n        one: бер ай чамасы\n        other: \"%{count} ай чамасы\"\n      about_x_years:\n        one: бер ел чамасы\n        other: \"%{count} ел чамасы\"\n      almost_x_years:\n        one: бер ел диярлек\n        other: \"%{count} ел диярлек\"\n      half_a_minute: минуттан азрак\n      less_than_x_minutes:\n        one: бер минуттан азрак\n        other: \"%{count} минуттан азрак\"\n      less_than_x_seconds:\n        one: бер секундтан азрак\n        other: \"%{count} секундтан азрак\"\n      over_x_years:\n        one: бер ел артык\n        other: \"%{count} ел артык\"\n      x_days:\n        one: бер көн\n        other: \"%{count} көн\"\n      x_minutes:\n        one: бер минут\n        other: \"%{count} минут\"\n      x_months:\n        one: бер ай\n        other: \"%{count} ай\"\n      x_seconds:\n        one: бер секунд\n        other: \"%{count} секунд\"\n    prompts:\n      day: Көн\n      hour: Сәгать\n      minute: Минут\n      month: Ай\n      second: Секунд\n      year: Ел\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: раслау кирәк\n      blank: буш була алмый\n      confirmation: \"%{attribute} читнең мәгнәсе белән тигез түгел\"\n      empty: буш була алмый\n      equal_to: мәгнәсе %{count} була гына ала\n      even: так кына була ала\n      exclusion: резервта саклау дигән әһәмияте бар\n      greater_than: \"%{count} мәгнәсеннән зур була ала\"\n      greater_than_or_equal_to: \"%{count} мәгнәсеннән зур яки тигез була ала\"\n      inclusion: мәгнәсе алдан исәпкә алынган түгел\n      invalid: мәгнәсе ялгыш\n      less_than: \"%{count} мәгнәсеннән азрак була ала\"\n      less_than_or_equal_to: \"%{count} мәгнәсеннән азрак яки тигез була ала\"\n      not_a_number: сан тугел\n      not_an_integer: бөтен сан түгел\n      odd: җөп кенә була ала\n      other_than: \"%{count} мәгнәсеннән икенче булырга тиеш\"\n      present: буш булырга тиеш\n      taken: бар инде\n      too_long:\n        one: бигрәк озын (озынлыгы бердән озынрак була алмый)\n        other: бигрәк озын (озынлыгы %{count} мәгнәсеннән озынрак була алмый)\n      too_short:\n        one: бигрәк кыска (озынлыгы бердән кыскарак була алмый)\n        other: бигрәк кыска (озынлыгы %{count} мәгнәсеннән кыскарак була алмый)\n      wrong_length:\n        one: озынлыгы ялгыш (озынлыгы бергә тигез булырга тиеш)\n        other: озынлыгы ялгыш (озынлыгы %{count} мәгнәгә тигез булырга тиеш)\n    template:\n      body: \":\"\n      header:\n        one: \"%{model}: %{count} хата аркасында саклау чыкмады\"\n        other: \"%{model}: %{count} хата аркасында саклау чыкмады\"\n  helpers:\n    select:\n      prompt: 'Сайлагыз: '\n    submit:\n      create: \"%{model} иҗат итергә\"\n      submit: \"%{model} саклап калырга\"\n      update: \"%{model} саклап калырга\"\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: сум\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            one: миллиард\n            other: миллиард\n          million:\n            one: миллион\n            other: миллион\n          quadrillion:\n            one: квадриллион\n            other: квадриллион\n          thousand:\n            one: мең\n            other: мең\n          trillion:\n            one: триллион\n            other: триллион\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: байт\n            other: байт\n          gb: ГБ\n          kb: КБ\n          mb: МБ\n          tb: ТБ\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" һәм \"\n      two_words_connector: \" һәм \"\n      words_connector: \", \"\n  time:\n    am: иртәнге\n    formats:\n      default: \"%a, %d %b %Y, %H:%M:%S %z\"\n      long: \"%d %B %Y, %H:%M\"\n      short: \"%d %b, %H:%M\"\n    pm: кичке\n"
  },
  {
    "path": "config/locales/defaults/ug.yml",
    "content": "---\nug:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: \"%{errors} دا خاتالىق بار\"\n        restrict_dependent_destroy:\n          has_many: \"%{record} بۇ مەلۇماتنى ئىشلىتىدۇ، شۇڭا ئۆچۈرگىلى بولمايدۇ\"\n          has_one: \"%{record} بۇ مەلۇماتنى ئىشلىتىدۇ، شۇڭا ئۆچۈرگىلى بولمايدۇ\"\n  date:\n    abbr_day_names:\n    - يەكشەنبە\n    - دۈشەنبە\n    - سەيشەنبە\n    - چارشەنبە\n    - پەيشەنبە\n    - جۈمە\n    - شەنبە\n    abbr_month_names:\n    -\n    - يانۋار\n    - فېۋرال\n    - مارت\n    - ئاپرېل\n    - ماي\n    - ئىيۇن\n    - ئىيۇل\n    - ئاۋغۇست\n    - سېنتەبىر\n    - ئۆكتەبىر\n    - نويابىر\n    - دېكابىر\n    day_names:\n    - يەكشەنبە\n    - دۈشەنبە\n    - سەيشەنبە\n    - چارشەنبە\n    - پەيشەنبە\n    - جۈمە\n    - شەنبە\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%Y-%m-%d\"\n      short: \"%b%d\"\n    month_names:\n    -\n    - يانۋار\n    - فېۋرال\n    - مارت\n    - ئاپرېل\n    - ماي\n    - ئىيۇن\n    - ئىيۇل\n    - ئاۋغۇست\n    - سېنتەبىر\n    - ئۆكتەبىر\n    - نويابىر\n    - دېكابىر\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: تەخمىنەن بىر سائەت\n        other: تەخمىنەن %{count} سائەت\n      about_x_months:\n        one: تەخمىنەن بىر ئاي\n        other: تەخمىنەن %{count} ئاي\n      about_x_years:\n        one: تەخمىنەن بىر يىل\n        other: تەخمىنەن %{count} يىل\n      almost_x_years:\n        one: بىر يىلغا يېقىن\n        other: \"%{count} يىلغا يېقىن\"\n      half_a_minute: يېرىم مىنۇت\n      less_than_x_minutes:\n        one: بىر مىنۇتقا يەتمىگەن\n        other: \"%{count} مىنۇتقا يەتمىگەن\"\n      less_than_x_seconds:\n        one: بىر سىكۇنتقا يەتمىگەن\n        other: \"%{count} سىكنۇتقا\"\n      over_x_years:\n        one: بىر يىلدىن ئارتۇق\n        other: \"%{count} يىلدىن ئارتۇق\"\n      x_days:\n        one: بىر كۈن\n        other: \"%{count} كۈن\"\n      x_minutes:\n        one: بىر مىنۇت\n        other: \"%{count} مىنۇت\"\n      x_months:\n        one: بىر ئاي\n        other: \"%{count} ئاي\"\n      x_seconds:\n        one: بىر سىكنۇت\n        other: \"%{count} سىكنۇت\"\n    prompts:\n      day: كۈن\n      hour: سائەت\n      minute: مىنۇت\n      month: ئاي\n      second: سىكنۇت\n      year: يىل\n  errors:\n    format: \"%{attribute}%{message}\"\n    messages:\n      accepted: تەستىقلاش كېرەك\n      blank: بوش بولماسلىقى كېرەك\n      confirmation: تەستىق بىلەن ماس ئەمەس\n      empty: بوش بولماسلىقى كېرەك\n      equal_to: \"%{count} غا تەڭ\"\n      even: جۈپ سان\n      exclusion: مۇمكىن ئەمەس\n      greater_than: \"%{count} دىن چوڭ\"\n      greater_than_or_equal_to: \"%{count} دىن چوڭ ياكى تەڭ\"\n      inclusion: كۈتىلمىگەن نەتىجە\n      invalid: ئىناۋەتسىز\n      less_than: \"%{count} دىن كىچىك\"\n      less_than_or_equal_to: \"%{count} دىن كىچىك ياكى تەڭ\"\n      not_a_number: سان ئەمەس\n      not_an_integer: پۈتۈن سان ئەمەس\n      odd: تاق سان\n      other_than: ئىناۋەتسىز ئۇزۇنلۇق (%{count} ھەرىپ بولماسلىقى كېرەك)\n      present: بوش بولىشى كېرەك\n      taken: ئىشلىتىپ بولۇنغان\n      too_long:\n        one: بەك ئۇزۇن (بىر ھەرىپ)\n        other: بەك ئۇزۇن (ئەڭ ئۇزۇن بولغاندا %{count} ھەرىپ)\n      too_short:\n        one: بەك قىسقا (بىر ھەرىپ)\n        other: بەك قىسقا (ئەڭ قىسقا بولغاندا %{count} ھەرىپ)\n      wrong_length:\n        one: ئىناۋەتسىز ئۇزۇنلۇق (بىر ھەرىپ)\n        other: ئىناۋەتسىز ئۇزۇنلۇق (چوقۇم %{count} ھەرىپ)\n    template:\n      body: تۆۋەندىكى سۆز بۆلەكلىرىدە خاتالىق بار\n      header:\n        one: بىر خاتالىق تۈپەيلىدىن 「%{model}」 ساقلاش مەغلۇب بولدى\n        other: \"%{count} خاتالىق تۈپەيلىدىن 「%{model}」ساقلاش مەغلۇب بولدى\"\n  helpers:\n    select:\n      prompt: تاللاڭ\n    submit:\n      create: \"%{model} قوشۇش\"\n      submit: \"%{model} ساقلاش\"\n      update: \"%{model} يېڭىلاش\"\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u %n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: CN¥\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: مىليارد\n          million: مىليون\n          quadrillion: گېگابايت\n          thousand: مىڭ\n          trillion: مېگا\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: بايت\n            other: بايت\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" ۋە \"\n      two_words_connector: \" ۋە \"\n      words_connector: \", \"\n  time:\n    am: چۈشتىن بۇرۇن\n    formats:\n      default: \"%Y %b %d %A %H:%M:%S %Z\"\n      long: \"%Y %b %d %H:%M\"\n      short: \"%b %d %H:%M\"\n    pm: چۈشتىن كېيىن\n"
  },
  {
    "path": "config/locales/defaults/uk.yml",
    "content": "---\nuk:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Виникли помилки: %{errors}'\n        restrict_dependent_destroy:\n          has_many: 'Неможливо видалити запис, так як існують залежності: %{record}'\n          has_one: 'Неможливо видалити запис, так як існує залежність: %{record}'\n  date:\n    abbr_day_names:\n    - нд.\n    - пн.\n    - вт.\n    - ср.\n    - чт.\n    - пт.\n    - сб.\n    abbr_month_names:\n    -\n    - січ.\n    - лют.\n    - бер.\n    - квіт.\n    - трав.\n    - черв.\n    - лип.\n    - серп.\n    - вер.\n    - жовт.\n    - лист.\n    - груд.\n    day_names:\n    - неділя\n    - понеділок\n    - вівторок\n    - середа\n    - четвер\n    - п'ятниця\n    - субота\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Січня\n    - Лютого\n    - Березня\n    - Квітня\n    - Травня\n    - Червня\n    - Липня\n    - Серпня\n    - Вересня\n    - Жовтня\n    - Листопада\n    - Грудня\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: близько %{count} годин\n        many: близько %{count} годин\n        one: близько %{count} години\n        other: близько %{count} години\n      about_x_months:\n        few: близько %{count} місяців\n        many: близько %{count} місяців\n        one: близько %{count} місяця\n        other: близько %{count} місяця\n      about_x_years:\n        few: близько %{count} років\n        many: близько %{count} років\n        one: близько %{count} року\n        other: близько %{count} року\n      almost_x_years:\n        few: майже %{count} роки\n        many: майже %{count} років\n        one: майже %{count} рік\n        other: майже %{count} років\n      half_a_minute: пів хвилини\n      less_than_x_minutes:\n        few: менше %{count} хвилин\n        many: менше %{count} хвилин\n        one: менше %{count} хвилини\n        other: менше %{count} хвилини\n      less_than_x_seconds:\n        few: менше %{count} секунд\n        many: менше %{count} секунд\n        one: менше %{count} секунди\n        other: менше %{count} секунди\n      over_x_years:\n        few: більше %{count} років\n        many: більше %{count} років\n        one: більше %{count} року\n        other: більше %{count} року\n      x_days:\n        few: \"%{count} дні\"\n        many: \"%{count} днів\"\n        one: \"%{count} день\"\n        other: \"%{count} дня\"\n      x_minutes:\n        few: \"%{count} хвилини\"\n        many: \"%{count} хвилин\"\n        one: \"%{count} хвилина\"\n        other: \"%{count} хвилини\"\n      x_months:\n        few: \"%{count} місяці\"\n        many: \"%{count} місяців\"\n        one: \"%{count} місяць\"\n        other: \"%{count} місяця\"\n      x_seconds:\n        few: \"%{count} секунди\"\n        many: \"%{count} секунд\"\n        one: \"%{count} секунда\"\n        other: \"%{count} секунди\"\n      x_years:\n        few: \"%{count} роки\"\n        many: \"%{count} років\"\n        one: \"%{count} рік\"\n        other: \"%{count} року\"\n    prompts:\n      day: День\n      hour: Година\n      minute: Хвилина\n      month: Місяць\n      second: Секунда\n      year: Рік\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: має бути прийнятий\n      blank: не може бути пустим\n      confirmation: не збігається з підтвердженням\n      empty: не може бути порожнім\n      equal_to: має дорівнювати %{count}\n      even: має бути парним\n      exclusion: зарезервовано\n      greater_than: має бути більше ніж %{count}\n      greater_than_or_equal_to: має бути більше ніж або дорівнювати %{count}\n      inclusion: не включено до переліку\n      invalid: недійсний\n      less_than: має бути менше ніж %{count}\n      less_than_or_equal_to: має бути менше ніж або дорівнювати %{count}\n      model_invalid: 'Виникли помилки: %{errors}'\n      not_a_number: не число\n      not_an_integer: не є цілим числом\n      odd: має бути непарним\n      other_than: має відрізнятись від %{count}\n      present: має бути пустим\n      required: не може бути порожнім\n      taken: вже зайнятий\n      too_long:\n        few: занадто довгий (максимум %{count} знаки)\n        many: занадто довгий (максимум %{count} знаків)\n        one: занадто довгий (максимум %{count} знак)\n        other: занадто довгий (максимум %{count} знаку)\n      too_short:\n        few: занадто короткий (мінімум %{count} знаки)\n        many: занадто короткий (мінімум %{count} знаків)\n        one: занадто короткий (мінімум %{count} знак)\n        other: занадто короткий (мінімум %{count} знаку)\n      wrong_length:\n        few: неправильна довжина (має бути %{count} знаки)\n        many: неправильна довжина (має бути %{count} знаків)\n        one: неправильна довжина (має бути %{count} знак)\n        other: неправильна довжина (має бути %{count} знаку)\n    template:\n      body: 'Помилки виявлено в таких полях:'\n      header:\n        few: \"%{model} не збережено через %{count} помилки\"\n        many: \"%{model} не збережено через %{count} помилок\"\n        one: \"%{model} не збережено через %{count} помилку\"\n        other: \"%{model} не збережено через %{count} помилки\"\n  helpers:\n    select:\n      prompt: 'Оберіть: '\n    submit:\n      create: Створити %{model}\n      submit: Зберегти %{model}\n      update: Зберегти %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"₴\"\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            few: Мільярдів\n            many: Мільярдів\n            one: Мільярд\n            other: Мільярдів\n          million:\n            few: Мільйонів\n            many: Мільйонів\n            one: Мільйон\n            other: Мільйонів\n          quadrillion:\n            few: Квадрильйонів\n            many: Квадрильйонів\n            one: Квадрильйон\n            other: Квадрильйонів\n          thousand:\n            few: Тисяч\n            many: Тисяч\n            one: Тисяча\n            other: Тисяч\n          trillion:\n            few: Трильйонів\n            many: Трильйонів\n            one: Трильйон\n            other: Трильйонів\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: байти\n            many: байтів\n            one: байт\n            other: байту\n          gb: ГБ\n          kb: кБ\n          mb: МБ\n          tb: ТБ\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" та \"\n      two_words_connector: \" і \"\n      words_connector: \", \"\n  time:\n    am: до полудня\n    formats:\n      default: \"%a, %d %b %Y, %H:%M:%S %z\"\n      long: \"%d %B %Y, %H:%M\"\n      short: \"%d %b, %H:%M\"\n    pm: по полудні\n"
  },
  {
    "path": "config/locales/defaults/ur.yml",
    "content": "---\nur:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'توثیق میں نا کا می: %{errors}'\n        restrict_dependent_destroy:\n          has_many: چند منحصر %{record}  کی موجودگی کے باعث اس ریکارڈ کو حذف نہیں\n            کیا جا سکتا\n          has_one: ایک منحصر %{record} کی موجودگی کے باعث اس ریکارڈ کو حذف نہیں کیا\n            جا سکتا\n  date:\n    abbr_day_names:\n    - اتوار\n    - سوموار\n    - منگل\n    - بدھ\n    - جمعرات\n    - جمعہ\n    - ہفتہ\n    abbr_month_names:\n    -\n    - جنوری\n    - فروری\n    - مارچ\n    - اپریل\n    - مئی\n    - جون\n    - جولائی\n    - اگست\n    - ستمبر\n    - اکتوبر\n    - نومبر\n    - دسمبر\n    day_names:\n    - اتوار\n    - سوموار\n    - منگل\n    - بدھ\n    - جمعرات\n    - جمعہ\n    - ہفتہ\n    formats:\n      default: \"%d %B %Y\"\n      long: \"%d %B، %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - جنوری\n    - فروری\n    - مارچ\n    - اپریل\n    - مئی\n    - جون\n    - جولائی\n    - اگست\n    - ستمبر\n    - اکتوبر\n    - نومبر\n    - دسمبر\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: تقریبا ایک گھنٹہ\n        other: تقریبا %{count} گھنٹے\n      about_x_months:\n        one: تقریبا ایک مہینہ\n        other: تقریبا %{count} مہینہ\n      about_x_years:\n        one: تقریبا ایک سال\n        other: تقریبا %{count} سال\n      almost_x_years:\n        one: تقریبا ایک سال\n        other: تقریبا %{count} سال\n      half_a_minute: آدھا منٹ\n      less_than_x_minutes:\n        one: ایک مںٹ سے کم\n        other: \"%{count} مںٹوں سے کم\"\n      less_than_x_seconds:\n        one: ایک سیکنڈ سے کم\n        other: \"%{count} سیکنڈوں سے کم\"\n      over_x_years:\n        one: ایک سال سے زیادہ\n        other: \"%{count} سالوں سے زیادہ\"\n      x_days:\n        one: ایک دن\n        other: \"%{count} دن\"\n      x_minutes:\n        one: ایک منٹ\n        other: \"%{count} منٹ\"\n      x_months:\n        one: ایک ماہ\n        other: \"%{count} ماہ\"\n      x_seconds:\n        one: ایک سیکنڈ\n        other: \"%{count} سیکنڈ\"\n    prompts:\n      day: دن\n      hour: گھنٹہ\n      minute: منٹ\n      month: ماہ\n      second: سیکنڈ\n      year: سال\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: قبول ہونا ضروری ہے\n      blank: لازم ہے\n      confirmation: \"%{attribute} میل نہیں رکھتا\"\n      empty: لازم ہے\n      equal_to: \"%{count} کے برابر ہونا چاہیے\"\n      even: جفت ہونا ضروری ہے\n      exclusion: مخصوص ہے\n      greater_than: \"%{count} سے زیادہ ہونا چاہیے\"\n      greater_than_or_equal_to: \"%{count} سے بڑا یا برابر ہونا چاہیے\"\n      inclusion: فہرست میں شامل نہیں ہے\n      invalid: باطل ہے\n      less_than: \"%{count} سے کم ہونا چاہیے\"\n      less_than_or_equal_to: \"%{count} سے کم یا اس کے برابر ہونا چاہیے\"\n      not_a_number: ایک نمبر نہیں ہے\n      not_an_integer: ایک عدد صحیح ہونا ضروری ہے\n      odd: طاق ہونا ضروری ہے\n      other_than: \"%{count} کے علاوہ کسی اور کا ہونا لازمی ہے\"\n      present: خالی ہونا ضروری ہے\n      taken: پہلے سے ہی استعمال میں ہے\n      too_long:\n        one: بہت طویل ہے (زیادہ سے زیادہ ایک حرف)\n        other: iبہت طویل ہے (زیادہ سے زیادہ %{count} حروف)\n      too_short:\n        one: بہت چھوٹا ہے (کم از کم اکی حرف)\n        other: بہت چھوٹا ہے (کم از کم %{count} حروف)\n      wrong_length:\n        one: غلط طوالت (ایک حرف ہونا چاہئے)\n        other: غلط طوالت (%{count} حروف ہونے چاہئے)\n    template:\n      body: 'مندرجہ ذیل متن کے ساتھ مسائل ہیں:'\n      header:\n        one: ایک خرابی کے باعث یہ %{model} محفوظ نہیں کیا جا سکا\n        other: \"%{count} خرابیوں کے باعث یہ %{model} محفوظ نہیں کیا جا سکا\"\n  helpers:\n    select:\n      prompt: منتخب کیجیے\n    submit:\n      create: \"%{model} تشکیل دیں\"\n      submit: \"%{model} محفوظ کریں\"\n      update: اپ ڈیٹ %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: Rs\n    format:\n      delimiter: \",\"\n      precision: 0\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: بلین\n          million: ملین\n          quadrillion: کواڈریلن\n          thousand: ہزار\n          trillion: ٹریلن\n          unit: Rs\n      format:\n        delimiter: ''\n        precision: 0\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n فیصد\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \"، اور \"\n      two_words_connector: \" اور \"\n      words_connector: \"، \"\n  time:\n    am: صبح\n    formats:\n      default: \"%a، %d %b %Y، %p %l:%M %Z\"\n      long: \"%d %B، %Y %p %H:%M\"\n      short: \"%d %b، %H:%M\"\n    pm: شام\n"
  },
  {
    "path": "config/locales/defaults/uz.yml",
    "content": "---\nuz:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: \"%{errors}ta hato bor.\"\n  date:\n    abbr_day_names:\n    - Ya\n    - Du\n    - Sh\n    - Ch\n    - Pa\n    - Ju\n    - Sh\n    abbr_month_names:\n    -\n    - yan.\n    - fev.\n    - mart\n    - apr.\n    - may\n    - iyun\n    - iyul\n    - avg.\n    - sen.\n    - okt.\n    - noy.\n    - dek.\n    day_names:\n    - yakshanba\n    - dushanba\n    - seshanba\n    - chorshanba\n    - payshanba\n    - juma\n    - shanba\n    formats:\n      default: \"%d.%m.%Y\"\n      long: \"%d %B %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - yanvar\n    - fevral\n    - mart\n    - aprel\n    - may\n    - iyun\n    - iyul\n    - avgust\n    - sentyabr\n    - oktyabr\n    - noyabr\n    - dekabr\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        few: chamasi %{count} soat\n        many: chamasi %{count} soat\n        one: chamasi %{count} soat\n        other: chamasi %{count} soat\n      about_x_months:\n        few: chamasi %{count} oy\n        many: chamasi %{count} oy\n        one: chamasi %{count} oy\n        other: chamasi %{count} oy\n      about_x_years:\n        few: chamasi %{count} yil\n        many: chamasi %{count} yil\n        one: chamasi %{count} yil\n        other: chamasi %{count} yil\n      almost_x_years:\n        few: deyarli %{count} yil\n        many: deyarli %{count} yil\n        one: deyarli %{count} yil\n        other: deyarli %{count} yil\n      half_a_minute: bir daqiqadan kam\n      less_than_x_minutes:\n        few: \"%{count} daqiqadan kam\"\n        many: \"%{count} daqiqadan kam\"\n        one: \"%{count} daqiqadan kam\"\n        other: \"%{count} daqiqadan kam\"\n      less_than_x_seconds:\n        few: \"%{count} soniyadan kam\"\n        many: \"%{count} soniyadan kam\"\n        one: \"%{count} soniyadan kam\"\n        other: \"%{count} soniyadan kam\"\n      over_x_years:\n        few: \"%{count} yildan ziyod\"\n        many: \"%{count} yildan ziyod\"\n        one: \"%{count} yildan ziyod\"\n        other: \"%{count} yildan ziyod\"\n      x_days:\n        few: \"%{count} kun\"\n        many: \"%{count} kun\"\n        one: \"%{count} kun\"\n        other: \"%{count} kun\"\n      x_minutes:\n        few: \"%{count} daqiqa\"\n        many: \"%{count} daqiqa\"\n        one: \"%{count} daqiqa\"\n        other: \"%{count} daqiqa\"\n      x_months:\n        few: \"%{count} oy\"\n        many: \"%{count} oy\"\n        one: \"%{count} oy\"\n        other: \"%{count} oy\"\n      x_seconds:\n        few: \"%{count} soniya\"\n        many: \"%{count} soniya\"\n        one: \"%{count} soniya\"\n        other: \"%{count} soniya\"\n    prompts:\n      day: kun\n      hour: soat\n      minute: daqiqa\n      month: oy\n      second: soniya\n      year: yil\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: tasdiqlash kerak\n      blank: bosh bo'lishi mumkin emas\n      confirmation: tasdiq bilan mos emas\n      empty: bosh bo'lishi mumkin emas\n      equal_to: \"%{count} ga teng\"\n      even: juft sonlar mumkin\n      exclusion: mumkin emas\n      greater_than: \"%{count} dan yuqori\"\n      greater_than_or_equal_to: \"%{count} ga teng yoki yuqori\"\n      inclusion: kutilmagan natija\n      invalid: noto'gri\n      less_than: \"%{count} dan kam\"\n      less_than_or_equal_to: \"%{count} ga teng yoki kam\"\n      not_a_number: son emas\n      not_an_integer: butun son emas\n      odd: toq sonlar mumkin\n      taken: band\n      too_long:\n        few: uzunligi yetarligidan ortiq (%{count} ta simvoldan yuqori mumkin emas)\n        many: uzunligi yetarligidan ortiq (%{count} ta simvoldan yuqori mumkin emas)\n        one: uzunligi yetarligidan ortiq (%{count} ta simvoldan yuqori mumkin emas)\n        other: uzunligi yetarligidan ortiq (%{count} ta simvoldan yuqori mumkin emas)\n      too_short:\n        few: uzunligi yetarli emas (%{count} ta simvoldan kam mumkin emas)\n        many: uzunligi yetarli emas (%{count} ta simvoldan kam mumkin emas)\n        one: uzunligi yetarli emas (%{count} ta simvoldan kam mumkin emas)\n        other: uzunligi yetarli emas (%{count} ta simvoldan kam mumkin emas)\n      wrong_length:\n        few: uzunligi noto'gri (uzunligi %{count} simvolga teng bulishi kerak)\n        many: uzunligi noto'gri (uzunligi %{count} simvolga teng bulishi kerak)\n        one: uzunligi noto'gri (uzunligi %{count} simvolga teng bulishi kerak)\n        other: uzunligi noto'gri (uzunligi %{count} simvolga teng bulishi kerak)\n    template:\n      body: 'Quyidagilarda hatolar mavjud:'\n      header:\n        few: \"%{count} ta hatolar sababli %{model}:ni saqlab bulmadi\"\n        many: \"%{count} ta hatolar sababli %{model}:ni saqlab bulmadi\"\n        one: \"%{count} ta hato sababli %{model}:ni saqlab bulmadi\"\n        other: \"%{count} ta hatolar sababli %{model}:ni saqlab bulmadi\"\n  helpers:\n    select:\n      prompt: 'Tanlang: '\n    submit:\n      create: Yarat %{model}\n      submit: Saqla %{model}\n      update: Saqla %{model}\n  number:\n    currency:\n      format:\n        delimiter: \" \"\n        format: \"%n %u\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: so'm.\n    format:\n      delimiter: \" \"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion:\n            few: milliard\n            many: milliard\n            one: milliard\n            other: milliard\n          million:\n            few: million\n            many: million\n            one: million\n            other: million\n          quadrillion:\n            few: kvadrillion\n            many: kvadrillion\n            one: kvadrillion\n            other: kvadrillion\n          thousand:\n            few: ming\n            many: ming\n            one: ming\n            other: ming\n          trillion:\n            few: trillion\n            many: trillion\n            one: trillion\n            other: trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            few: bayt\n            many: bayt\n            one: bayt\n            other: bayt\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \" va \"\n      two_words_connector: \" va \"\n      words_connector: \", \"\n  time:\n    am: ertalab\n    formats:\n      default: \"%a, %d %b %Y, %H:%M:%S %z\"\n      long: \"%d %B %Y, %H:%M\"\n      short: \"%d %b, %H:%M\"\n    pm: kechasi\n"
  },
  {
    "path": "config/locales/defaults/vi.yml",
    "content": "---\nvi:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Có các lỗi sau: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Không thể xóa do tồn tại một số đối tượng phụ thuộc %{record}\n          has_one: Không thể xóa do tồn tại đối tượng phụ thuộc %{record}\n  date:\n    abbr_day_names:\n    - CN\n    - Thứ 2\n    - Thứ 3\n    - Thứ 4\n    - Thứ 5\n    - Thứ 6\n    - Thứ 7\n    abbr_month_names:\n    -\n    - Thg 1\n    - Thg 2\n    - Thg 3\n    - Thg 4\n    - Thg 5\n    - Thg 6\n    - Thg 7\n    - Thg 8\n    - Thg 9\n    - Thg 10\n    - Thg 11\n    - Thg 12\n    day_names:\n    - Chủ nhật\n    - Thứ hai\n    - Thứ ba\n    - Thứ tư\n    - Thứ năm\n    - Thứ sáu\n    - Thứ bảy\n    formats:\n      default: \"%d-%m-%Y\"\n      long: \"%d %B, %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Tháng một\n    - Tháng hai\n    - Tháng ba\n    - Tháng tư\n    - Tháng năm\n    - Tháng sáu\n    - Tháng bảy\n    - Tháng tám\n    - Tháng chín\n    - Tháng mười\n    - Tháng mười một\n    - Tháng mười hai\n    order:\n    - :day\n    - :month\n    - :year\n  datetime:\n    distance_in_words:\n      about_x_hours: khoảng %{count} giờ\n      about_x_months: khoảng %{count} tháng\n      about_x_years: khoảng %{count} năm\n      almost_x_years: gần %{count} năm\n      half_a_minute: 30 giây\n      less_than_x_minutes: chưa tới %{count} phút\n      less_than_x_seconds: chưa tới %{count} giây\n      over_x_years: hơn %{count} năm\n      x_days: \"%{count} ngày\"\n      x_minutes: \"%{count} phút\"\n      x_months: \"%{count} tháng\"\n      x_seconds: \"%{count} giây\"\n    prompts:\n      day: Ngày\n      hour: Giờ\n      minute: Phút\n      month: Tháng\n      second: Giây\n      year: Năm\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: phải được đồng ý\n      blank: không thể để trắng\n      confirmation: không khớp với xác nhận\n      empty: không thể rỗng\n      equal_to: phải bằng %{count}\n      even: phải là số chẵn\n      exclusion: đã được giành trước\n      greater_than: phải lớn hơn %{count}\n      greater_than_or_equal_to: phải lớn hơn hoặc bằng %{count}\n      inclusion: không có trong danh sách\n      invalid: không hợp lệ\n      less_than: phải nhỏ hơn %{count}\n      less_than_or_equal_to: phải nhỏ hơn hoặc bằng %{count}\n      not_a_number: không phải là số\n      not_an_integer: phải là một số nguyên\n      odd: phải là số lẻ\n      other_than: cần phải khác %{count}\n      present: cần phải để trắng\n      required: phải có\n      taken: đã tồn tại\n      too_long: quá dài (tối đa %{count} ký tự)\n      too_short: quá ngắn (tối thiểu %{count} ký tự)\n      wrong_length: độ dài không đúng (phải là %{count} ký tự)\n    template:\n      body: 'Có lỗi với các mục sau:'\n      header: \"%{count} lỗi ngăn không cho lưu %{model} này\"\n  helpers:\n    select:\n      prompt: Vui lòng chọn\n    submit:\n      create: Tạo %{model}\n      submit: Lưu %{model}\n      update: Cập nhật %{model}\n  number:\n    currency:\n      format:\n        delimiter: \".\"\n        format: \"%n %u\"\n        precision: 0\n        separator: \",\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: VNĐ\n    format:\n      delimiter: \".\"\n      precision: 3\n      separator: \",\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Tỷ\n          million: Triệu\n          quadrillion: Triệu tỷ\n          thousand: Nghìn\n          trillion: Nghìn tỷ\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Byte\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", và \"\n      two_words_connector: \" và \"\n      words_connector: \", \"\n  time:\n    am: sáng\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B, %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: chiều\n"
  },
  {
    "path": "config/locales/defaults/wo.yml",
    "content": "---\nwo:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 'Validation failed: %{errors}'\n        restrict_dependent_destroy:\n          has_many: Cannot delete record because dependent %{record} exist\n          has_one: Cannot delete record because a dependent %{record} exists\n  date:\n    abbr_day_names:\n    - Dib\n    - Alt\n    - Tal\n    - All\n    - Alx\n    - Ajj\n    - Gaw\n    abbr_month_names:\n    -\n    - Jan\n    - Feb\n    - Mar\n    - Apr\n    - May\n    - Jun\n    - Jul\n    - Aug\n    - Sep\n    - Oct\n    - Nov\n    - Dec\n    day_names:\n    - Dibèer\n    - Altine\n    - Talaata\n    - Allarba\n    - Alxamess\n    - Ajjouma\n    - Gaawu\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%d %B, %Y\"\n      short: \"%d %b\"\n    month_names:\n    -\n    - Tamkharit\n    - Digui Gamou\n    - Gamou\n    - Raki Gamou\n    - Rakati Gamou\n    - Mamou Kor\n    - Ndeyou Kor\n    - Baraxlou\n    - Kor\n    - Kori\n    - Digui Tabaski\n    - Tabaski\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours:\n        one: about %{count} hour\n        other: about %{count} hours\n      about_x_months:\n        one: about %{count} month\n        other: about %{count} months\n      about_x_years:\n        one: about %{count} year\n        other: about %{count} years\n      almost_x_years:\n        one: almost %{count} year\n        other: almost %{count} years\n      half_a_minute: half a minute\n      less_than_x_minutes:\n        one: less than a minute\n        other: less than %{count} minutes\n      less_than_x_seconds:\n        one: less than %{count} second\n        other: less than %{count} seconds\n      over_x_years:\n        one: over %{count} year\n        other: over %{count} years\n      x_days:\n        one: \"%{count} day\"\n        other: \"%{count} days\"\n      x_minutes:\n        one: \"%{count} minute\"\n        other: \"%{count} minutes\"\n      x_months:\n        one: \"%{count} month\"\n        other: \"%{count} months\"\n      x_seconds:\n        one: \"%{count} second\"\n        other: \"%{count} seconds\"\n      x_years:\n        one: \"%{count} year\"\n        other: \"%{count} years\"\n    prompts:\n      day: Day\n      hour: Hour\n      minute: Minute\n      month: Month\n      second: Second\n      year: Year\n  errors:\n    format: \"%{attribute} %{message}\"\n    messages:\n      accepted: must be accepted\n      blank: can't be blank\n      confirmation: doesn't match %{attribute}\n      empty: can't be empty\n      equal_to: must be equal to %{count}\n      even: must be even\n      exclusion: is reserved\n      greater_than: must be greater than %{count}\n      greater_than_or_equal_to: must be greater than or equal to %{count}\n      inclusion: is not included in the list\n      invalid: is invalid\n      less_than: must be less than %{count}\n      less_than_or_equal_to: must be less than or equal to %{count}\n      not_a_number: is not a number\n      not_an_integer: must be an integer\n      odd: must be odd\n      taken: has already been taken\n      too_long:\n        one: is too long (maximum is %{count} character)\n        other: is too long (maximum is %{count} characters)\n      too_short:\n        one: is too short (minimum is %{count} character)\n        other: is too short (minimum is %{count} characters)\n      wrong_length:\n        one: is the wrong length (should be %{count} character)\n        other: is the wrong length (should be %{count} characters)\n    template:\n      body: 'There were problems with the following fields:'\n      header:\n        one: \"%{count} error prohibited this %{model} from being saved\"\n        other: \"%{count} errors prohibited this %{model} from being saved\"\n  helpers:\n    select:\n      prompt: Please select\n    submit:\n      create: Create %{model}\n      submit: Save %{model}\n      update: Update %{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: \"$\"\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: Billion\n          million: Million\n          quadrillion: Quadrillion\n          thousand: Thousand\n          trillion: Trillion\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 3\n        significant: true\n        strip_insignificant_zeros: true\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: Byte\n            other: Bytes\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \", and \"\n      two_words_connector: \" and \"\n      words_connector: \", \"\n  time:\n    am: am\n    formats:\n      default: \"%a, %d %b %Y %H:%M:%S %z\"\n      long: \"%d %B, %Y %H:%M\"\n      short: \"%d %b %H:%M\"\n    pm: pm\n"
  },
  {
    "path": "config/locales/defaults/zh-CN.yml",
    "content": "---\nzh-CN:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 验证失败：%{errors}\n        restrict_dependent_destroy:\n          has_many: 由于%{record}需要此记录，所以无法移除记录\n          has_one: 由于%{record}需要此记录，所以无法移除记录\n  date:\n    abbr_day_names:\n    - 周日\n    - 周一\n    - 周二\n    - 周三\n    - 周四\n    - 周五\n    - 周六\n    abbr_month_names:\n    -\n    - 1月\n    - 2月\n    - 3月\n    - 4月\n    - 5月\n    - 6月\n    - 7月\n    - 8月\n    - 9月\n    - 10月\n    - 11月\n    - 12月\n    day_names:\n    - 星期日\n    - 星期一\n    - 星期二\n    - 星期三\n    - 星期四\n    - 星期五\n    - 星期六\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%Y年%m月%d日\"\n      short: \"%m月%d日\"\n    month_names:\n    -\n    - 一月\n    - 二月\n    - 三月\n    - 四月\n    - 五月\n    - 六月\n    - 七月\n    - 八月\n    - 九月\n    - 十月\n    - 十一月\n    - 十二月\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours: 大约%{count}小时\n      about_x_months: 大约%{count}个月\n      about_x_years: 大约%{count}年\n      almost_x_years: 接近%{count}年\n      half_a_minute: 半分钟\n      less_than_x_minutes: 不到%{count}分钟\n      less_than_x_seconds: 不到%{count}秒\n      over_x_years: \"%{count}年多\"\n      x_days: \"%{count}天\"\n      x_minutes: \"%{count}分钟\"\n      x_months: \"%{count}个月\"\n      x_seconds: \"%{count}秒\"\n      x_years: \"%{count}年\"\n    prompts:\n      day: 日\n      hour: 时\n      minute: 分\n      month: 月\n      second: 秒\n      year: 年\n  errors:\n    format: \"%{attribute}%{message}\"\n    messages:\n      accepted: 必须是可被接受的\n      blank: 不能为空字符\n      confirmation: 与%{attribute}不匹配\n      empty: 不能留空\n      equal_to: 必须等于%{count}\n      even: 必须为双数\n      exclusion: 是保留关键字\n      greater_than: 必须大于%{count}\n      greater_than_or_equal_to: 必须大于或等于%{count}\n      inclusion: 不包含于列表中\n      invalid: 是无效的\n      less_than: 必须小于%{count}\n      less_than_or_equal_to: 必须小于或等于%{count}\n      model_invalid: 验证失败：%{errors}\n      not_a_number: 不是数字\n      not_an_integer: 必须是整数\n      odd: 必须为单数\n      other_than: 长度非法（不可为%{count}个字符）\n      present: 必须是空白\n      required: 必须存在\n      taken: 已经被使用\n      too_long: 过长（最长为%{count}个字符）\n      too_short: 过短（最短为%{count}个字符）\n      wrong_length: 长度非法（必须为%{count}个字符）\n    template:\n      body: 如下字段出现错误：\n      header: 有%{count}个错误发生导致“%{model}”无法被保存。\n  helpers:\n    select:\n      prompt: 请选择\n    submit:\n      create: 新增%{model}\n      submit: 储存%{model}\n      update: 更新%{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u %n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: CN¥\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: 十亿\n          million: 百万\n          quadrillion: 千兆\n          thousand: 千\n          trillion: 兆\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte: 字节\n          eb: EB\n          gb: GB\n          kb: KB\n          mb: MB\n          pb: PB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \"、\"\n      two_words_connector: 和\n      words_connector: \"、\"\n  time:\n    am: 上午\n    formats:\n      default: \"%Y年%m月%d日 %A %H:%M:%S %Z\"\n      long: \"%Y年%m月%d日 %H:%M\"\n      short: \"%m月%d日 %H:%M\"\n    pm: 下午\n"
  },
  {
    "path": "config/locales/defaults/zh-TW.yml",
    "content": "---\nzh-TW:\n  activerecord:\n    errors:\n      messages:\n        record_invalid: 校驗失敗：%{errors}\n        restrict_dependent_destroy:\n          has_many: 由於%{record}需要此記錄，所以無法移除記錄\n          has_one: 由於%{record}需要此記錄，所以無法移除記錄\n  date:\n    abbr_day_names:\n    - 週日\n    - 週一\n    - 週二\n    - 週三\n    - 週四\n    - 週五\n    - 週六\n    abbr_month_names:\n    -\n    - 1月\n    - 2月\n    - 3月\n    - 4月\n    - 5月\n    - 6月\n    - 7月\n    - 8月\n    - 9月\n    - 10月\n    - 11月\n    - 12月\n    day_names:\n    - 星期日\n    - 星期一\n    - 星期二\n    - 星期三\n    - 星期四\n    - 星期五\n    - 星期六\n    formats:\n      default: \"%Y-%m-%d\"\n      long: \"%Y年%m月%d日\"\n      short: \"%m月%d日\"\n    month_names:\n    -\n    - 一月\n    - 二月\n    - 三月\n    - 四月\n    - 五月\n    - 六月\n    - 七月\n    - 八月\n    - 九月\n    - 十月\n    - 十一月\n    - 十二月\n    order:\n    - :year\n    - :month\n    - :day\n  datetime:\n    distance_in_words:\n      about_x_hours: 大約%{count}小時\n      about_x_months: 大約%{count}個月\n      about_x_years: 大約%{count}年\n      almost_x_years: 接近%{count}年\n      half_a_minute: 半分鐘\n      less_than_x_minutes: 不到%{count}分鐘\n      less_than_x_seconds: 不到%{count}秒\n      over_x_years: \"%{count}年多\"\n      x_days: \"%{count}天\"\n      x_minutes: \"%{count}分鐘\"\n      x_months: \"%{count}個月\"\n      x_seconds: \"%{count}秒\"\n      x_years: \"%{count}年\"\n    prompts:\n      day: 日\n      hour: 時\n      minute: 分\n      month: 月\n      second: 秒\n      year: 年\n  errors:\n    format: \"%{attribute}%{message}\"\n    messages:\n      accepted: 必須是可被接受的\n      blank: 不能為空白\n      confirmation: 與%{attribute}須一致\n      empty: 不能留空\n      equal_to: 必須等於%{count}\n      even: 必須是偶數\n      exclusion: 是被保留的關鍵字\n      greater_than: 必須大於%{count}\n      greater_than_or_equal_to: 必須大於或等於%{count}\n      inclusion: 沒有包含在列表中\n      invalid: 是無效的\n      less_than: 必須小於%{count}\n      less_than_or_equal_to: 必須小於或等於%{count}\n      model_invalid: 校驗失敗：%{errors}\n      not_a_number: 不是數字\n      not_an_integer: 必須是整數\n      odd: 必須是奇數\n      other_than: 不可以是%{count}個字\n      present: 必須是空白\n      required: 必須存在\n      taken: 已經被使用\n      too_long: 過長（最長是%{count}個字）\n      too_short: 過短（最短是%{count}個字）\n      wrong_length: 字數錯誤（必須是%{count}個字）\n    template:\n      body: 以下欄位發生問題：\n      header: 有%{count}個錯誤發生使得「%{model}」無法被儲存。\n  helpers:\n    select:\n      prompt: 請選擇\n    submit:\n      create: 新增%{model}\n      submit: 儲存%{model}\n      update: 更新%{model}\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        significant: false\n        strip_insignificant_zeros: false\n        unit: NT$\n    format:\n      delimiter: \",\"\n      precision: 3\n      separator: \".\"\n      significant: false\n      strip_insignificant_zeros: false\n    human:\n      decimal_units:\n        format: \"%n %u\"\n        units:\n          billion: 十億\n          million: 百萬\n          quadrillion: 千兆\n          thousand: 千\n          trillion: 兆\n          unit: ''\n      format:\n        delimiter: ''\n        precision: 1\n        significant: false\n        strip_insignificant_zeros: false\n      storage_units:\n        format: \"%n %u\"\n        units:\n          byte:\n            one: 位元組\n            other: 位元組\n          gb: GB\n          kb: KB\n          mb: MB\n          tb: TB\n    percentage:\n      format:\n        delimiter: ''\n        format: \"%n%\"\n    precision:\n      format:\n        delimiter: ''\n  support:\n    array:\n      last_word_connector: \"、\"\n      two_words_connector: 和\n      words_connector: \"、\"\n  time:\n    am: 上午\n    formats:\n      default: \"%Y年%m月%d日 %A %H:%M:%S %Z\"\n      long: \"%Y年%m月%d日 %H:%M\"\n      short: \"%m月%d日 %H:%M\"\n    pm: 下午\n"
  },
  {
    "path": "config/locales/doorkeeper.en.yml",
    "content": "en:\n  activerecord:\n    attributes:\n      doorkeeper/application:\n        name: 'Name'\n        redirect_uri: 'Redirect URI'\n    errors:\n      models:\n        doorkeeper/application:\n          attributes:\n            redirect_uri:\n              fragment_present: 'cannot contain a fragment.'\n              invalid_uri: 'must be a valid URI.'\n              unspecified_scheme: 'must specify a scheme.'\n              relative_uri: 'must be an absolute URI.'\n              secured_uri: 'must be an HTTPS/SSL URI.'\n              forbidden_uri: 'is forbidden by the server.'\n            scopes:\n              not_match_configured: \"doesn't match configured on the server.\"\n\n  doorkeeper:\n    applications:\n      confirmations:\n        destroy: 'Are you sure?'\n      buttons:\n        edit: 'Edit'\n        destroy: 'Destroy'\n        submit: 'Submit'\n        cancel: 'Cancel'\n        authorize: 'Authorize'\n      form:\n        error: 'Whoops! Check your form for possible errors'\n      help:\n        confidential: 'Application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.'\n        redirect_uri: 'Use one line per URI'\n        blank_redirect_uri: \"Leave it blank if you configured your provider to use Client Credentials, Resource Owner Password Credentials or any other grant type that doesn't require redirect URI.\"\n        scopes: 'Separate scopes with spaces. Leave blank to use the default scopes.'\n      edit:\n        title: 'Edit application'\n      index:\n        title: 'Your applications'\n        new: 'New Application'\n        name: 'Name'\n        callback_url: 'Callback URL'\n        confidential: 'Confidential?'\n        actions: 'Actions'\n        confidentiality:\n          'yes': 'Yes'\n          'no': 'No'\n      new:\n        title: 'New Application'\n      show:\n        title: 'Application: %{name}'\n        application_id: 'UID'\n        secret: 'Secret'\n        secret_hashed: 'Secret hashed'\n        scopes: 'Scopes'\n        confidential: 'Confidential'\n        callback_urls: 'Callback urls'\n        actions: 'Actions'\n        not_defined: 'Not defined'\n\n    authorizations:\n      buttons:\n        authorize: 'Authorize'\n        deny: 'Deny'\n      error:\n        title: 'An error has occurred'\n      new:\n        title: 'Authorization required'\n        prompt: 'Authorize %{client_name} to use your account?'\n        able_to: 'This application will be able to'\n      show:\n        title: 'Authorization code'\n      form_post:\n        title: 'Submit this form'\n\n    authorized_applications:\n      confirmations:\n        revoke: 'Are you sure?'\n      buttons:\n        revoke: 'Revoke'\n      index:\n        title: 'Your authorized applications'\n        application: 'Application'\n        created_at: 'Created At'\n        date_format: '%Y-%m-%d %H:%M:%S'\n\n    pre_authorization:\n      status: 'Pre-authorization'\n\n    errors:\n      messages:\n        # Common error messages\n        invalid_request:\n          unknown: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.'\n          missing_param: 'Missing required parameter: %{value}.'\n          request_not_authorized: 'Request need to be authorized. Required parameter for authorizing request is missing or invalid.'\n          invalid_code_challenge: 'Code challenge is required.'\n        invalid_redirect_uri: \"The requested redirect uri is malformed or doesn't match client redirect URI.\"\n        unauthorized_client: 'The client is not authorized to perform this request using this method.'\n        access_denied: 'The resource owner or authorization server denied the request.'\n        invalid_scope: 'The requested scope is invalid, unknown, or malformed.'\n        invalid_code_challenge_method:\n          zero: 'The authorization server does not support PKCE as there are no accepted code_challenge_method values.'\n          one: 'The code_challenge_method must be %{challenge_methods}.'\n          other: 'The code_challenge_method must be one of %{challenge_methods}.'\n        server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.'\n        temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.'\n\n        # Configuration error messages\n        credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.'\n        resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfigured.'\n        admin_authenticator_not_configured: 'Access to admin panel is forbidden due to Doorkeeper.configure.admin_authenticator being unconfigured.'\n\n        # Access grant errors\n        unsupported_response_type: 'The authorization server does not support this response type.'\n        unsupported_response_mode: 'The authorization server does not support this response mode.'\n\n        # Access token errors\n        invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.'\n        invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.'\n        unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.'\n\n        invalid_token:\n          revoked: \"The access token was revoked\"\n          expired: \"The access token expired\"\n          unknown: \"The access token is invalid\"\n        revoke:\n          unauthorized: \"You are not authorized to revoke this token\"\n\n        forbidden_token:\n          missing_scope: 'Access to this resource requires scope \"%{oauth_scopes}\".'\n\n    flash:\n      applications:\n        create:\n          notice: 'Application created.'\n        destroy:\n          notice: 'Application deleted.'\n        update:\n          notice: 'Application updated.'\n      authorized_applications:\n        destroy:\n          notice: 'Application revoked.'\n\n    layouts:\n      admin:\n        title: 'Doorkeeper'\n        nav:\n          oauth2_provider: 'OAuth2 Provider'\n          applications: 'Applications'\n          home: 'Home'\n      application:\n        title: 'OAuth authorization required'\n"
  },
  {
    "path": "config/locales/mailers/invitation_mailer/en.yml",
    "content": "---\nen:\n  invitation_mailer:\n    invite_email:\n      subject: \"%{inviter} has invited you to join their household on Maybe!\"\n"
  },
  {
    "path": "config/locales/models/account/en.yml",
    "content": "---\nen:\n  activerecord:\n    attributes:\n      account:\n        balance: Balance\n        currency: Currency\n        family: Family\n        family_id: Family\n        name: Name\n        subtype: Subtype\n    models:\n      account: Account\n      account/credit: Credit Card\n      account/depository: Bank Account\n      account/investment: Investment\n      account/loan: Loan\n      account/other_asset: Other Asset\n      account/other_liability: Other Liability\n      account/property: Real Estate\n      account/vehicle: Vehicle\n"
  },
  {
    "path": "config/locales/models/address/en.yml",
    "content": "---\nen:\n  address:\n    attributes:\n      country: Country\n      line1: Address Line 1\n      line2: Address Line 2\n      locality: Locality\n      postal_code: Postal Code\n      region: Region\n    format: \"%{line1} %{line2}, %{locality}, %{region} %{postal_code} %{country}\""
  },
  {
    "path": "config/locales/models/entry/en.yml",
    "content": "---\nen:\n  activerecord:\n    errors:\n      models:\n        entry:\n          attributes:\n            base:\n              invalid_sell_quantity: cannot sell %{sell_qty} shares of %{ticker} because\n                you only own %{current_qty} shares\n"
  },
  {
    "path": "config/locales/models/import/en.yml",
    "content": "---\nen:\n  activerecord:\n    attributes:\n      import:\n        currency: Currency\n        number_format: Number Format\n    errors:\n      models:\n        import:\n          attributes:\n            raw_file_str:\n              invalid_csv_format: is not a valid CSV format\n"
  },
  {
    "path": "config/locales/models/time_series/value/en.yml",
    "content": "---\nen:\n  activemodel:\n    errors:\n      models:\n        time_series/value:\n          attributes:\n            value:\n              must_be_a_money_or_numeric: must be a Money or Numeric\n"
  },
  {
    "path": "config/locales/models/transfer/en.yml",
    "content": "---\nen:\n  activerecord:\n    errors:\n      models:\n        transfer:\n          attributes:\n            base:\n              inflow_cannot_be_in_multiple_transfers: Inflow transaction cannot be\n                part of multiple transfers\n              must_be_from_different_accounts: Transfer must have different accounts\n              must_be_from_same_family: Transfer must be from the same family\n              must_be_within_date_range: Transfer transaction dates must be within\n                4 days of each other\n              must_have_opposite_amounts: Transfer transactions must have opposite\n                amounts\n              must_have_single_currency: Transfer must have a single currency\n              outflow_cannot_be_in_multiple_transfers: Outflow transaction cannot\n                be part of multiple transfers\n  transfer:\n    name: Transfer to %{to_account}\n    payment_name: Payment to %{to_account}\n"
  },
  {
    "path": "config/locales/models/trend/en.yml",
    "content": "---\nen:\n  activemodel:\n    errors:\n      models:\n        trend:\n          attributes:\n            current:\n              must_be_of_the_same_type_as_previous: must be of the same type as previous\n              must_be_of_type_money_numeric_or_nil: must be of type Money, Numeric,\n                or nil\n            previous:\n              must_be_of_the_same_type_as_current: must be of the same type as current\n              must_be_of_type_money_numeric_or_nil: must be of type Money, Numeric,\n                or nil\n"
  },
  {
    "path": "config/locales/models/user/en.yml",
    "content": "---\nen:\n  activerecord:\n    attributes:\n      user:\n        email: Email\n        family: Family\n        family_id: Family\n        first_name: First Name\n        last_name: Last Name\n        password: Password\n        password_confirmation: Password Confirmation\n    errors:\n      models:\n        user:\n          attributes:\n            base:\n              cannot_deactivate_admin_with_other_users: Admin cannot delete account\n                while other users are present. Please delete all members first.\n            profile_image:\n              invalid_file_size: file size must be less than %{max_megabytes}MB\n"
  },
  {
    "path": "config/locales/views/accounts/en.yml",
    "content": "---\nen:\n  accounts:\n    account:\n      troubleshoot: Troubleshoot\n    chart:\n      data_not_available: Data not available for the selected period\n    create:\n      success: \"%{type} account created\"\n    destroy:\n      success: \"%{type} account scheduled for deletion\"\n    empty:\n      empty_message: Add an account either via connection, importing or entering manually.\n      new_account: New account\n      no_accounts: No accounts yet\n    form:\n      balance: Current balance\n      name_label: Account name\n      name_placeholder: Example account name\n    index:\n      accounts: Accounts\n      manual_accounts:\n        other_accounts: Other accounts\n      new_account: New account\n      sync: Sync all\n    new:\n      import_accounts: Import accounts\n      method_selector:\n        connected_entry: Link account\n        connected_entry_eu: Link EU account\n        manual_entry: Enter account balance\n        title: How would you like to add it?\n      title: What would you like to add?\n    show:\n      activity:\n        amount: Amount\n        balance: Balance\n        date: Date\n        entries: entries\n        entry: entry\n        new: New\n        new_balance: New balance\n        new_transaction: New transaction\n        no_entries: No entries found\n        title: Activity\n      chart:\n        balance: Balance\n        owed: Amount owed\n      menu:\n        confirm_accept: Delete \"%{name}\"\n        confirm_body_html: \"<p>By deleting this account, you will erase its value\n          history, affecting various aspects of your overall account. This action\n          will have a direct impact on your net worth calculations and the account\n          graphs.</p><br /> <p>After deletion, there is no way you'll be able to restore\n          the account information because you'll need to add it as a new account.</p>\"\n        confirm_title: Delete account?\n        edit: Edit\n        import: Import transactions\n        manage: Manage accounts\n    update:\n      success: \"%{type} account updated\"\n  email_confirmations:\n    new:\n      invalid_token: Invalid or expired confirmation link.\n      success_login: Your email has been confirmed. Please log in with your new email\n        address.\n"
  },
  {
    "path": "config/locales/views/application/en.yml",
    "content": "---\nen:\n  number:\n    currency:\n      format:\n        delimiter: \",\"\n        format: \"%u%n\"\n        precision: 2\n        separator: \".\"\n        unit: \"$\"\n"
  },
  {
    "path": "config/locales/views/categories/en.yml",
    "content": "---\nen:\n  categories:\n    bootstrap:\n      success: Default categories created successfully\n    category:\n      delete: Delete category\n      edit: Edit category\n    create:\n      success: Category created successfully\n    destroy:\n      success: Category deleted successfully\n    edit:\n      edit: Edit category\n    form:\n      placeholder: Category name\n    index:\n      bootstrap: Use defaults (recommended)\n      categories: Categories\n      categories_expenses: Expense categories\n      categories_incomes: Income categories\n      empty: No categories found\n      new: New category\n    menu:\n      loading: Loading...\n    new:\n      new_category: New category\n    update:\n      success: Category updated successfully\n  category:\n    dropdowns:\n      show:\n        bootstrap: Generate default categories\n        empty: No categories found\n"
  },
  {
    "path": "config/locales/views/category/deletions/en.yml",
    "content": "---\nen:\n  category:\n    deletions:\n      create:\n        success: Transaction category deleted successfully\n      new:\n        category: Category\n        delete_and_leave_uncategorized: Delete \"%{category_name}\" and leave uncategorized\n        delete_and_recategorize: Delete \"%{category_name}\" and assign new category\n        delete_category: Delete category?\n        explanation: By deleting this category, every transaction that has the \"%{category_name}\"\n          category will be uncategorized. Instead of leaving them uncategorized, you\n          can also assign a new category below.\n        replacement_category_prompt: Select category\n"
  },
  {
    "path": "config/locales/views/category/dropdowns/en.yml",
    "content": "---\nen:\n  category:\n    dropdowns:\n      row:\n        delete: Delete category\n        edit: Edit category\n      show:\n        clear: Clear category\n        no_categories: No categories found\n        search_placeholder: Search\n"
  },
  {
    "path": "config/locales/views/credit_cards/en.yml",
    "content": "---\nen:\n  credit_cards:\n    edit:\n      edit: Edit %{account}\n    form:\n      annual_fee: Annual fee\n      annual_fee_placeholder: '99'\n      apr: APR\n      apr_placeholder: '15.99'\n      available_credit: Available credit\n      available_credit_placeholder: '10000'\n      expiration_date: Expiration date\n      minimum_payment: Minimum payment\n      minimum_payment_placeholder: '100'\n    new:\n      title: Enter credit card details\n    overview:\n      amount_owed: Amount Owed\n      annual_fee: Annual Fee\n      apr: APR\n      available_credit: Available Credit\n      expiration_date: Expiration Date\n      minimum_payment: Minimum Payment\n      unknown: Unknown\n"
  },
  {
    "path": "config/locales/views/cryptos/en.yml",
    "content": "---\nen:\n  cryptos:\n    edit:\n      edit: Edit %{account}\n    new:\n      title: Enter account balance\n"
  },
  {
    "path": "config/locales/views/depositories/en.yml",
    "content": "---\nen:\n  depositories:\n    edit:\n      edit: Edit %{account}\n    form:\n      none: None\n      subtype_prompt: Select account type\n    new:\n      title: Enter account balance\n"
  },
  {
    "path": "config/locales/views/email_confirmation_mailer/en.yml",
    "content": "---\nen:\n  email_confirmation_mailer:\n    confirmation_email:\n      body: You recently requested to change your email address. Click the button\n        below to confirm this change.\n      cta: Confirm email change\n      expiry_notice: This link will expire in %{hours} hours.\n      greeting: Hello!\n      subject: 'Maybe: Confirm your email change'\n"
  },
  {
    "path": "config/locales/views/entries/en.yml",
    "content": "---\nen:\n  entries:\n    create:\n      success: Entry created\n    destroy:\n      success: Entry deleted\n    empty:\n      description: Try adding an entry, editing filters or refining your search\n      title: No entries found\n    loading:\n      loading: Loading entries...\n    update:\n      success: Entry updated\n"
  },
  {
    "path": "config/locales/views/holdings/en.yml",
    "content": "---\nen:\n  holdings:\n    cash:\n      brokerage_cash: Brokerage cash\n    destroy:\n      success: Holding deleted\n    holding:\n      per_share: per share\n      shares: \"%{qty} shares\"\n    index:\n      average_cost: Average cost\n      holdings: Holdings\n      name: Name\n      new_holding: New transaction\n      no_holdings: No holdings to show.\n      return: Total return\n      weight: Weight\n    missing_price_tooltip:\n      description: This investment has missing values and we could not calculate\n        its returns or value.\n      missing_data: Missing data\n    show:\n      avg_cost_label: Average Cost\n      current_market_price_label: Current Market Price\n      delete: Delete\n      delete_subtitle: This will delete the holding and all your associated trades\n        on this account.  This action cannot be undone.\n      delete_title: Delete holding\n      history: History\n      overview: Overview\n      portfolio_weight_label: Portfolio Weight\n      settings: Settings\n      ticker_label: Ticker\n      trade_history_entry: \"%{qty} shares of %{security} at %{price}\"\n      total_return_label: Total Return\n      unknown: Unknown\n"
  },
  {
    "path": "config/locales/views/impersonation_sessions/en.yml",
    "content": "---\nen:\n  impersonation_sessions:\n    approve:\n      success: Request approved\n    complete:\n      success: Session completed\n    create:\n      success: Request sent to user. Waiting for approval.\n    join:\n      success: Joined session\n    leave:\n      success: Left session\n    reject:\n      success: Request rejected\n"
  },
  {
    "path": "config/locales/views/imports/en.yml",
    "content": "---\nen:\n  import:\n    cleans:\n      show:\n        description: Edit your data in the table below.  Red cells are invalid.\n        errors_notice: You have errors in your data.  Hover over the error to see\n          details.\n        errors_notice_mobile: You have errors in your data.  Tap over the error tooltip to see\n          details.\n        title: Clean your data\n    configurations:\n      mint_import:\n        date_format_label: Date format\n      show:\n        description: Select the columns that correspond to each field in your CSV.\n        title: Configure your import\n      trade_import:\n        date_format_label: Date format\n      transaction_import:\n        date_format_label: Date format\n    confirms:\n      mappings:\n        create_account: Create account\n        csv_mapping_label: \"%{mapping} in CSV\"\n        maybe_mapping_label: \"%{mapping} in Maybe\"\n        no_accounts: You don't have any accounts yet. Please create an account that\n          we can use for (unassigned) rows in your CSV or go back to the Clean step\n          and provide an account name we can use.\n        rows_label: Rows\n        unassigned_account: Need to create a new account for unassigned rows?\n      show:\n        account_mapping_description: Assign all of your imported file's accounts to\n          Maybe's existing accounts.  You can also add new accounts or leave them\n          uncategorized.\n        account_mapping_title: Assign your accounts\n        account_type_mapping_description: Assign all of your imported file's account\n          types to Maybe's\n        account_type_mapping_title: Assign your account types\n        category_mapping_description: Assign all of your imported file's categories\n          to Maybe's existing categories.  You can also add new categories or leave\n          them uncategorized.\n        category_mapping_title: Assign your categories\n        tag_mapping_description: Assign all of your imported file's tags to Maybe's\n          existing tags.  You can also add new tags or leave them uncategorized.\n        tag_mapping_title: Assign your tags\n    uploads:\n      show:\n        description: Paste or upload your CSV file below.  Please review the instructions\n          in the table below before beginning.\n        instructions_1: Below is an example CSV with columns available for import.\n        instructions_2: Your CSV must have a header row\n        instructions_3: You may name your columns anything you like.  You will map\n          them at a later step.\n        instructions_4: Columns marked with an asterisk (*) are required data.\n        instructions_5: No commas, no currency symbols, and no parentheses in numbers.\n        title: Import your data\n  imports:\n    empty:\n      message: No imports yet.\n      new: New import\n    import:\n      complete: Complete\n      delete: Delete\n      failed: Failed\n      in_progress: In progress\n      label: \"%{type}: %{datetime}\"\n      revert_failed: Revert failed\n      reverting: Reverting\n      uploading: Processing rows\n      view: View\n    index:\n      imports: Imports\n      new: New import\n      title: Imports\n    new:\n      description: You can manually import various types of data via CSV or use one\n        of our import templates like Mint.\n      import_accounts: Import accounts\n      import_mint: Import from Mint\n      import_portfolio: Import investments\n      import_transactions: Import transactions\n      resume: Resume %{type}\n      sources: Sources\n      title: New CSV Import\n    ready:\n      description: Here's a summary of the new items that will be added to your account\n        once you publish this import.\n      title: Confirm your import data\n"
  },
  {
    "path": "config/locales/views/investments/en.yml",
    "content": "---\nen:\n  investments:\n    edit:\n      edit: Edit %{account}\n    form:\n      none: None\n      subtype_prompt: Select investment type\n    new:\n      title: Enter account balance\n    show:\n      chart_title: Total value\n    value_tooltip:\n      cash: Cash\n      holdings: Holdings\n      total: Portfolio balance\n      total_value_tooltip: The total portfolio balance is the sum of brokerage cash\n        (available for trading) and the current market value of your holdings.\n"
  },
  {
    "path": "config/locales/views/invitation_mailer/en.yml",
    "content": "---\nen:\n  invitation_mailer:\n    invite_email:\n      accept_button: Accept Invitation\n      body: \"%{inviter} has invited you to join the %{family} family on Maybe!\"\n      expiry_notice: This invitation will expire in %{days} days\n      greeting: Welcome to Maybe!\n"
  },
  {
    "path": "config/locales/views/invitations/en.yml",
    "content": "---\nen:\n  invitations:\n    create:\n      failure: Could not send invitation\n      success: Invitation sent successfully\n    destroy:\n      failure: There was a problem removing the invitation.\n      not_authorized: You are not authorized to manage invitations.\n      success: Invitation was successfully removed.\n    new:\n      email_label: Email Address\n      email_placeholder: Enter email address\n      role_admin: Administrator\n      role_label: Role\n      role_member: Member\n      submit: Send Invitation\n      subtitle: Send an invitation to join your family account on Maybe\n      title: Invite Someone\n"
  },
  {
    "path": "config/locales/views/invite_codes/en.yml",
    "content": "---\nen:\n  invite_codes:\n    index:\n      invite_code_description: Generate a new code to see it displayed here. Generated\n        codes that have been used will no longer be shown.\n      no_invite_codes: No codes to show\n"
  },
  {
    "path": "config/locales/views/layout/en.yml",
    "content": "---\nen:\n  layouts:\n    auth:\n      existing_account: Already have an account?\n      no_account: New to Maybe?\n      sign_in: Sign in\n      sign_up: Create account\n      your_account: Your account\n    shared:\n      footer:\n        privacy_policy: Privacy Policy\n        terms_of_service: Terms of Service\n"
  },
  {
    "path": "config/locales/views/loans/en.yml",
    "content": "---\nen:\n  loans:\n    edit:\n      edit: Edit %{account}\n    form:\n      interest_rate: Interest rate\n      interest_rate_placeholder: '5.25'\n      initial_balance: Original loan balance\n      rate_type: Rate type\n      term_months: Term (months)\n      term_months_placeholder: '360'\n    new:\n      title: Enter loan details\n    overview:\n      interest_rate: Interest Rate\n      monthly_payment: Monthly Payment\n      not_applicable: N/A\n      original_principal: Original Principal\n      remaining_principal: Remaining Principal\n      term: Term\n      type: Type\n      unknown: Unknown\n"
  },
  {
    "path": "config/locales/views/merchants/en.yml",
    "content": "---\nen:\n  family_merchants:\n    create:\n      error: 'Error creating merchant: %{error}'\n      success: New merchant created successfully\n    destroy:\n      success: Merchant deleted successfully\n    edit:\n      title: Edit merchant\n    form:\n      name_placeholder: Merchant name\n    index:\n      empty: No merchants yet\n      new: New merchant\n      title: Merchants\n    merchant:\n      confirm_accept: Delete merchant\n      confirm_body: Are you sure you want to delete this merchant? Removing this merchant\n        will unlink all associated transactions and may effect your reporting.\n      confirm_title: Delete merchant?\n      delete: Delete merchant\n      edit: Edit merchant\n    new:\n      title: New merchant\n    update:\n      success: Merchant updated successfully\n"
  },
  {
    "path": "config/locales/views/mfa/en.yml",
    "content": "---\nen:\n  mfa:\n    backup_codes:\n      backup_codes_description: Each code can only be used once. Keep these codes\n        safe and secure.\n      backup_codes_title: Your Backup Codes\n      continue: Continue to Security Settings\n      description: Store these backup codes in a safe place - you'll need them if\n        you lose access to your authenticator app\n      page_title: Backup Codes\n      title: Save Your Backup Codes\n    create:\n      invalid_code: Invalid verification code. Please try again.\n    disable:\n      success: Two-factor authentication has been disabled\n    new:\n      code_label: Verification Code\n      code_placeholder: Enter 6-digit code\n      description: Enhance your account security by setting up two-factor authentication\n      page_title: Two-Factor Authentication Setup\n      scan_description: Use an authenticator app like Google Authenticator or 1Password\n        to scan this QR code\n      scan_title: 1. Scan QR Code\n      secret_description: If you can't scan the QR code, enter this secret key manually\n        in your authenticator app\n      secret_title: Manual Entry Code\n      title: Set Up Two-Factor Authentication\n      verify_button: Verify and Enable 2FA\n      verify_description: Enter the 6-digit code from your authenticator app\n      verify_title: 2. Enter Verification Code\n    verify:\n      description: Enter the code from your authenticator app to continue\n      page_title: Verify Two-Factor Authentication\n      title: Two-Factor Authentication\n      verify_button: Verify\n    verify_code:\n      invalid_code: Invalid authentication code. Please try again.\n"
  },
  {
    "path": "config/locales/views/onboardings/en.yml",
    "content": "---\nen:\n  onboardings:\n    header:\n      sign_out: Log out\n    preferences:\n      currency: Currency\n      date_format: Date format\n      example: Example account\n      locale: Language\n      preview: Preview how data displays based on preferences.\n      submit: Complete\n      subtitle: Let's configure your preferences.\n      title: Configure your preferences\n    profile:\n      country: Country\n      first_name: First Name\n      household_name: Household Name\n      last_name: Last Name\n      profile_image: Profile Image\n      submit: Continue\n      subtitle: Let's complete your profile.\n      title: Let's set up the basics\n    show:\n      message: We’re really excited you’re here. In the next step we’ll ask you a\n        few questions to complete your profile and then get you all set up.\n      setup: Set up account\n      title: Meet Maybe\n"
  },
  {
    "path": "config/locales/views/other_assets/en.yml",
    "content": "---\nen:\n  other_assets:\n    edit:\n      edit: Edit %{account}\n    new:\n      title: Enter asset details\n"
  },
  {
    "path": "config/locales/views/other_liabilities/en.yml",
    "content": "---\nen:\n  other_liabilities:\n    edit:\n      edit: Edit %{account}\n    new:\n      title: Enter liability details\n"
  },
  {
    "path": "config/locales/views/pages/en.yml",
    "content": "---\nen:\n  pages:\n    changelog:\n      title: What's new\n    dashboard:\n      net_worth_chart:\n        data_not_available: Data not available for the selected period\n        title: Net Worth\n      no_account_empty_state:\n        new_account: New account\n        no_account_subtitle: Since no accounts have been added, there's no data to\n          display. Add your first accounts to start viewing dashboard data.\n        no_account_title: No accounts yet\n"
  },
  {
    "path": "config/locales/views/password_mailer/en.yml",
    "content": "---\nen:\n  password_mailer:\n    password_reset:\n      cta: Reset your password\n      ignore_if_not_requested: If you didn't make this request, you can ignore this\n        email.\n      request_made: A request was made to reset your Maybe password. Click the link\n        to reset it.\n      subject: 'Maybe: Reset your password'\n"
  },
  {
    "path": "config/locales/views/password_resets/en.yml",
    "content": "---\nen:\n  password_resets:\n    edit:\n      title: Reset password\n    new:\n      requested: Please check your email for a link to reset your password.\n      submit: Reset password\n      title: Reset password\n    update:\n      invalid_token: Invalid token.\n      success: Your password has been reset.\n"
  },
  {
    "path": "config/locales/views/passwords/en.yml",
    "content": "---\nen:\n  passwords:\n    edit:\n      password: New Password\n      password_challenge: Current Password\n      submit: Reset Password\n      title: Update Password\n    update:\n      success: Your password has been reset.\n"
  },
  {
    "path": "config/locales/views/plaid_items/en.yml",
    "content": "---\nen:\n  plaid_items:\n    create:\n      success: Account linked successfully.  Please wait for accounts to sync.\n    destroy:\n      success: Accounts scheduled for deletion.\n    plaid_item:\n      add_new: Add new connection\n      confirm_accept: Delete institution\n      confirm_body: This will permanently delete all the accounts in this group and\n        all associated data.\n      confirm_title: Delete institution?\n      connection_lost: Connection lost\n      connection_lost_description: This connection is no longer valid. You'll need\n        to delete this connection and add it again to continue syncing data.\n      delete: Delete\n      error: Error occurred while syncing data\n      no_accounts_description: We could not load any accounts from this financial\n        institution.\n      no_accounts_title: No accounts found\n      requires_update: Requires re-authentication\n      status: Last synced %{timestamp} ago\n      status_never: Requires data sync\n      syncing: Syncing...\n      update: Update connection\n"
  },
  {
    "path": "config/locales/views/properties/en.yml",
    "content": "---\nen:\n  properties:\n    edit:\n      edit: Edit %{account}\n    form:\n      address_line1: Street address\n      address_line1_placeholder: 123 Main St\n      area: Living area\n      area_placeholder: '2000'\n      area_unit: Unit of measurement\n      country: Country\n      country_placeholder: US\n      locality: City\n      locality_placeholder: San Francisco\n      none: None\n      postal_code: ZIP/Postal code\n      postal_code_placeholder: '94105'\n      region: State/Province\n      region_placeholder: CA\n      subtype_prompt: Select property type\n      year_built: Year built\n      year_built_placeholder: '2000'\n    new:\n      title: Enter property details\n    overview:\n      living_area: Living Area\n      market_value: Market Value\n      purchase_price: Purchase Price\n      trend: Trend\n      unknown: Unknown\n      year_built: Year Built\n"
  },
  {
    "path": "config/locales/views/registrations/en.yml",
    "content": "---\nen:\n  helpers:\n    label:\n      user:\n        invite_code: Invite Code\n    submit:\n      user:\n        create: Continue\n  registrations:\n    create:\n      failure: There was a problem signing up.\n      invalid_invite_code: Invalid invite code, please try again.\n      success: You have signed up successfully.\n    new:\n      invitation_message: \"%{inviter} has invited you to join as a %{role}\"\n      join_family_title: Join %{family}\n      role_admin: administrator\n      role_member: member\n      submit: Create account\n      title: Create your account\n      welcome_body: To get started, you must sign up for a new account.  You will\n        then be able to configure additional settings within the app.\n      welcome_title: Welcome to Self Hosted Maybe!\n      password_placeholder: Enter your password\n"
  },
  {
    "path": "config/locales/views/sessions/en.yml",
    "content": "---\nen:\n  sessions:\n    create:\n      invalid_credentials: Invalid email or password.\n    destroy:\n      logout_successful: You have signed out successfully.\n    new:\n      email: Email address\n      email_placeholder: you@example.com\n      forgot_password: Forgot your password?\n      password: Password\n      submit: Log in\n      title: Sign in to your account\n      password_placeholder: Enter your password"
  },
  {
    "path": "config/locales/views/settings/api_keys/en.yml",
    "content": "---\nen:\n  settings:\n    api_keys_controller:\n      success: \"Your API key has been created successfully\"\n      revoked_successfully: \"API key has been revoked successfully\"\n      revoke_failed: \"Failed to revoke API key\"\n      scope_descriptions:\n        read_accounts: \"View Accounts\"\n        read_transactions: \"View Transactions\"\n        read_balances: \"View Balances\"\n        write_transactions: \"Create Transactions\"\n    api_keys:\n      show:\n        title: \"API Key Management\"\n      no_api_key:\n        title: \"Create Your API Key\"\n        description: \"Get programmatic access to your Maybe data with a secure API key.\"\n        what_you_can_do: \"What you can do with the API:\"\n        feature_1: \"Access your account data programmatically\"\n        feature_2: \"Build custom integrations and applications\"\n        feature_3: \"Automate data retrieval and analysis\"\n        security_note_title: \"Security First\"\n        security_note: \"Your API key will have restricted permissions based on the scopes you select. You can only have one active API key at a time.\"\n        create_api_key: \"Create API Key\"\n      current_api_key:\n        title: \"Your API Key\"\n        description: \"Your active API key is ready to use. Keep it secure and never share it publicly.\"\n        active: \"Active\"\n        key_name: \"Name\"\n        created_at: \"Created\"\n        last_used: \"Last Used\"\n        expires: \"Expires\"\n        ago: \"ago\"\n        never_used: \"Never used\"\n        never_expires: \"Never expires\"\n        permissions: \"Permissions\"\n        usage_instructions_title: \"How to use your API key\"\n        usage_instructions: \"Include your API key in the X-Api-Key header when making requests to the Maybe API:\"\n        regenerate_key: \"Create New Key\"\n        revoke_key: \"Revoke Key\"\n        revoke_confirmation: \"Are you sure you want to revoke this API key? This action cannot be undone and will immediately disable all applications using this key.\"\n      new:\n        title: \"Create API Key\"\n        create_new_key: \"Create New API Key\"\n        description: \"Configure your new API key with a descriptive name and appropriate permissions.\"\n        name_label: \"API Key Name\"\n        name_placeholder: \"e.g., Production App, Analytics Dashboard\"\n        name_help: \"Choose a descriptive name to help you identify this key's purpose.\"\n        permissions_label: \"Permissions\"\n        permissions_help: \"Select the permissions your API key needs. You can always create a new key with different permissions.\"\n        scope_details:\n          read_accounts: \"View account information, balances, and account-level data\"\n          read_transactions: \"View transaction data, categories, and transaction details\"\n          read_balances: \"View historical balance data and account value trends\"\n          write_transactions: \"Create and update transaction records (coming soon)\"\n        security_warning_title: \"Important Security Notice\"\n        security_warning: \"Your API key will be shown only once after creation. Store it securely and never share it publicly. If you lose it, you'll need to create a new one.\"\n        create_key: \"Create API Key\"\n        cancel: \"Cancel\"\n      created:\n        title: \"API Key Created\"\n        success_title: \"API Key Created Successfully\"\n        success_description: \"Your new API key is ready to use. Make sure to copy it now as you won't be able to see it again.\"\n        your_api_key: \"Your API Key\"\n        key_name: \"Name\"\n        permissions: \"Permissions\"\n        critical_warning_title: \"⚠️ Critical: Save Your API Key Now\"\n        critical_warning_1: \"This is the only time you'll see your API key in plain text.\"\n        critical_warning_2: \"Copy and store it securely in your password manager or application.\"\n        critical_warning_3: \"If you lose this key, you'll need to create a new one.\"\n        usage_instructions_title: \"Quick Start\"\n        usage_instructions: \"Use your API key by including it in the X-Api-Key header:\"\n        copy_key: \"Copy API Key\"\n        continue: \"Continue to API Key Settings\""
  },
  {
    "path": "config/locales/views/settings/en.yml",
    "content": "---\nen:\n  settings:\n    billings:\n      show:\n        page_title: Billing\n        subscription_subtitle: Update your subscription and billing details\n        subscription_title: Manage subscription\n    preferences:\n      show:\n        country: Country\n        currency: Currency\n        date_format: Date format\n        general_subtitle: Configure your preferences\n        general_title: General\n        default_period: Default Period\n        language: Language\n        page_title: Preferences\n        theme_dark: Dark\n        theme_light: Light\n        theme_subtitle: Choose a preferred theme for the app\n        theme_system: System\n        theme_title: Theme\n        timezone: Timezone\n    profiles:\n      destroy:\n        cannot_remove_self: You cannot remove yourself from the account.\n        member_removal_failed: There was a problem removing the member.\n        member_removed: Member was successfully removed.\n        not_authorized: You are not authorized to remove members.\n      show:\n        confirm_delete:\n          body: Are you sure you want to permanently delete your account? This action\n            is irreversible.\n          title: Delete account?\n        confirm_reset:\n          body: Are you sure you want to reset your account? This will delete all your accounts, categories, merchants, tags, and other data. This action cannot be undone.\n          title: Reset account?\n        confirm_remove_invitation:\n          body: Are you sure you want to remove the invitation for %{email}?\n          title: Remove Invitation\n        confirm_remove_member:\n          body: Are you sure you want to remove %{name} from your account?\n          title: Remove Member\n        danger_zone_title: Danger Zone\n        delete_account: Delete account\n        delete_account_warning: Deleting your account will permanently remove all\n          your data and cannot be undone.\n        reset_account: Reset account\n        reset_account_warning: Resetting your account will delete all your accounts, categories, merchants, tags, and other data, but keep your user account intact.\n        email: Email\n        first_name: First Name\n        household_form_input_placeholder: Enter household name\n        household_form_label: Household name\n        household_subtitle: Invite family members, partners and other inviduals.  Invitees\n          can login to your household and access your shared accounts.\n        household_title: Household\n        invitation_link: Invitation link\n        invite_member: Add member\n        last_name: Last Name\n        page_title: Account\n        pending: Pending\n        profile_subtitle: Customize how you appear on Maybe\n        profile_title: Profile\n        remove_invitation: Remove Invitation\n        remove_member: Remove Member\n        save: Save\n    securities:\n      show:\n        page_title: Security\n    settings_nav:\n      accounts_label: Accounts\n      api_key_label: API Key\n      billing_label: Billing\n      categories_label: Categories\n      feedback_label: Feedback\n      general_section_title: General\n      imports_label: Imports\n      logout: Logout\n      merchants_label: Merchants\n      other_section_title: More\n      preferences_label: Preferences\n      profile_label: Account\n      rules_label: Rules\n      security_label: Security\n      self_hosting_label: Self hosting\n      tags_label: Tags\n      transactions_section_title: Transactions\n      whats_new_label: What's new\n    settings_nav_link_large:\n      next: Next\n      previous: Back\n    user_avatar_field:\n      accepted_formats: JPG or PNG. 5MB max.\n      choose: Upload photo\n      choose_label: (optional)\n      change: Change photo\n"
  },
  {
    "path": "config/locales/views/settings/hostings/en.yml",
    "content": "---\nen:\n  settings:\n    hostings:\n      invite_code_settings:\n        description: Every new user that joins your instance of Maybe can only do\n          so via an invite code\n        email_confirmation_description: When enabled, users must confirm their email\n          address when changing it.\n        email_confirmation_title: Require email confirmation\n        generate_tokens: Generate new code\n        generated_tokens: Generated codes\n        title: Require invite code for signup\n      show:\n        general: General Settings\n        invites: Invite Codes\n        title: Self-Hosting\n        danger_zone: Danger Zone\n        clear_cache: Clear data cache\n        clear_cache_warning: Clearing the data cache will remove all exchange rates, security prices, account balances, and other data. This will not delete accounts, transactions, categories, or other user-owned data.\n        confirm_clear_cache:\n          title: Clear data cache?\n          body: Are you sure you want to clear the data cache? This will remove all exchange rates, security prices, account balances, and other data. This action cannot be undone.\n      synth_settings:\n        api_calls_used: \"%{used} / %{limit} API calls used (%{percentage})\"\n        description: Input the API key provided by Synth\n        label: API Key\n        placeholder: Enter your API key here\n        plan: \"%{plan} plan\"\n        title: Synth Settings\n      update:\n        failure: Invalid setting value\n        success: Settings updated\n      clear_cache:\n        cache_cleared: Data cache has been cleared. This may take a few moments to complete.\n      not_authorized: You are not authorized to perform this action\n"
  },
  {
    "path": "config/locales/views/settings/securities/en.yml",
    "content": "---\nen:\n  settings:\n    securities:\n      show:\n        disable_mfa: Disable 2FA\n        disable_mfa_confirm: Are you sure you want to disable two-factor authentication?\n          This will make your account less secure.\n        enable_mfa: Enable 2FA\n        mfa_description: Add an extra layer of security to your account by requiring\n          a code from your authenticator app when signing in\n        mfa_title: Two-Factor Authentication\n"
  },
  {
    "path": "config/locales/views/shared/en.yml",
    "content": "---\nen:\n  shared:\n    confirm_modal:\n      accept: Confirm\n      body_html: \"<p>You will not be able to undo this decision</p>\"\n      cancel: Cancel\n      title: Are you sure?\n    money_field:\n      label: Amount\n    syncing_notice:\n      syncing: Syncing accounts data...\n    trend_change:\n      no_change: \"no change\"\n"
  },
  {
    "path": "config/locales/views/subscriptions/en.yml",
    "content": "en:\n  subscriptions:\n    self_hosted_alert: \"Maybe+ is not available in self-hosted mode.\"\n"
  },
  {
    "path": "config/locales/views/tag/deletions/en.yml",
    "content": "---\nen:\n  tag:\n    deletions:\n      create:\n        deleted: Tag deleted\n      new:\n        delete_and_leave_uncategorized: Delete \"%{tag_name}\"\n        delete_and_recategorize: Delete \"%{tag_name}\" and assign new tag\n        delete_tag: Delete tag?\n        explanation: \"%{tag_name} will be removed from transactions and other taggable\n          entities.  Instead of leaving them untagged, you can also assign a new tag\n          below.\"\n        replacement_tag_prompt: Select tag\n        tag: Tag\n"
  },
  {
    "path": "config/locales/views/tags/en.yml",
    "content": "---\nen:\n  tags:\n    create:\n      created: Tag created\n      error: 'Error creating tag: %{error}'\n    destroy:\n      deleted: Tag deleted\n    edit:\n      edit: Edit tag\n    form:\n      placeholder: Tag name\n    index:\n      empty: No tags yet\n      new: New tag\n      tags: Tags\n    new:\n      new: New tag\n    tag:\n      delete: Delete\n      edit: Edit\n    update:\n      updated: Tag updated\n"
  },
  {
    "path": "config/locales/views/trades/en.yml",
    "content": "---\nen:\n  trades:\n    form:\n      account: Transfer account (optional)\n      account_prompt: Search account\n      amount: Amount\n      holding: Ticker symbol\n      price: Price per share\n      qty: Quantity\n      submit: Add transaction\n      ticker_placeholder: AAPL\n      type: Type\n    header:\n      buy: Buy\n      current_market_price_label: Current Market Price\n      overview: Overview\n      purchase_price_label: Purchase Price\n      purchase_qty_label: Purchase Quantity\n      sell: Sell\n      symbol_label: Symbol\n      total_return_label: Unrealized gain/loss\n    new:\n      title: New transaction\n    show:\n      additional: Additional\n      cost_per_share_label: Cost per Share\n      date_label: Date\n      delete: Delete\n      delete_subtitle: This action cannot be undone\n      delete_title: Delete Trade\n      details: Details\n      exclude_subtitle: This trade will not be included in reports and calculations\n      exclude_title: Exclude from analytics\n      note_label: Note\n      note_placeholder: Add any additional notes here...\n      quantity_label: Quantity\n      settings: Settings\n"
  },
  {
    "path": "config/locales/views/transactions/en.yml",
    "content": "---\nen:\n  transactions:\n    form:\n      account: Account\n      account_prompt: Select an Account\n      amount: Amount\n      category: Category\n      category_prompt: Select a Category\n      date: Date\n      description: Description\n      description_placeholder: Describe transaction\n      expense: Expense\n      income: Income\n      none: (none)\n      note_label: Notes\n      note_placeholder: Enter a note\n      submit: Add transaction\n      tags_label: Tags\n      transfer: Transfer\n    new:\n      new_transaction: New transaction\n    show:\n      account_label: Account\n      amount: Amount\n      category_label: Category\n      date_label: Date\n      delete: Delete\n      delete_subtitle: This permanently deletes the transaction, affects your historical\n        balances, and cannot be undone.\n      delete_title: Delete transaction\n      details: Details\n      merchant_label: Merchant\n      name_label: Name\n      nature: Type\n      none: \"(none)\"\n      note_label: Notes\n      note_placeholder: Enter a note\n      overview: Overview\n      settings: Settings\n      tags_label: Tags\n      uncategorized: \"(uncategorized)\"\n    header:\n      edit_categories: Edit categories\n      edit_imports: Edit imports\n      edit_merchants: Edit merchants\n      edit_tags: Edit tags\n      import: Import\n    index:\n      transaction: transaction\n      transactions: transactions\n    searches:\n      filters:\n        amount_filter:\n          equal_to: Equal to\n          greater_than: Greater than\n          less_than: Less than\n          placeholder: '0'\n        badge:\n          expense: Expense\n          income: Income\n          on_or_after: on or after %{date}\n          on_or_before: on or before %{date}\n          transfer: Transfer\n        type_filter:\n          expense: Expense\n          income: Income\n          transfer: Transfer\n      menu:\n        account_filter: Account\n        amount_filter: Amount\n        apply: Apply\n        cancel: Cancel\n        category_filter: Category\n        clear_filters: Clear filters\n        date_filter: Date\n        merchant_filter: Merchant\n        tag_filter: Tag\n        type_filter: Type\n      search:\n        equal_to: equal to\n        greater_than: greater than\n        less_than: less than\n"
  },
  {
    "path": "config/locales/views/transfers/en.yml",
    "content": "---\nen:\n  transfers:\n    create:\n      success: Transfer created\n    destroy:\n      success: Transfer removed\n    form:\n      amount: Amount\n      date: Date\n      expense: Expense\n      from: From\n      income: Income\n      select_account: Select account\n      submit: Create transfer\n      to: To\n      transfer: Transfer\n    new:\n      title: New transfer\n    show:\n      delete: Remove transfer\n      delete_subtitle: This removes the transfer.  It will not delete the underlying\n        transactions.\n      delete_title: Remove transfer?\n      details: Details\n      note_label: Notes\n      note_placeholder: Add a note to this transfer\n      overview: Overview\n      settings: Settings\n    update:\n      success: Transfer updated\n"
  },
  {
    "path": "config/locales/views/users/en.yml",
    "content": "---\nen:\n  users:\n    destroy:\n      success: Your account has been deleted.\n    update:\n      email_change_failed: Failed to change email address.\n      email_change_initiated: Please check your new email address for confirmation\n        instructions.\n      success: Your profile has been updated.\n    reset:\n      success: Your account has been reset. Data will be deleted in the background in some time.\n      unauthorized: You are not authorized to perform this action\n"
  },
  {
    "path": "config/locales/views/valuations/en.yml",
    "content": "---\nen:\n  valuations:\n    form:\n      amount: Amount\n      submit: Add balance update\n    header:\n      balance: Balance\n    index:\n      change: change\n      date: date\n      new_entry: New entry\n      no_valuations: No valuations for this account yet\n      valuations: Value\n      value: value\n    new:\n      title: New balance\n    show:\n      amount: Amount\n      date_label: Date\n      delete: Delete\n      delete_subtitle: This action cannot be undone\n      delete_title: Delete Entry\n      details: Details\n      name_label: Name\n      name_placeholder: Enter a name for this entry\n      note_label: Notes\n      note_placeholder: Add any additional details about this entry\n      overview: Overview\n      settings: Settings\n"
  },
  {
    "path": "config/locales/views/vehicles/en.yml",
    "content": "---\nen:\n  vehicles:\n    edit:\n      edit: Edit %{account}\n    form:\n      make: Make\n      make_placeholder: Toyota\n      mileage: Mileage\n      mileage_placeholder: '15000'\n      mileage_unit: Unit\n      model: Model\n      model_placeholder: Camry\n      year: Year\n      year_placeholder: '2023'\n    new:\n      title: Enter vehicle details\n    overview:\n      current_price: Current Price\n      make_model: Make & Model\n      mileage: Mileage\n      purchase_price: Purchase Price\n      trend: Trend\n      unknown: Unknown\n      year: Year\n"
  },
  {
    "path": "config/puma.rb",
    "content": "# This configuration file will be evaluated by Puma. The top-level methods that\n# are invoked here are part of Puma's configuration DSL. For more information\n# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.\n\nrails_env = ENV.fetch(\"RAILS_ENV\", \"development\")\n\n# Puma starts a configurable number of processes (workers) and each process\n# serves each request in a thread from an internal thread pool.\n#\n# The ideal number of threads per worker depends both on how much time the\n# application spends waiting for IO operations and on how much you wish to\n# to prioritize throughput over latency.\n#\n# As a rule of thumb, increasing the number of threads will increase how much\n# traffic a given process can handle (throughput), but due to CRuby's\n# Global VM Lock (GVL) it has diminishing returns and will degrade the\n# response time (latency) of the application.\n#\n# The default is set to 3 threads as it's deemed a decent compromise between\n# throughput and latency for the average Rails application.\n#\n# Any libraries that use a connection pool or another resource pool should\n# be configured to provide at least as many connections as the number of\n# threads. This includes Active Record's `pool` parameter in `database.yml`.\nthreads_count = ENV.fetch(\"RAILS_MAX_THREADS\") { 3 }\nthreads threads_count, threads_count\n\nif rails_env == \"production\"\n  # If you are running more than 1 thread per process, the workers count\n  # should be equal to the number of processors (CPU cores) in production.\n  #\n  # It defaults to 1 because it's impossible to reliably detect how many\n  # CPU cores are available. Make sure to set the `WEB_CONCURRENCY` environment\n  # variable to match the number of processors.\n  workers_count = Integer(ENV.fetch(\"WEB_CONCURRENCY\") { 1 })\n  workers workers_count if workers_count > 1\n\n  preload_app!\nend\n\n# Specifies the `port` that Puma will listen on to receive requests; default is 3000.\nport ENV.fetch(\"PORT\") { 3000 }\n\n# Specifies the `environment` that Puma will run in.\nenvironment rails_env\n\n# Allow puma to be restarted by `bin/rails restart` command.\nplugin :tmp_restart\n\npidfile ENV[\"PIDFILE\"] if ENV[\"PIDFILE\"]\n\nif rails_env == \"development\"\n  # Specifies a very generous `worker_timeout` so that the worker\n  # isn't killed by Puma when suspended by a debugger.\n  worker_timeout 3600\nend\n"
  },
  {
    "path": "config/routes.rb",
    "content": "require \"sidekiq/web\"\nrequire \"sidekiq/cron/web\"\n\nRails.application.routes.draw do\n  use_doorkeeper\n  # MFA routes\n  resource :mfa, controller: \"mfa\", only: [ :new, :create ] do\n    get :verify\n    post :verify, to: \"mfa#verify_code\"\n    delete :disable\n  end\n\n  mount Lookbook::Engine, at: \"/design-system\"\n\n  # Uses basic auth - see config/initializers/sidekiq.rb\n  mount Sidekiq::Web => \"/sidekiq\"\n\n  # AI chats\n  resources :chats do\n    resources :messages, only: :create\n\n    member do\n      post :retry\n    end\n  end\n\n  resources :family_exports, only: %i[new create index] do\n    member do\n      get :download\n    end\n  end\n\n  get \"changelog\", to: \"pages#changelog\"\n  get \"feedback\", to: \"pages#feedback\"\n\n  resource :current_session, only: %i[update]\n\n  resource :registration, only: %i[new create]\n  resources :sessions, only: %i[new create destroy]\n  resource :password_reset, only: %i[new create edit update]\n  resource :password, only: %i[edit update]\n  resource :email_confirmation, only: :new\n\n  resources :users, only: %i[update destroy] do\n    delete :reset, on: :member\n    patch :rule_prompt_settings, on: :member\n  end\n\n  resource :onboarding, only: :show do\n    collection do\n      get :preferences\n      get :goals\n      get :trial\n    end\n  end\n\n  namespace :settings do\n    resource :profile, only: [ :show, :destroy ]\n    resource :preferences, only: :show\n    resource :hosting, only: %i[show update] do\n      delete :clear_cache, on: :collection\n    end\n    resource :billing, only: :show\n    resource :security, only: :show\n    resource :api_key, only: [ :show, :new, :create, :destroy ]\n  end\n\n  resource :subscription, only: %i[new show create] do\n    collection do\n      get :upgrade\n      get :success\n    end\n  end\n\n  resources :tags, except: :show do\n    resources :deletions, only: %i[new create], module: :tag\n    delete :destroy_all, on: :collection\n  end\n\n  namespace :category do\n    resource :dropdown, only: :show\n  end\n\n  resources :categories, except: :show do\n    resources :deletions, only: %i[new create], module: :category\n\n    post :bootstrap, on: :collection\n    delete :destroy_all, on: :collection\n  end\n\n  resources :budgets, only: %i[index show edit update], param: :month_year do\n    get :picker, on: :collection\n\n    resources :budget_categories, only: %i[index show update]\n  end\n\n  resources :family_merchants, only: %i[index new create edit update destroy]\n\n  resources :transfers, only: %i[new create destroy show update]\n\n  resources :imports, only: %i[index new show create destroy] do\n    member do\n      post :publish\n      put :revert\n      put :apply_template\n    end\n\n    resource :upload, only: %i[show update], module: :import\n    resource :configuration, only: %i[show update], module: :import\n    resource :clean, only: :show, module: :import\n    resource :confirm, only: :show, module: :import\n\n    resources :rows, only: %i[show update], module: :import\n    resources :mappings, only: :update, module: :import\n  end\n\n  resources :holdings, only: %i[index new show destroy]\n  resources :trades, only: %i[show new create update destroy]\n  resources :valuations, only: %i[show new create update destroy] do\n    post :confirm_create, on: :collection\n    post :confirm_update, on: :member\n  end\n\n  namespace :transactions do\n    resource :bulk_deletion, only: :create\n    resource :bulk_update, only: %i[new create]\n  end\n\n  resources :transactions, only: %i[index new create show update destroy] do\n    resource :transfer_match, only: %i[new create]\n    resource :category, only: :update, controller: :transaction_categories\n\n    collection do\n      delete :clear_filter\n    end\n  end\n\n  resources :accountable_sparklines, only: :show, param: :accountable_type\n\n  direct :entry do |entry, options|\n    if entry.new_record?\n      route_for entry.entryable_name.pluralize, options\n    else\n      route_for entry.entryable_name, entry, options\n    end\n  end\n\n  resources :rules, except: :show do\n    member do\n      get :confirm\n      post :apply\n    end\n\n    collection do\n      delete :destroy_all\n    end\n  end\n\n  resources :accounts, only: %i[index new show destroy], shallow: true do\n    member do\n      post :sync\n      get :sparkline\n      patch :toggle_active\n    end\n\n    collection do\n      post :sync_all\n    end\n  end\n\n  # Convenience routes for polymorphic paths\n  # Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123\n  direct :edit_account do |model, options|\n    route_for \"edit_#{model.accountable_name}\", model, options\n  end\n\n  resources :depositories, only: %i[new create edit update]\n  resources :investments, only: %i[new create edit update]\n  resources :properties, only: %i[new create edit update] do\n    member do\n      get :balances\n      patch :update_balances\n\n      get :address\n      patch :update_address\n    end\n  end\n  resources :vehicles, only: %i[new create edit update]\n  resources :credit_cards, only: %i[new create edit update]\n  resources :loans, only: %i[new create edit update]\n  resources :cryptos, only: %i[new create edit update]\n  resources :other_assets, only: %i[new create edit update]\n  resources :other_liabilities, only: %i[new create edit update]\n\n  resources :securities, only: :index\n\n  resources :invite_codes, only: %i[index create]\n\n  resources :invitations, only: [ :new, :create, :destroy ] do\n    get :accept, on: :member\n  end\n\n  # API routes\n  namespace :api do\n    namespace :v1 do\n      # Authentication endpoints\n      post \"auth/signup\", to: \"auth#signup\"\n      post \"auth/login\", to: \"auth#login\"\n      post \"auth/refresh\", to: \"auth#refresh\"\n\n      # Production API endpoints\n      resources :accounts, only: [ :index ]\n      resources :transactions, only: [ :index, :show, :create, :update, :destroy ]\n      resource :usage, only: [ :show ], controller: \"usage\"\n\n      resources :chats, only: [ :index, :show, :create, :update, :destroy ] do\n        resources :messages, only: [ :create ] do\n          post :retry, on: :collection\n        end\n      end\n\n      # Test routes for API controller testing (only available in test environment)\n      if Rails.env.test?\n        get \"test\", to: \"test#index\"\n        get \"test_not_found\", to: \"test#not_found\"\n        get \"test_family_access\", to: \"test#family_access\"\n        get \"test_scope_required\", to: \"test#scope_required\"\n        get \"test_multiple_scopes_required\", to: \"test#multiple_scopes_required\"\n      end\n    end\n  end\n\n\n\n  resources :currencies, only: %i[show]\n\n  resources :impersonation_sessions, only: [ :create ] do\n    post :join, on: :collection\n    delete :leave, on: :collection\n\n    member do\n      put :approve\n      put :reject\n      put :complete\n    end\n  end\n\n  resources :plaid_items, only: %i[new edit create destroy] do\n    member do\n      post :sync\n    end\n  end\n\n  namespace :webhooks do\n    post \"plaid\"\n    post \"plaid_eu\"\n    post \"stripe\"\n  end\n\n  get \"redis-configuration-error\", to: \"pages#redis_configuration_error\"\n\n  # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.\n  # Can be used by load balancers and uptime monitors to verify that the app is live.\n  get \"up\" => \"rails/health#show\", as: :rails_health_check\n\n  # Render dynamic PWA files from app/views/pwa/*\n  get \"service-worker\" => \"rails/pwa#service_worker\", as: :pwa_service_worker\n  get \"manifest\" => \"rails/pwa#manifest\", as: :pwa_manifest\n\n  get \"imports/:import_id/upload/sample_csv\", to: \"import/uploads#sample_csv\", as: :import_upload_sample_csv\n\n  get \"privacy\", to: redirect(\"https://maybefinance.com/privacy\")\n  get \"terms\", to: redirect(\"https://maybefinance.com/tos\")\n\n  # Defines the root path route (\"/\")\n  root \"pages#dashboard\"\nend\n"
  },
  {
    "path": "config/schedule.yml",
    "content": "import_market_data:\n  cron: \"0 22 * * 1-5\" # 5:00 PM EST / 6:00 PM EDT (NY time) Monday through Friday\n  class: \"ImportMarketDataJob\"\n  queue: \"scheduled\"\n  description: \"Imports market data daily at 5:00 PM EST (1 hour after market close)\"\n  args:\n    mode: \"full\"\n    clear_cache: false\n  \nclean_syncs:\n  cron: \"0 * * * *\" # every hour\n  class: \"SyncCleanerJob\"\n  queue: \"scheduled\"\n  description: \"Cleans up stale syncs\"\n\nrun_security_health_checks:\n  cron: \"0 2 * * 1-5\" # 2:00 AM EST / 3:00 AM EDT (NY time) Monday through Friday\n  class: \"SecurityHealthCheckJob\"\n  queue: \"scheduled\"\n  description: \"Runs security health checks to detect issues with security data\"\n"
  },
  {
    "path": "config/sidekiq.yml",
    "content": "concurrency: <%= ENV.fetch(\"RAILS_MAX_THREADS\") { 3 } %>\nqueues:\n  - [scheduled, 10] # For cron-like jobs (e.g. \"daily market data sync\")\n  - [high_priority, 4]\n  - [medium_priority, 2]\n  - [low_priority, 1]\n  - [default, 1]\n"
  },
  {
    "path": "config/storage.yml",
    "content": "local:\n  service: Disk\n  root: <%= Rails.root.join(\"storage\") %>\n\ntest:\n  service: Disk\n  root: <%= Rails.root.join(\"tmp/storage\") %>\n\namazon:\n  service: S3\n  access_key_id: <%= ENV[\"S3_ACCESS_KEY_ID\"] %> \n  secret_access_key: <%= ENV[\"S3_SECRET_ACCESS_KEY\"] %>\n  region: <%= ENV[\"S3_REGION\"] || \"us-east-1\" %>\n  bucket: <%= ENV[\"S3_BUCKET\"] %>\n\ncloudflare:\n  service: S3\n  endpoint: https://<%= ENV['CLOUDFLARE_ACCOUNT_ID'] %>.r2.cloudflarestorage.com\n  access_key_id: <%= ENV['CLOUDFLARE_ACCESS_KEY_ID'] %>\n  secret_access_key: <%= ENV['CLOUDFLARE_SECRET_ACCESS_KEY'] %>\n  region: auto\n  bucket: <%= ENV['CLOUDFLARE_BUCKET'] %>\n  request_checksum_calculation: \"when_required\"\n  response_checksum_validation: \"when_required\"\n  \n"
  },
  {
    "path": "config.ru",
    "content": "# This file is used by Rack-based servers to start the application.\n\nrequire_relative \"config/environment\"\n\nrun Rails.application\nRails.application.load_server\n"
  },
  {
    "path": "db/migrate/20240201183314_enable_uuid.rb",
    "content": "class EnableUuid < ActiveRecord::Migration[7.2]\n  def change\n    enable_extension 'pgcrypto' unless extension_enabled?('pgcrypto')\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240201184038_create_families.rb",
    "content": "class CreateFamilies < ActiveRecord::Migration[7.2]\n  def change\n    create_table :families, id: :uuid do |t|\n      t.string :name\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240201184212_create_users.rb",
    "content": "class CreateUsers < ActiveRecord::Migration[7.2]\n  def change\n    create_table :users, id: :uuid do |t|\n      t.references :family, null: false, foreign_key: true, type: :uuid\n      t.string :first_name\n      t.string :last_name\n      t.string :email\n      t.string :password_digest\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240202015428_create_accounts.rb",
    "content": "class CreateAccounts < ActiveRecord::Migration[7.2]\n  def change\n    create_table :accounts, id: :uuid do |t|\n      t.string :type\n      t.string :subtype\n      t.references :family, null: false, foreign_key: true, type: :uuid\n      t.string :name\n      t.bigint :balance, default: 0\n      t.string :currency, default: \"USD\"\n\n      t.timestamps\n    end\n\n    add_index :accounts, :type\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240202191425_create_account_loans.rb",
    "content": "class CreateAccountLoans < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_loans, id: :uuid do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240202191746_add_accountable_to_account.rb",
    "content": "class AddAccountableToAccount < ActiveRecord::Migration[7.2]\n  def change\n    add_column :accounts, :accountable_type, :string\n    add_column :accounts, :accountable_id, :uuid\n    add_index :accounts, :accountable_type\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240202192214_create_account_depositories.rb",
    "content": "class CreateAccountDepositories < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_depositories, id: :uuid do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240202192231_create_account_credits.rb",
    "content": "class CreateAccountCredits < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_credits, id: :uuid do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240202192238_create_account_investments.rb",
    "content": "class CreateAccountInvestments < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_investments, id: :uuid do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240202192312_create_account_properties.rb",
    "content": "class CreateAccountProperties < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_properties, id: :uuid do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240202192319_create_account_vehicles.rb",
    "content": "class CreateAccountVehicles < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_vehicles, id: :uuid do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240202192327_create_account_other_assets.rb",
    "content": "class CreateAccountOtherAssets < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_other_assets, id: :uuid do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240202192333_create_account_other_liabilities.rb",
    "content": "class CreateAccountOtherLiabilities < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_other_liabilities, id: :uuid do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240202230325_create_invite_codes.rb",
    "content": "class CreateInviteCodes < ActiveRecord::Migration[7.2]\n  def change\n    create_table :invite_codes, id: :uuid do |t|\n      t.string :token, null: false\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240203030754_remove_type_from_accounts.rb",
    "content": "class RemoveTypeFromAccounts < ActiveRecord::Migration[7.2]\n  def change\n    remove_column :accounts, :type\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240203050018_add_token_index_to_invite_codes.rb",
    "content": "class AddTokenIndexToInviteCodes < ActiveRecord::Migration[7.2]\n  def change\n    add_index :invite_codes, :token, unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240206031739_replace_money_field.rb",
    "content": "class ReplaceMoneyField < ActiveRecord::Migration[7.2]\n  def change\n    add_column :accounts, :balance_cents, :integer\n    change_column :accounts, :balance_cents, :integer, limit: 8\n    remove_column :accounts, :balance\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240209153232_add_currency_to_families.rb",
    "content": "class AddCurrencyToFamilies < ActiveRecord::Migration[7.2]\n  def change\n    add_column :families, :currency, :string, default: 'USD'\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240209174912_redo_money_storage.rb",
    "content": "class RedoMoneyStorage < ActiveRecord::Migration[7.2]\n  def change\n    add_column :accounts, :original_balance, :decimal, precision: 19, scale: 4, default: 0.0\n    add_column :accounts, :original_currency, :string, default: \"USD\"\n    add_column :accounts, :converted_balance, :decimal, precision: 19, scale: 4, default: 0.0\n    add_column :accounts, :converted_currency, :string, default: \"USD\"\n\n    remove_column :accounts, :balance_cents\n    remove_column :accounts, :currency\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240209200519_create_currencies.rb",
    "content": "class CreateCurrencies < ActiveRecord::Migration[7.2]\n  def change\n    create_table :currencies, id: :uuid do |t|\n      t.string :name\n      t.string :iso_code\n\n      t.timestamps\n    end\n\n    add_index :currencies, :iso_code, unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240209200924_create_exchange_rates.rb",
    "content": "class CreateExchangeRates < ActiveRecord::Migration[7.2]\n  def change\n    create_table :exchange_rates, id: :uuid do |t|\n      t.string :base_currency, null: false\n      t.string :converted_currency, null: false\n      t.decimal :rate\n      t.date :date\n\n      t.timestamps\n    end\n\n    add_index :exchange_rates, :base_currency\n    add_index :exchange_rates, :converted_currency\n    add_index :exchange_rates, %i[base_currency converted_currency date], unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240210155058_create_good_jobs.rb",
    "content": "# frozen_string_literal: true\n\nclass CreateGoodJobs < ActiveRecord::Migration[7.2]\n  def change\n    # Uncomment for Postgres v12 or earlier to enable gen_random_uuid() support\n    # enable_extension 'pgcrypto'\n\n    create_table :good_jobs, id: :uuid do |t|\n      t.text :queue_name\n      t.integer :priority\n      t.jsonb :serialized_params\n      t.datetime :scheduled_at\n      t.datetime :performed_at\n      t.datetime :finished_at\n      t.text :error\n\n      t.timestamps\n\n      t.uuid :active_job_id\n      t.text :concurrency_key\n      t.text :cron_key\n      t.uuid :retried_good_job_id\n      t.datetime :cron_at\n\n      t.uuid :batch_id\n      t.uuid :batch_callback_id\n\n      t.boolean :is_discrete\n      t.integer :executions_count\n      t.text :job_class\n      t.integer :error_event, limit: 2\n      t.text :labels, array: true\n    end\n\n    create_table :good_job_batches, id: :uuid do |t|\n      t.timestamps\n      t.text :description\n      t.jsonb :serialized_properties\n      t.text :on_finish\n      t.text :on_success\n      t.text :on_discard\n      t.text :callback_queue_name\n      t.integer :callback_priority\n      t.datetime :enqueued_at\n      t.datetime :discarded_at\n      t.datetime :finished_at\n    end\n\n    create_table :good_job_executions, id: :uuid do |t|\n      t.timestamps\n\n      t.uuid :active_job_id, null: false\n      t.text :job_class\n      t.text :queue_name\n      t.jsonb :serialized_params\n      t.datetime :scheduled_at\n      t.datetime :finished_at\n      t.text :error\n      t.integer :error_event, limit: 2\n    end\n\n    create_table :good_job_processes, id: :uuid do |t|\n      t.timestamps\n      t.jsonb :state\n    end\n\n    create_table :good_job_settings, id: :uuid do |t|\n      t.timestamps\n      t.text :key\n      t.jsonb :value\n      t.index :key, unique: true\n    end\n\n    add_index :good_jobs, :scheduled_at, where: \"(finished_at IS NULL)\", name: :index_good_jobs_on_scheduled_at\n    add_index :good_jobs, [ :queue_name, :scheduled_at ], where: \"(finished_at IS NULL)\", name: :index_good_jobs_on_queue_name_and_scheduled_at\n    add_index :good_jobs, [ :active_job_id, :created_at ], name: :index_good_jobs_on_active_job_id_and_created_at\n    add_index :good_jobs, :concurrency_key, where: \"(finished_at IS NULL)\", name: :index_good_jobs_on_concurrency_key_when_unfinished\n    add_index :good_jobs, [ :cron_key, :created_at ], where: \"(cron_key IS NOT NULL)\", name: :index_good_jobs_on_cron_key_and_created_at_cond\n    add_index :good_jobs, [ :cron_key, :cron_at ], where: \"(cron_key IS NOT NULL)\", unique: true, name: :index_good_jobs_on_cron_key_and_cron_at_cond\n    add_index :good_jobs, [ :finished_at ], where: \"retried_good_job_id IS NULL AND finished_at IS NOT NULL\", name: :index_good_jobs_jobs_on_finished_at\n    add_index :good_jobs, [ :priority, :created_at ], order: { priority: \"DESC NULLS LAST\", created_at: :asc },\n      where: \"finished_at IS NULL\", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished\n    add_index :good_jobs, [ :priority, :created_at ], order: { priority: \"ASC NULLS LAST\", created_at: :asc },\n      where: \"finished_at IS NULL\", name: :index_good_job_jobs_for_candidate_lookup\n    add_index :good_jobs, [ :batch_id ], where: \"batch_id IS NOT NULL\"\n    add_index :good_jobs, [ :batch_callback_id ], where: \"batch_callback_id IS NOT NULL\"\n    add_index :good_jobs, :labels, using: :gin, where: \"(labels IS NOT NULL)\", name: :index_good_jobs_on_labels\n\n    add_index :good_job_executions, [ :active_job_id, :created_at ], name: :index_good_job_executions_on_active_job_id_and_created_at\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240212150110_create_account_balances.rb",
    "content": "class CreateAccountBalances < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_balances, id: :uuid do |t|\n      t.references :account, null: false, type: :uuid, foreign_key: { on_delete: :cascade }\n      t.date :date, null: false\n      t.decimal :balance, precision: 19, scale: 4, null: false\n      t.string :currency, default: \"USD\", null: false\n\n      t.timestamps\n    end\n\n    add_index :account_balances, [ :account_id, :date ], unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240215201527_create_valuations.rb",
    "content": "class CreateValuations < ActiveRecord::Migration[7.2]\n  def change\n    create_table :valuations, id: :uuid do |t|\n      t.string :type, null: false\n      t.references :account, null: false, type: :uuid, foreign_key: { on_delete: :cascade }\n      t.date :date, null: false\n      t.decimal :value, precision: 19, scale: 4, null: false\n      t.string :currency, default: \"USD\", null: false\n\n      t.timestamps\n    end\n\n    # Since all dates are daily (no concept of time of day), limit account to 1 valuation per day\n    add_index :valuations, [ :account_id, :date ], unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240221004818_remove_valuation_type.rb",
    "content": "class RemoveValuationType < ActiveRecord::Migration[7.2]\n  def change\n    remove_column :valuations, :type, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240222144849_add_status_to_account.rb",
    "content": "class AddStatusToAccount < ActiveRecord::Migration[7.2]\n  def change\n    add_column :accounts, :status, :string, default: \"OK\"\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240223162105_create_transactions.rb",
    "content": "class CreateTransactions < ActiveRecord::Migration[7.2]\n  def change\n    create_table :transactions, id: :uuid do |t|\n      t.string :name\n      t.date :date, null: false\n      t.decimal :amount, precision: 19, scale: 4, null: false\n      t.string :currency, default: \"USD\", null: false\n      t.references :account, null: false, type: :uuid, foreign_key: { on_delete: :cascade }\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240227142457_rename_account_balance.rb",
    "content": "class RenameAccountBalance < ActiveRecord::Migration[7.2]\n  def change\n    rename_column :accounts, :original_balance, :balance\n    rename_column :accounts, :original_currency, :currency\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240302145715_add_classification_to_accounts.rb",
    "content": "class AddClassificationToAccounts < ActiveRecord::Migration[7.2]\n  def change\n    change_table :accounts do |t|\n      t.virtual(\n        :classification,\n        type: :string,\n        stored: true,\n        as: <<-SQL\n          CASE\n            WHEN accountable_type IN ('Account::Loan', 'Account::Credit', 'Account::OtherLiability')\n            THEN 'liability'\n            ELSE 'asset'\n          END\n        SQL\n      )\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240306193345_add_is_active_to_account.rb",
    "content": "class AddIsActiveToAccount < ActiveRecord::Migration[7.2]\n  def change\n    add_column :accounts, :is_active, :boolean, default: true, null: false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240307082827_create_transaction_categories.rb",
    "content": "class CreateTransactionCategories < ActiveRecord::Migration[7.2]\n  def change\n    create_table :transaction_categories, id: :uuid do |t|\n      t.string \"name\", null: false\n      t.string \"color\", default: \"#6172F3\", null: false\n      t.string \"internal_category\"\n      t.references :family, null: false, foreign_key: true, type: :uuid\n\n      t.timestamps\n    end\n\n    add_reference :transactions, :category, foreign_key: { to_table: :transaction_categories }, type: :uuid\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240308121431_remove_currency_table.rb",
    "content": "class RemoveCurrencyTable < ActiveRecord::Migration[7.2]\n  def change\n    drop_table :currencies\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240308214956_add_notes_and_excluded_to_transaction.rb",
    "content": "class AddNotesAndExcludedToTransaction < ActiveRecord::Migration[7.2]\n  def change\n    add_column :transactions, :excluded, :boolean, default: false\n    add_column :transactions, :notes, :text\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240309180636_add_sync_status_fields_to_account.rb",
    "content": "class AddSyncStatusFieldsToAccount < ActiveRecord::Migration[7.2]\n  def change\n    create_enum :account_status, %w[ok syncing error]\n\n    remove_column :accounts, :status, :string\n\n    change_table :accounts do |t|\n      t.enum :status, enum_type: :account_status, default: \"ok\", null: false\n      t.jsonb :sync_warnings, default: '[]', null: false\n      t.jsonb :sync_errors, default: '[]', null: false\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240313141813_update_unique_indexes_for_account_balance_and_exchange_rate.rb",
    "content": "class UpdateUniqueIndexesForAccountBalanceAndExchangeRate < ActiveRecord::Migration[7.2]\n  def change\n    rename_index :exchange_rates, 'idx_on_base_currency_converted_currency_date_255be792be', 'index_exchange_rates_on_base_converted_date_unique'\n    remove_index :account_balances, name: \"index_account_balances_on_account_id_and_date\"\n    add_index :account_balances, [ :account_id, :date, :currency ], unique: true, name: \"index_account_balances_on_account_id_date_currency_unique\"\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240313203622_remove_converted_balance_from_account.rb",
    "content": "class RemoveConvertedBalanceFromAccount < ActiveRecord::Migration[7.2]\n  def change\n    remove_column :accounts, :converted_balance, :decimal\n    remove_column :accounts, :converted_currency, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240319154732_create_account_cryptos.rb",
    "content": "class CreateAccountCryptos < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_cryptos, id: :uuid do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240325064211_add_uniq_index_to_users_email.rb",
    "content": "class AddUniqIndexToUsersEmail < ActiveRecord::Migration[7.2]\n  def change\n    add_index :users, :email, unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240401213443_add_last_sync_date_to_accounts.rb",
    "content": "class AddLastSyncDateToAccounts < ActiveRecord::Migration[7.2]\n  def change\n    add_column :accounts, :last_sync_date, :date\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240403192649_add_last_login_at_to_users.rb",
    "content": "class AddLastLoginAtToUsers < ActiveRecord::Migration[7.2]\n  def change\n    add_column :users, :last_login_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240404112829_change_transaction_category_delete_behavior.rb",
    "content": "class ChangeTransactionCategoryDeleteBehavior < ActiveRecord::Migration[7.2]\n  def change\n    remove_foreign_key :transactions, :transaction_categories, column: :category_id\n    add_foreign_key :transactions, :transaction_categories, column: :category_id, on_delete: :nullify\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240410183531_create_settings.rb",
    "content": "class CreateSettings < ActiveRecord::Migration[7.2]\n  def self.up\n    create_table :settings do |t|\n      t.string  :var,        null: false\n      t.text    :value,      null: true\n      t.timestamps\n    end\n\n    add_index :settings, %i[var], unique: true\n  end\n\n  def self.down\n    drop_table :settings\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240411102931_add_last_seen_upgrade_to_user.rb",
    "content": "class AddLastSeenUpgradeToUser < ActiveRecord::Migration[7.2]\n  def change\n    # Self-hosted users will be prompted to upgrade to the latest commit or release.\n    add_column :users, :last_prompted_upgrade_commit_sha, :string\n\n    # All users will be notified when a new commit or release has successfully been deployed.\n    add_column :users, :last_alerted_upgrade_commit_sha, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240425000110_add_role_to_users.rb",
    "content": "class AddRoleToUsers < ActiveRecord::Migration[7.2]\n  def change\n    create_enum :user_role, %w[admin member]\n    add_column :users, :role, :user_role, default: \"member\", null: false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240426162500_create_active_storage_tables.active_storage.rb",
    "content": "# This migration comes from active_storage (originally 20170806125915)\nclass CreateActiveStorageTables < ActiveRecord::Migration[7.0]\n  def change\n    # Use Active Record's configured type for primary and foreign keys\n    primary_key_type, foreign_key_type = primary_and_foreign_key_types\n\n    create_table :active_storage_blobs, id: primary_key_type do |t|\n      t.string   :key,          null: false\n      t.string   :filename,     null: false\n      t.string   :content_type\n      t.text     :metadata\n      t.string   :service_name, null: false\n      t.bigint   :byte_size,    null: false\n      t.string   :checksum\n\n      if connection.supports_datetime_with_precision?\n        t.datetime :created_at, precision: 6, null: false\n      else\n        t.datetime :created_at, null: false\n      end\n\n      t.index [ :key ], unique: true\n    end\n\n    create_table :active_storage_attachments, id: primary_key_type do |t|\n      t.string     :name,     null: false\n      t.references :record,   null: false, polymorphic: true, index: false, type: foreign_key_type\n      t.references :blob,     null: false, type: foreign_key_type\n\n      if connection.supports_datetime_with_precision?\n        t.datetime :created_at, precision: 6, null: false\n      else\n        t.datetime :created_at, null: false\n      end\n\n      t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true\n      t.foreign_key :active_storage_blobs, column: :blob_id\n    end\n\n    create_table :active_storage_variant_records, id: primary_key_type do |t|\n      t.belongs_to :blob, null: false, index: false, type: foreign_key_type\n      t.string :variation_digest, null: false\n\n      t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true\n      t.foreign_key :active_storage_blobs, column: :blob_id\n    end\n  end\n\n  private\n    def primary_and_foreign_key_types\n      config = Rails.configuration.generators\n      setting = config.options[config.orm][:primary_key_type]\n      primary_key_type = setting || :primary_key\n      foreign_key_type = setting || :bigint\n      [ primary_key_type, foreign_key_type ]\n    end\nend\n"
  },
  {
    "path": "db/migrate/20240426191312_add_transaction_merchants.rb",
    "content": "class AddTransactionMerchants < ActiveRecord::Migration[7.2]\n  def change\n    create_table :transaction_merchants, id: :uuid do |t|\n      t.string \"name\", null: false\n      t.string \"color\", default: \"#e99537\", null: false\n      t.references :family, null: false, foreign_key: true, type: :uuid\n\n      t.timestamps\n    end\n\n    add_reference :transactions, :merchant, foreign_key: { to_table: :transaction_merchants }, type: :uuid\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240430111641_add_active_to_users.rb",
    "content": "class AddActiveToUsers < ActiveRecord::Migration[7.2]\n  def change\n    add_column :users, :active, :boolean, default: true, null: false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240502205006_create_imports.rb",
    "content": "class CreateImports < ActiveRecord::Migration[7.2]\n  def change\n    create_enum :import_status, %w[pending importing complete failed]\n\n    create_table :imports, id: :uuid do |t|\n      t.references :account, null: false, foreign_key: true, type: :uuid\n      t.jsonb :column_mappings\n      t.enum :status, enum_type: :import_status, default: \"pending\"\n      t.string :raw_csv_str\n      t.string :normalized_csv_str\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240520074309_add_admin_role_to_current_users.rb",
    "content": "class AddAdminRoleToCurrentUsers < ActiveRecord::Migration[7.2]\n  def up\n    User.update_all(role: \"admin\")\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240522133147_create_tags.rb",
    "content": "class CreateTags < ActiveRecord::Migration[7.2]\n  def change\n    create_table :tags, id: :uuid do |t|\n      t.string :name\n      t.string \"color\", default: \"#e99537\", null: false\n      t.references :family, null: false, foreign_key: true, type: :uuid\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240522151453_create_taggings.rb",
    "content": "class CreateTaggings < ActiveRecord::Migration[7.2]\n  def change\n    create_table :taggings, id: :uuid do |t|\n      t.references :tag, null: false, foreign_key: true, type: :uuid\n      t.references :taggable, polymorphic: true, type: :uuid\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240524203959_change_account_error_columns_default.rb",
    "content": "class ChangeAccountErrorColumnsDefault < ActiveRecord::Migration[7.2]\n  def up\n    change_column_default :accounts, :sync_warnings, from: \"[]\", to: []\n    change_column_default :accounts, :sync_errors, from: \"[]\", to: []\n    Account.update_all(sync_warnings: [])\n    Account.update_all(sync_errors: [])\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240612164751_create_institutions.rb",
    "content": "class CreateInstitutions < ActiveRecord::Migration[7.2]\n  def change\n    create_table :institutions, id: :uuid do |t|\n      t.string :name, null: false\n      t.string :logo_url\n      t.references :family, null: false, foreign_key: true, type: :uuid\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240612164944_add_institution_to_accounts.rb",
    "content": "class AddInstitutionToAccounts < ActiveRecord::Migration[7.2]\n  def change\n    add_reference :accounts, :institution, foreign_key: true, type: :uuid\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240614120946_create_transfers.rb",
    "content": "class CreateTransfers < ActiveRecord::Migration[7.2]\n  def change\n    create_table :transfers, id: :uuid do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240614121110_add_transfer_fields_to_transaction.rb",
    "content": "class AddTransferFieldsToTransaction < ActiveRecord::Migration[7.2]\n  def change\n    change_table :transactions do |t|\n      t.references :transfer, foreign_key: true, type: :uuid\n      t.boolean :marked_as_transfer, default: false, null: false\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240619125949_rename_accountable_tables.rb",
    "content": "class RenameAccountableTables < ActiveRecord::Migration[7.2]\n  def change\n    rename_table :account_depositories, :depositories\n    rename_table :account_investments, :investments\n    rename_table :account_credits, :credit_cards\n    rename_table :account_properties, :properties\n    rename_table :account_vehicles, :vehicles\n    rename_table :account_loans, :loans\n    rename_table :account_cryptos, :cryptos\n    rename_table :account_other_assets, :other_assets\n    rename_table :account_other_liabilities, :other_liabilities\n\n    reversible do |dir|\n      dir.up do\n        update_accountable_types(\n          'Account::Depository' => 'Depository',\n          'Account::Investment' => 'Investment',\n          'Account::Credit' => 'CreditCard',\n          'Account::Property' => 'Property',\n          'Account::Vehicle' => 'Vehicle',\n          'Account::Loan' => 'Loan',\n          'Account::Crypto' => 'Crypto',\n          'Account::OtherAsset' => 'OtherAsset',\n          'Account::OtherLiability' => 'OtherLiability'\n        )\n\n        remove_column :accounts, :classification, :virtual\n\n        change_table :accounts do |t|\n          t.virtual(\n            :classification,\n            type: :string,\n            stored: true,\n            as: <<-SQL\n              CASE\n                WHEN accountable_type IN ('Loan', 'CreditCard', 'OtherLiability')\n                THEN 'liability'\n                ELSE 'asset'\n              END\n            SQL\n          )\n        end\n      end\n\n      dir.down do\n        update_accountable_types(\n          'Depository' => 'Account::Depository',\n          'Investment' => 'Account::Investment',\n          'CreditCard' => 'Account::Credit',\n          'Property' => 'Account::Property',\n          'Vehicle' => 'Account::Vehicle',\n          'Loan' => 'Account::Loan',\n          'Crypto' => 'Account::Crypto',\n          'OtherAsset' => 'Account::OtherAsset',\n          'OtherLiability' => 'Account::OtherLiability'\n        )\n\n        remove_column :accounts, :classification, :virtual\n\n        change_table :accounts do |t|\n          t.virtual(\n            :classification,\n            type: :string,\n            stored: true,\n            as: <<-SQL\n              CASE\n                WHEN accountable_type IN ('Account::Loan', 'Account::Credit', 'Account::OtherLiability')\n                THEN 'liability'\n                ELSE 'asset'\n              END\n            SQL\n          )\n        end\n      end\n    end\n  end\n\n  private\n\n    def update_accountable_types(mapping)\n      Account.reset_column_information\n\n      mapping.each do |old_type, new_type|\n        Account.where(accountable_type: old_type).update_all(accountable_type: new_type)\n      end\n    end\nend\n"
  },
  {
    "path": "db/migrate/20240620114307_rename_categories_table.rb",
    "content": "class RenameCategoriesTable < ActiveRecord::Migration[7.2]\n  def change\n    rename_table :transaction_categories, :categories\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240620122201_rename_merchants_table.rb",
    "content": "class RenameMerchantsTable < ActiveRecord::Migration[7.2]\n  def change\n    rename_table :transaction_merchants, :merchants\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240620125026_rename_transfer_table.rb",
    "content": "class RenameTransferTable < ActiveRecord::Migration[7.2]\n  def change\n    rename_table :transfers, :account_transfers\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240620221801_rename_valuation_table.rb",
    "content": "class RenameValuationTable < ActiveRecord::Migration[7.2]\n  def change\n    rename_table :valuations, :account_valuations\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240621212528_rename_transactions_table.rb",
    "content": "class RenameTransactionsTable < ActiveRecord::Migration[7.2]\n  def change\n    rename_table :transactions, :account_transactions\n\n    reversible do |dir|\n      dir.up do\n        Tagging.where(taggable_type: 'Transaction').update_all(taggable_type: \"Account::Transaction\")\n      end\n\n      dir.down do\n        Tagging.where(taggable_type: 'Account::Transaction').update_all(taggable_type: \"Transaction\")\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240624160611_create_account_entries.rb",
    "content": "class CreateAccountEntries < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_entries, id: :uuid do |t|\n      t.references :account, null: false, foreign_key: true, type: :uuid\n      t.string :entryable_type\n      t.uuid :entryable_id\n      t.decimal :amount, precision: 19, scale: 4\n      t.string :currency\n      t.date :date\n      t.string :name\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240624161153_migrate_entryables.rb",
    "content": "class MigrateEntryables < ActiveRecord::Migration[7.2]\n  def change\n    reversible do |dir|\n      dir.up do\n        # Migrate Account::Transaction data\n        execute <<-SQL.squish\n          INSERT INTO account_entries (name, date, amount, currency, account_id, entryable_type, entryable_id, created_at, updated_at)\n          SELECT name, date, amount, currency, account_id, 'Account::Transaction', id, created_at, updated_at\n          FROM account_transactions\n        SQL\n\n        # Migrate Account::Valuation data\n        execute <<-SQL.squish\n          INSERT INTO account_entries (name, date, amount, currency, account_id, entryable_type, entryable_id, created_at, updated_at)\n          SELECT 'Manual valuation', date, value, currency, account_id, 'Account::Valuation', id, created_at, updated_at\n          FROM account_valuations\n        SQL\n      end\n\n      dir.down do\n        # Delete the entries from account_entries\n        execute <<-SQL.squish\n          DELETE FROM account_entries WHERE entryable_type IN ('Account::Transaction', 'Account::Valuation')\n        SQL\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240624164119_remove_old_columns_from_entryables.rb",
    "content": "class RemoveOldColumnsFromEntryables < ActiveRecord::Migration[7.2]\n  def change\n    reversible do |dir|\n      dir.up do\n        # Remove old columns from Account::Transaction\n        remove_column :account_transactions, :name\n        remove_column :account_transactions, :date\n        remove_column :account_transactions, :amount\n        remove_column :account_transactions, :currency\n        remove_column :account_transactions, :account_id\n\n        # Remove old columns from Account::Valuation\n        remove_column :account_valuations, :date\n        remove_column :account_valuations, :value\n        remove_column :account_valuations, :currency\n        remove_column :account_valuations, :account_id\n      end\n\n      dir.down do\n        # Add old columns back to Account::Transaction\n        add_column :account_transactions, :name, :string\n        add_column :account_transactions, :date, :date\n        add_column :account_transactions, :amount, :decimal, precision: 19, scale: 4\n        add_column :account_transactions, :currency, :string\n        add_column :account_transactions, :account_id, :uuid\n\n        # Add old columns back to Account::Valuation\n        add_column :account_valuations, :date, :date\n        add_column :account_valuations, :value, :decimal, precision: 19, scale: 4\n        add_column :account_valuations, :currency, :string\n        add_column :account_valuations, :account_id, :uuid\n\n        # Repopulate data for Account::Transaction\n        execute <<-SQL.squish\n          UPDATE account_transactions at\n          SET name = ae.name,\n              date = ae.date,\n              amount = ae.amount,\n              currency = ae.currency,\n              account_id = ae.account_id\n          FROM account_entries ae\n          WHERE ae.entryable_type = 'Account::Transaction' AND ae.entryable_id = at.id\n        SQL\n\n        # Repopulate data for Account::Valuation\n        execute <<-SQL.squish\n          UPDATE account_valuations av\n          SET date = ae.date,\n              value = ae.amount,\n              currency = ae.currency,\n              account_id = ae.account_id\n          FROM account_entries ae\n          WHERE ae.entryable_type = 'Account::Valuation' AND ae.entryable_id = av.id\n        SQL\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240628104551_move_transfers_association_from_transactions_to_entries.rb",
    "content": "class MoveTransfersAssociationFromTransactionsToEntries < ActiveRecord::Migration[7.2]\n  def change\n    reversible do |dir|\n      dir.up do\n        add_reference :account_entries, :transfer, foreign_key: { to_table: :account_transfers }, type: :uuid\n        add_column :account_entries, :marked_as_transfer, :boolean, default: false, null: false\n\n        execute <<-SQL.squish\n          UPDATE account_entries\n          SET transfer_id = transactions.transfer_id,\n              marked_as_transfer = transactions.marked_as_transfer\n          FROM account_transactions AS transactions\n          WHERE account_entries.entryable_id = transactions.id\n          AND account_entries.entryable_type = 'Account::Transaction'\n        SQL\n\n        remove_reference :account_transactions, :transfer, foreign_key: { to_table: :account_transfers }, type: :uuid\n        remove_column :account_transactions, :marked_as_transfer\n      end\n\n      dir.down do\n        add_reference :account_transactions, :transfer, foreign_key: { to_table: :account_transfers }, type: :uuid\n        add_column :account_transactions, :marked_as_transfer, :boolean, default: false, null: false\n\n        execute <<-SQL.squish\n          UPDATE account_transactions\n          SET transfer_id = account_entries.transfer_id,\n              marked_as_transfer = account_entries.marked_as_transfer\n          FROM account_entries\n          WHERE account_entries.entryable_id = account_transactions.id\n          AND account_entries.entryable_type = 'Account::Transaction'\n        SQL\n\n        remove_reference :account_entries, :transfer, foreign_key: { to_table: :account_transfers }, type: :uuid\n        remove_column :account_entries, :marked_as_transfer\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240706151026_rename_rate_fields.rb",
    "content": "class RenameRateFields < ActiveRecord::Migration[7.2]\n  def change\n    rename_column :exchange_rates, :base_currency, :from_currency\n    rename_column :exchange_rates, :converted_currency, :to_currency\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240707130331_create_account_syncs.rb",
    "content": "class CreateAccountSyncs < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_syncs, id: :uuid do |t|\n      t.references :account, null: false, foreign_key: true, type: :uuid\n      t.string :status, null: false, default: \"pending\"\n      t.date :start_date\n      t.datetime :last_ran_at\n      t.string :error\n      t.text :warnings, array: true, default: []\n\n      t.timestamps\n    end\n\n    remove_column :accounts, :status, :string\n    remove_column :accounts, :sync_warnings, :jsonb\n    remove_column :accounts, :sync_errors, :jsonb\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240709113713_create_good_job_execution_error_backtrace.rb",
    "content": "# frozen_string_literal: true\n\nclass CreateGoodJobExecutionErrorBacktrace < ActiveRecord::Migration[7.2]\n  def change\n    reversible do |dir|\n      dir.up do\n        # Ensure this incremental update migration is idempotent\n        # with monolithic install migration.\n        return if connection.column_exists?(:good_job_executions, :error_backtrace)\n      end\n    end\n\n    add_column :good_job_executions, :error_backtrace, :text, array: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240709113714_create_good_job_process_lock_ids.rb",
    "content": "# frozen_string_literal: true\n\nclass CreateGoodJobProcessLockIds < ActiveRecord::Migration[7.2]\n  def change\n    reversible do |dir|\n      dir.up do\n        # Ensure this incremental update migration is idempotent\n        # with monolithic install migration.\n        return if connection.column_exists?(:good_jobs, :locked_by_id)\n      end\n    end\n\n    add_column :good_jobs, :locked_by_id, :uuid\n    add_column :good_jobs, :locked_at, :datetime\n    add_column :good_job_executions, :process_id, :uuid\n    add_column :good_job_processes, :lock_type, :integer, limit: 2\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240709113715_create_good_job_process_lock_indexes.rb",
    "content": "# frozen_string_literal: true\n\nclass CreateGoodJobProcessLockIndexes < ActiveRecord::Migration[7.2]\n  disable_ddl_transaction!\n\n  def change\n    reversible do |dir|\n      dir.up do\n        unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked)\n          add_index :good_jobs, [ :priority, :scheduled_at ],\n                    order: { priority: \"ASC NULLS LAST\", scheduled_at: :asc },\n                    where: \"finished_at IS NULL AND locked_by_id IS NULL\",\n                    name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked,\n                    algorithm: :concurrently\n        end\n\n        unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id)\n          add_index :good_jobs, :locked_by_id,\n                    where: \"locked_by_id IS NOT NULL\",\n                    name: :index_good_jobs_on_locked_by_id,\n                    algorithm: :concurrently\n        end\n\n        unless connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at)\n          add_index :good_job_executions, [ :process_id, :created_at ],\n                    name: :index_good_job_executions_on_process_id_and_created_at,\n                    algorithm: :concurrently\n        end\n      end\n\n      dir.down do\n        remove_index(:good_jobs, name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked)\n        remove_index(:good_jobs, name: :index_good_jobs_on_locked_by_id) if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id)\n        remove_index(:good_job_executions, name: :index_good_job_executions_on_process_id_and_created_at) if connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at)\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240709152243_create_good_job_execution_duration.rb",
    "content": "# frozen_string_literal: true\n\nclass CreateGoodJobExecutionDuration < ActiveRecord::Migration[7.2]\n  def change\n    reversible do |dir|\n      dir.up do\n        # Ensure this incremental update migration is idempotent\n        # with monolithic install migration.\n        return if connection.column_exists?(:good_job_executions, :duration)\n      end\n    end\n\n    add_column :good_job_executions, :duration, :interval\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240710182529_create_securities.rb",
    "content": "class CreateSecurities < ActiveRecord::Migration[7.2]\n  def change\n    create_table :securities, id: :uuid do |t|\n      t.string :isin, null: false\n      t.string :symbol\n      t.string :name\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240710182728_create_security_prices.rb",
    "content": "class CreateSecurityPrices < ActiveRecord::Migration[7.2]\n  def change\n    create_table :security_prices, id: :uuid do |t|\n      t.string :isin\n      t.date :date\n      t.decimal :price, precision: 19, scale: 4\n      t.string :currency, default: \"USD\"\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240710184048_create_account_trades.rb",
    "content": "class CreateAccountTrades < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_trades, id: :uuid do |t|\n      t.references :security, null: false, foreign_key: true, type: :uuid\n      t.decimal :qty, precision: 19, scale: 4\n      t.decimal :price, precision: 19, scale: 4\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240710184249_create_account_holdings.rb",
    "content": "class CreateAccountHoldings < ActiveRecord::Migration[7.2]\n  def change\n    create_table :account_holdings, id: :uuid do |t|\n      t.references :account, null: false, foreign_key: true, type: :uuid\n      t.references :security, null: false, foreign_key: true, type: :uuid\n      t.date :date\n      t.decimal :qty, precision: 19, scale: 4\n      t.decimal :price, precision: 19, scale: 4\n      t.decimal :amount, precision: 19, scale: 4\n      t.string :currency\n\n      t.timestamps\n    end\n\n    add_index :account_holdings, %i[account_id security_id date currency], unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240717113535_remove_default_from_account_balance.rb",
    "content": "class RemoveDefaultFromAccountBalance < ActiveRecord::Migration[7.2]\n  def change\n    change_column_default :accounts, :balance, from: \"0.0\", to: nil\n    change_column_default :accounts, :currency, from: \"USD\", to: nil\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240725163339_add_last_synced_at_to_family.rb",
    "content": "class AddLastSyncedAtToFamily < ActiveRecord::Migration[7.2]\n  def change\n    add_column :families, :last_synced_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240731191344_change_primary_identifier_for_security.rb",
    "content": "class ChangePrimaryIdentifierForSecurity < ActiveRecord::Migration[7.2]\n  def change\n    rename_column :securities, :symbol, :ticker\n    remove_column :securities, :isin, :string\n    rename_column :security_prices, :isin, :ticker\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240807153618_add_currency_field_to_trade.rb",
    "content": "class AddCurrencyFieldToTrade < ActiveRecord::Migration[7.2]\n  def change\n    add_column :account_trades, :currency, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240813170608_fix_invalid_accountable_data.rb",
    "content": "class FixInvalidAccountableData < ActiveRecord::Migration[7.2]\n  def up\n    Account.all.each do |account|\n      unless account.accountable\n        puts \"Generating new accountable for id=#{account.id}, name=#{account.name}, type=#{account.accountable_type}\"\n        new_accountable = Accountable.from_type(account.accountable_type).new\n        account.update!(accountable: new_accountable)\n      end\n    end\n  end\n\n  def down\n    # Not reversible\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240815125404_create_issues.rb",
    "content": "class CreateIssues < ActiveRecord::Migration[7.2]\n  def change\n    create_table :issues, id: :uuid do |t|\n      t.references :issuable, type: :uuid, polymorphic: true\n      t.string :type\n      t.integer :severity\n      t.datetime :last_observed_at\n      t.datetime :resolved_at\n      t.jsonb :data\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240815190722_remove_warnings_from_sync.rb",
    "content": "class RemoveWarningsFromSync < ActiveRecord::Migration[7.2]\n  def change\n    remove_column :account_syncs, :warnings, :text, array: true, default: []\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240816071555_add_col_sep_to_imports.rb",
    "content": "class AddColSepToImports < ActiveRecord::Migration[7.2]\n  def change\n    add_column :imports, :col_sep, :string, default: ','\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240817144454_rename_import_raw_csv_str_to_raw_file_str.rb",
    "content": "class RenameImportRawCsvStrToRawFileStr < ActiveRecord::Migration[7.2]\n  def change\n    rename_column :imports, :raw_csv_str, :raw_file_str\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240822174006_create_addresses.rb",
    "content": "class CreateAddresses < ActiveRecord::Migration[7.2]\n  def change\n    create_table :addresses, id: :uuid do |t|\n      t.references :addressable, type: :uuid, polymorphic: true\n      t.string :line1\n      t.string :line2\n      t.string :county\n      t.string :locality\n      t.string :region\n      t.string :country\n      t.integer :postal_code\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240822180845_add_property_attributes.rb",
    "content": "class AddPropertyAttributes < ActiveRecord::Migration[7.2]\n  def change\n    add_column :properties, :year_built, :integer\n    add_column :properties, :area_value, :integer\n    add_column :properties, :area_unit, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240823125526_add_details_to_vehicle.rb",
    "content": "class AddDetailsToVehicle < ActiveRecord::Migration[7.2]\n  def change\n    add_column :vehicles, :year, :integer\n    add_column :vehicles, :mileage_value, :integer\n    add_column :vehicles, :mileage_unit, :string\n    add_column :vehicles, :make, :string\n    add_column :vehicles, :model, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240911143158_add_last_synced_at_institution.rb",
    "content": "class AddLastSyncedAtInstitution < ActiveRecord::Migration[7.2]\n  def change\n    add_column :institutions, :last_synced_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240921170426_change_import_owner.rb",
    "content": "class ChangeImportOwner < ActiveRecord::Migration[7.2]\n  def up\n    add_reference :imports, :family, foreign_key: true, type: :uuid\n    add_column :imports, :original_account_id, :uuid\n\n    execute <<-SQL\n      UPDATE imports\n      SET family_id = (SELECT family_id FROM accounts WHERE accounts.id = imports.account_id),\n          original_account_id = account_id\n    SQL\n\n    remove_reference :imports, :account, foreign_key: true, type: :uuid\n    change_column_null :imports, :family_id, false\n  end\n\n  def down\n    add_reference :imports, :account, foreign_key: true, type: :uuid\n\n    execute <<-SQL\n      UPDATE imports\n      SET account_id = original_account_id\n    SQL\n\n    remove_reference :imports, :family, foreign_key: true, type: :uuid\n    remove_column :imports, :original_account_id, :uuid\n    change_column_null :imports, :account_id, false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20240925112218_add_import_types.rb",
    "content": "class AddImportTypes < ActiveRecord::Migration[7.2]\n  def change\n    change_table :imports do |t|\n      t.string :type\n      t.string :date_col_label, default: \"date\"\n      t.string :amount_col_label, default: \"amount\"\n      t.string :name_col_label, default: \"name\"\n      t.string :category_col_label, default: \"category\"\n      t.string :tags_col_label, default: \"tags\"\n      t.string :account_col_label, default: \"account\"\n      t.string :qty_col_label, default: \"qty\"\n      t.string :ticker_col_label, default: \"ticker\"\n      t.string :price_col_label, default: \"price\"\n      t.string :entity_type_col_label, default: \"type\"\n      t.string :notes_col_label, default: \"notes\"\n      t.string :currency_col_label, default: \"currency\"\n      t.string :date_format, default: \"%m/%d/%Y\"\n      t.string :signage_convention, default: \"inflows_positive\"\n      t.string :error\n    end\n\n    Import.update_all(type: \"TransactionImport\")\n\n    change_column_null :imports, :type, false\n\n    # Add import references so we can associate imported resources after the import\n    add_reference :account_entries, :import, foreign_key: true, type: :uuid\n    add_reference :accounts, :import, foreign_key: true, type: :uuid\n\n    create_table :import_rows, id: :uuid do |t|\n      t.references :import, null: false, foreign_key: true, type: :uuid\n      t.string :account\n      t.string :date\n      t.string :qty\n      t.string :ticker\n      t.string :price\n      t.string :amount\n      t.string :currency\n      t.string :name\n      t.string :category\n      t.string :tags\n      t.string :entity_type\n      t.text :notes\n\n      t.timestamps\n    end\n\n    create_table :import_mappings, id: :uuid do |t|\n      t.string :type, null: false\n      t.string :key\n      t.string :value\n      t.boolean :create_when_empty, default: true\n      t.references :import, null: false, type: :uuid\n      t.references :mappable, polymorphic: true, type: :uuid\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241001181256_add_locale_preference.rb",
    "content": "class AddLocalePreference < ActiveRecord::Migration[7.2]\n  def change\n    add_column :families, :locale, :string, default: \"en\"\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241003163448_create_sessions.rb",
    "content": "class CreateSessions < ActiveRecord::Migration[7.2]\n  def change\n    create_table :sessions, id: :uuid do |t|\n      t.references :user, null: false, foreign_key: true, type: :uuid\n      t.string :user_agent\n      t.string :ip_address\n\n      t.timestamps\n    end\n\n    remove_column :users, :last_login_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241007211438_add_billing_to_families.rb",
    "content": "class AddBillingToFamilies < ActiveRecord::Migration[7.2]\n  def change\n    add_column :families, :stripe_plan_id, :string\n    add_column :families, :stripe_customer_id, :string\n    add_column :families, :stripe_subscription_status, :string, default: \"incomplete\"\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241008122449_add_debt_account_views.rb",
    "content": "class AddDebtAccountViews < ActiveRecord::Migration[7.2]\n  def change\n    change_table :loans do |t|\n      t.string :rate_type\n      t.decimal :interest_rate, precision: 10, scale: 2\n      t.integer :term_months\n    end\n\n    change_table :credit_cards do |t|\n      t.decimal :available_credit, precision: 10, scale: 2\n      t.decimal :minimum_payment, precision: 10, scale: 2\n      t.decimal :apr, precision: 10, scale: 2\n      t.date :expiration_date\n      t.decimal :annual_fee, precision: 10, scale: 2\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241009132959_add_notes_to_entry.rb",
    "content": "class AddNotesToEntry < ActiveRecord::Migration[7.2]\n  def change\n    add_column :account_entries, :notes, :text\n    add_column :account_entries, :excluded, :boolean, default: false\n\n    reversible do |dir|\n      dir.up do\n        execute <<-SQL\n          UPDATE account_entries\n          SET notes = account_transactions.notes,\n              excluded = account_transactions.excluded\n          FROM account_transactions\n          WHERE account_entries.entryable_type = 'Account::Transaction'\n            AND account_entries.entryable_id = account_transactions.id\n        SQL\n      end\n\n      dir.down do\n        execute <<-SQL\n          UPDATE account_transactions\n          SET notes = account_entries.notes,\n              excluded = account_entries.excluded\n          FROM account_entries\n          WHERE account_entries.entryable_type = 'Account::Transaction'\n            AND account_entries.entryable_id = account_transactions.id\n        SQL\n      end\n    end\n\n    remove_column :account_transactions, :notes, :text\n    remove_column :account_transactions, :excluded, :boolean\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241009214601_add_super_admin_to_users.rb",
    "content": "class AddSuperAdminToUsers < ActiveRecord::Migration[7.2]\n  def change\n    reversible do |dir|\n      dir.up do\n        change_column :users, :role, :string, default: 'member'\n\n        execute <<-SQL\n          DROP TYPE user_role;\n        SQL\n      end\n\n      dir.down do\n        execute <<-SQL\n          CREATE TYPE user_role AS ENUM ('admin', 'member');\n        SQL\n\n        change_column_default :users, :role, nil\n        change_column :users, :role, :user_role, using: 'role::user_role'\n        change_column_default :users, :role, 'member'\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241017162347_create_impersonation_sessions.rb",
    "content": "class CreateImpersonationSessions < ActiveRecord::Migration[7.2]\n  def change\n    create_table :impersonation_sessions, id: :uuid do |t|\n      t.references :impersonator, null: false, foreign_key: { to_table: :users }, type: :uuid\n      t.references :impersonated, null: false, foreign_key: { to_table: :users }, type: :uuid\n      t.string :status, null: false, default: 'pending'\n      t.timestamps\n    end\n\n    add_reference :sessions, :active_impersonator_session, type: :uuid, foreign_key: { to_table: :impersonation_sessions }\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241017162536_create_impersonation_session_logs.rb",
    "content": "class CreateImpersonationSessionLogs < ActiveRecord::Migration[7.2]\n  def change\n    create_table :impersonation_session_logs, id: :uuid do |t|\n      t.references :impersonation_session, type: :uuid, foreign_key: true, null: false\n      t.string :controller\n      t.string :action\n      t.text :path\n      t.string :method\n      t.string :ip_address\n      t.text :user_agent\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241017204250_add_accounts_indexes.rb",
    "content": "class AddAccountsIndexes < ActiveRecord::Migration[7.2]\n  def change\n    add_index :accounts, [ :family_id, :accountable_type ]\n    add_index :accounts, [ :accountable_id, :accountable_type ]\n    add_index :accounts, [ :family_id, :id ]\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241018201653_add_account_mode.rb",
    "content": "class AddAccountMode < ActiveRecord::Migration[7.2]\n  def change\n    add_column :accounts, :mode, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241022170439_create_stock_exchanges.rb",
    "content": "class CreateStockExchanges < ActiveRecord::Migration[7.2]\n  def change\n    create_table :stock_exchanges, id: :uuid do |t|\n      t.string :name, null: false\n      t.string :acronym\n      t.string :mic, null: false\n      t.string :country, null: false\n      t.string :country_code, null: false\n      t.string :city, null: false\n      t.string :website\n      t.string :timezone_name, null: false\n      t.string :timezone_abbr, null: false\n      t.string :timezone_abbr_dst\n      t.string :currency_code, null: false\n      t.string :currency_symbol, null: false\n      t.string :currency_name, null: false\n      t.timestamps\n    end\n\n    add_index :stock_exchanges, :country\n    add_index :stock_exchanges, :country_code\n    add_index :stock_exchanges, :currency_code\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241022192319_fix_user_role_column_type.rb",
    "content": "class FixUserRoleColumnType < ActiveRecord::Migration[7.2]\n  def change\n    # First remove any constraints/references to the enum\n    execute <<-SQL\n      ALTER TABLE users ALTER COLUMN role TYPE varchar USING role::text;\n    SQL\n\n    # Then set the default\n    change_column_default :users, :role, 'member'\n\n    # Finally drop the enum type\n    execute <<-SQL\n      DROP TYPE IF EXISTS user_role;\n    SQL\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241022221544_add_onboarding_fields.rb",
    "content": "class AddOnboardingFields < ActiveRecord::Migration[7.2]\n  def change\n    add_column :users, :onboarded_at, :datetime\n    add_column :families, :date_format, :string, default: \"%m-%d-%Y\"\n    add_column :families, :country, :string, default: \"US\"\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241023195438_add_stock_exchange_reference.rb",
    "content": "class AddStockExchangeReference < ActiveRecord::Migration[7.2]\n  def change\n    add_column :securities, :country_code, :string\n    add_reference :securities, :stock_exchange, type: :uuid, foreign_key: true\n    add_index :securities, :country_code\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241024142537_add_subscription_timestamp_to_session.rb",
    "content": "class AddSubscriptionTimestampToSession < ActiveRecord::Migration[7.2]\n  def change\n    add_column :sessions, :subscribed_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241025174650_add_mic_to_securities.rb",
    "content": "class AddMicToSecurities < ActiveRecord::Migration[7.2]\n  def change\n    add_column :securities, :exchange_mic, :string\n    add_column :securities, :exchange_acronym, :string\n\n    remove_column :securities, :stock_exchange_id, :uuid\n\n    add_index :securities, [ :ticker, :exchange_mic ], unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241025182612_add_search_vector_to_securities.rb",
    "content": "class AddSearchVectorToSecurities < ActiveRecord::Migration[7.2]\n  def change\n    add_column :securities, :search_vector, :virtual, type: :tsvector, as: \"setweight(to_tsvector('simple', coalesce(ticker, '')), 'B') || to_tsvector('simple', coalesce(name, ''))\", stored: true\n    add_index :securities, :search_vector, using: :gin\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241029125406_add_reference_to_security_prices.rb",
    "content": "class AddReferenceToSecurityPrices < ActiveRecord::Migration[7.2]\n  def change\n    add_reference :security_prices, :security, foreign_key: true, type: :uuid\n\n    reversible do |dir|\n      dir.up do\n        Security::Price.find_each do |sp|\n          security = Security.find_by(ticker: sp.ticker)\n          sp.update_column(:security_id, security&.id)\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241029184115_remove_prices_missing_issue.rb",
    "content": "class RemovePricesMissingIssue < ActiveRecord::Migration[7.2]\n  def up\n    execute \"DELETE FROM issues WHERE type = 'Issue::PricesMissing'\"\n  end\n\n  def down\n    # Cannot restore deleted issues since we don't have the original data\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241029234028_remove_search_vector.rb",
    "content": "class RemoveSearchVector < ActiveRecord::Migration[7.2]\n  def change\n    remove_column :securities, :search_vector\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241030121302_fix_not_null_stock_exchange_data.rb",
    "content": "class FixNotNullStockExchangeData < ActiveRecord::Migration[7.2]\n  def change\n    change_column_null :stock_exchanges, :currency_code, true\n    change_column_null :stock_exchanges, :currency_symbol, true\n    change_column_null :stock_exchanges, :currency_name, true\n    change_column_null :stock_exchanges, :city, true\n    change_column_null :stock_exchanges, :timezone_name, true\n    change_column_null :stock_exchanges, :timezone_abbr, true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241030151105_remove_account_mode.rb",
    "content": "class RemoveAccountMode < ActiveRecord::Migration[7.2]\n  def change\n    remove_column :accounts, :mode\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241030222235_create_invitations.rb",
    "content": "class CreateInvitations < ActiveRecord::Migration[7.2]\n  def change\n    create_table :invitations, id: :uuid do |t|\n      t.string :email\n      t.string :role\n      t.string :token\n      t.references :family, null: false, foreign_key: true, type: :uuid\n      t.references :inviter, null: false, foreign_key: { to_table: :users }, type: :uuid\n      t.datetime :accepted_at\n      t.datetime :expires_at\n\n      t.timestamps\n    end\n\n    add_index :invitations, :token, unique: true\n    add_index :invitations, :email\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241106193743_add_plaid_domain.rb",
    "content": "class AddPlaidDomain < ActiveRecord::Migration[7.2]\n  def change\n    create_table :plaid_items, id: :uuid do |t|\n      t.references :family, null: false, type: :uuid, foreign_key: true\n      t.string :access_token\n      t.string :plaid_id\n      t.string :name\n      t.string :next_cursor\n      t.boolean :scheduled_for_deletion, default: false\n\n      t.timestamps\n    end\n\n    create_table :plaid_accounts, id: :uuid do |t|\n      t.references :plaid_item, null: false, type: :uuid, foreign_key: true\n      t.string :plaid_id\n      t.string :plaid_type\n      t.string :plaid_subtype\n      t.decimal :current_balance, precision: 19, scale: 4\n      t.decimal :available_balance, precision: 19, scale: 4\n      t.string :currency\n      t.string :name\n      t.string :mask\n\n      t.timestamps\n    end\n\n    create_table :syncs, id: :uuid do |t|\n      t.references :syncable, polymorphic: true, null: false, type: :uuid\n      t.datetime :last_ran_at\n      t.date :start_date\n      t.string :status, default: \"pending\"\n      t.string :error\n      t.jsonb :data\n\n      t.timestamps\n    end\n\n    remove_column :families, :last_synced_at, :datetime\n    add_column :families, :last_auto_synced_at, :datetime\n    remove_column :accounts, :last_sync_date, :date\n    remove_reference :accounts, :institution\n    add_reference :accounts, :plaid_account, type: :uuid, foreign_key: true\n\n    add_column :account_entries, :plaid_id, :string\n    add_column :accounts, :scheduled_for_deletion, :boolean, default: false\n\n    drop_table :account_syncs do |t|\n      t.timestamps\n    end\n\n    drop_table :institutions do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241108150422_add_unique_email_index_to_invitations.rb",
    "content": "class AddUniqueEmailIndexToInvitations < ActiveRecord::Migration[7.2]\n  def change\n    add_index :invitations, [ :email, :family_id ], unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241114164118_add_products_to_plaid_item.rb",
    "content": "class AddProductsToPlaidItem < ActiveRecord::Migration[7.2]\n  def change\n    add_column :plaid_items, :available_products, :string, array: true, default: []\n    add_column :plaid_items, :billed_products, :string, array: true, default: []\n\n    rename_column :families, :last_auto_synced_at, :last_synced_at\n    add_column :plaid_items, :last_synced_at, :datetime\n    add_column :accounts, :last_synced_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241122183828_change_loan_interest_rate_precision.rb.rb",
    "content": "class ChangeLoanInterestRatePrecision < ActiveRecord::Migration[7.2]\n  def change\n    change_column :loans, :interest_rate, :decimal, precision: 10, scale: 3\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241126211249_add_logo_url_to_security.rb",
    "content": "class AddLogoUrlToSecurity < ActiveRecord::Migration[7.2]\n  def change\n    add_column :securities, :logo_url, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241204235400_add_balance_components.rb",
    "content": "class AddBalanceComponents < ActiveRecord::Migration[7.2]\n  def change\n    add_column :accounts, :cash_balance, :decimal, precision: 19, scale: 4, default: 0\n    add_column :account_balances, :cash_balance, :decimal, precision: 19, scale: 4, default: 0\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241207002408_add_family_timezone.rb",
    "content": "class AddFamilyTimezone < ActiveRecord::Migration[7.2]\n  def change\n    add_column :families, :timezone, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241212141453_add_merchant_logo.rb",
    "content": "class AddMerchantLogo < ActiveRecord::Migration[7.2]\n  def change\n    add_column :merchants, :icon_url, :string\n    add_column :merchants, :enriched_at, :datetime\n\n    add_column :account_entries, :enriched_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241217141716_add_enrichment_setting.rb",
    "content": "class AddEnrichmentSetting < ActiveRecord::Migration[7.2]\n  def change\n    add_column :families, :data_enrichment_enabled, :boolean, default: false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241218132503_add_enriched_name_field.rb",
    "content": "class AddEnrichedNameField < ActiveRecord::Migration[7.2]\n  def change\n    add_column :account_entries, :enriched_name, :string\n\n    reversible do |dir|\n      dir.up do\n        execute <<-SQL\n          UPDATE account_entries ae\n          SET name = CASE ae.entryable_type\n            WHEN 'Account::Trade' THEN\n              CASE\n                WHEN EXISTS (\n                  SELECT 1 FROM account_trades t\n                  WHERE t.id = ae.entryable_id AND t.qty < 0\n                ) THEN 'Sell trade'\n                ELSE 'Buy trade'\n              END\n            WHEN 'Account::Transaction' THEN\n              CASE\n                WHEN ae.amount > 0 THEN 'Expense'\n                ELSE 'Income'\n              END\n            WHEN 'Account::Valuation' THEN 'Balance update'\n            ELSE 'Unknown entry'\n          END\n          WHERE name IS NULL\n        SQL\n\n        change_column_null :account_entries, :name, false\n      end\n\n      dir.down do\n        change_column_null :account_entries, :name, true\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241219151540_add_region_to_plaid_item.rb",
    "content": "class AddRegionToPlaidItem < ActiveRecord::Migration[7.2]\n  def change\n    add_column :plaid_items, :plaid_region, :string, null: false, default: \"us\"\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241219174803_add_parent_category.rb",
    "content": "class AddParentCategory < ActiveRecord::Migration[7.2]\n  def change\n    add_column :categories, :parent_id, :uuid\n    remove_column :categories, :internal_category, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241227142333_add_error_trace_to_syncs.rb",
    "content": "class AddErrorTraceToSyncs < ActiveRecord::Migration[7.2]\n  def change\n    add_column :syncs, :error_backtrace, :text, array: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20241231140709_reverse_transfer_relations.rb",
    "content": "class ReverseTransferRelations < ActiveRecord::Migration[7.2]\n  def change\n    create_table :transfers, id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n      t.references :inflow_transaction, null: false, foreign_key: { to_table: :account_transactions }, type: :uuid\n      t.references :outflow_transaction, null: false, foreign_key: { to_table: :account_transactions }, type: :uuid\n      t.string :status, null: false, default: \"pending\"\n      t.text :notes\n\n      t.index [ :inflow_transaction_id, :outflow_transaction_id ], unique: true\n      t.timestamps\n    end\n\n    reversible do |dir|\n      dir.up do\n        execute <<~SQL\n          INSERT INTO transfers (inflow_transaction_id, outflow_transaction_id, status, created_at, updated_at)\n          SELECT\n            CASE WHEN e1.amount <= 0 THEN e1.entryable_id ELSE e2.entryable_id END as inflow_transaction_id,\n            CASE WHEN e1.amount <= 0 THEN e2.entryable_id ELSE e1.entryable_id END as outflow_transaction_id,\n            'confirmed' as status,\n            e1.created_at,\n            e1.updated_at\n          FROM account_entries e1\n          JOIN account_entries e2 ON\n            e1.transfer_id = e2.transfer_id AND\n            e1.id != e2.id AND\n            e1.id < e2.id -- Ensures we don't duplicate transfers from both sides\n          JOIN accounts a1 ON e1.account_id = a1.id\n          JOIN accounts a2 ON e2.account_id = a2.id\n          WHERE\n            e1.entryable_type = 'Account::Transaction' AND\n            e2.entryable_type = 'Account::Transaction' AND\n            e1.transfer_id IS NOT NULL AND\n            a1.family_id = a2.family_id;\n        SQL\n      end\n\n      dir.down do\n        execute <<~SQL\n          WITH new_transfers AS (\n            INSERT INTO account_transfers (created_at, updated_at)\n            SELECT created_at, updated_at\n            FROM transfers\n            RETURNING id, created_at\n          ),\n          transfer_pairs AS (\n            SELECT\n              nt.id as transfer_id,\n              ae_in.id as inflow_entry_id,\n              ae_out.id as outflow_entry_id\n            FROM transfers t\n            JOIN new_transfers nt ON nt.created_at = t.created_at\n            JOIN account_entries ae_in ON ae_in.entryable_id = t.inflow_transaction_id\n            JOIN account_entries ae_out ON ae_out.entryable_id = t.outflow_transaction_id\n            WHERE\n              ae_in.entryable_type = 'Account::Transaction' AND\n              ae_out.entryable_type = 'Account::Transaction'\n          )\n          UPDATE account_entries ae\n          SET transfer_id = tp.transfer_id\n          FROM transfer_pairs tp\n          WHERE ae.id IN (tp.inflow_entry_id, tp.outflow_entry_id);\n        SQL\n      end\n    end\n\n    remove_foreign_key :account_entries, :account_transfers, column: :transfer_id\n    remove_column :account_entries, :transfer_id, :uuid\n    remove_column :account_entries, :marked_as_transfer, :boolean\n\n    drop_table :account_transfers, id: :uuid, default: -> { \"gen_random_uuid()\" } do |t|\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250108182147_create_budgets.rb",
    "content": "class CreateBudgets < ActiveRecord::Migration[7.2]\n  def change\n    create_table :budgets, id: :uuid do |t|\n      t.references :family, null: false, foreign_key: true, type: :uuid\n      t.date :start_date, null: false\n      t.date :end_date, null: false\n      t.decimal :budgeted_spending, precision: 19, scale: 4\n      t.decimal :expected_income, precision: 19, scale: 4\n      t.string :currency, null: false\n      t.timestamps\n    end\n\n    add_index :budgets, %i[family_id start_date end_date], unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250108200055_create_budget_categories.rb",
    "content": "class CreateBudgetCategories < ActiveRecord::Migration[7.2]\n  def change\n    create_table :budget_categories, id: :uuid do |t|\n      t.references :budget, null: false, foreign_key: true, type: :uuid\n      t.references :category, null: false, foreign_key: true, type: :uuid\n      t.decimal :budgeted_spending, null: false, precision: 19, scale: 4\n      t.string :currency, null: false\n      t.timestamps\n    end\n\n    add_index :budget_categories, %i[budget_id category_id], unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250110012347_category_classification.rb",
    "content": "class CategoryClassification < ActiveRecord::Migration[7.2]\n  def change\n    add_column :categories, :classification, :string, null: false, default: \"expense\"\n    add_column :categories, :lucide_icon, :string\n\n    # Attempt to update existing user categories that are likely to be income\n    reversible do |dir|\n      dir.up do\n        execute <<-SQL\n          UPDATE categories\n          SET classification = 'income'\n          WHERE lower(name) in ('income', 'incomes', 'other income', 'other incomes');\n        SQL\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250120210449_align_transfer_cascade_behavior.rb",
    "content": "class AlignTransferCascadeBehavior < ActiveRecord::Migration[7.2]\n  def change\n    remove_foreign_key :transfers, :account_transactions, column: :inflow_transaction_id\n    remove_foreign_key :transfers, :account_transactions, column: :outflow_transaction_id\n\n    add_foreign_key :transfers, :account_transactions,\n                    column: :inflow_transaction_id,\n                    on_delete: :cascade\n\n    add_foreign_key :transfers, :account_transactions,\n                    column: :outflow_transaction_id,\n                    on_delete: :cascade\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250124224316_create_rejected_transfers.rb",
    "content": "class CreateRejectedTransfers < ActiveRecord::Migration[7.2]\n  def change\n    create_table :rejected_transfers, id: :uuid do |t|\n      t.references :inflow_transaction, null: false, foreign_key: { to_table: :account_transactions }, type: :uuid\n      t.references :outflow_transaction, null: false, foreign_key: { to_table: :account_transactions }, type: :uuid\n      t.timestamps\n    end\n\n    add_index :rejected_transfers, [ :inflow_transaction_id, :outflow_transaction_id ], unique: true\n\n    reversible do |dir|\n      dir.up do\n        execute <<~SQL\n          INSERT INTO rejected_transfers (inflow_transaction_id, outflow_transaction_id, created_at, updated_at)\n          SELECT\n            inflow_transaction_id,\n            outflow_transaction_id,\n            created_at,\n            updated_at\n          FROM transfers\n          WHERE status = 'rejected'\n        SQL\n\n        execute <<~SQL\n          DELETE FROM transfers\n          WHERE status = 'rejected'\n        SQL\n      end\n\n      dir.down do\n        execute <<~SQL\n          INSERT INTO transfers (inflow_transaction_id, outflow_transaction_id, status, created_at, updated_at)\n          SELECT\n            inflow_transaction_id,\n            outflow_transaction_id,\n            'rejected',\n            created_at,\n            updated_at\n          FROM rejected_transfers\n        SQL\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250128203303_store_transaction_filters_in_session.rb",
    "content": "class StoreTransactionFiltersInSession < ActiveRecord::Migration[7.2]\n  def change\n    add_column :sessions, :prev_transaction_page_params, :jsonb, default: {}\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250130191533_add_email_confirmation_to_users.rb",
    "content": "class AddEmailConfirmationToUsers < ActiveRecord::Migration[7.2]\n  def change\n    add_column :users, :unconfirmed_email, :string\n    add_column :users, :email_confirmation_token, :string\n    add_column :users, :email_confirmation_sent_at, :datetime\n\n    add_index :users, :email_confirmation_token, unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250130214500_remove_email_confirmation_sent_at_from_users.rb",
    "content": "class RemoveEmailConfirmationSentAtFromUsers < ActiveRecord::Migration[7.2]\n  def change\n    remove_column :users, :email_confirmation_sent_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250131171943_remove_email_confirmation_token_from_users.rb",
    "content": "class RemoveEmailConfirmationTokenFromUsers < ActiveRecord::Migration[7.2]\n  def change\n    remove_index :users, :email_confirmation_token\n    remove_column :users, :email_confirmation_token, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250206003115_remove_import_status_enum.rb",
    "content": "class RemoveImportStatusEnum < ActiveRecord::Migration[7.2]\n  def up\n    change_column_default :imports, :status, nil\n    change_column :imports, :status, :string\n    execute \"DROP TYPE IF EXISTS import_status\"\n  end\n\n  def down\n    execute <<-SQL\n      CREATE TYPE import_status AS ENUM (\n        'pending',\n        'importing',\n        'complete',\n        'failed'\n      );\n    SQL\n\n    change_column :imports, :status, :import_status, using: 'status::import_status'\n    change_column_default :imports, :status, 'pending'\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250206141452_add_institution_details_to_plaid_items.rb",
    "content": "class AddInstitutionDetailsToPlaidItems < ActiveRecord::Migration[7.2]\n  def change\n    add_column :plaid_items, :institution_url, :string\n    add_column :plaid_items, :institution_id, :string\n    add_column :plaid_items, :institution_color, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250206151825_add_mfa_fields_to_users.rb",
    "content": "class AddMfaFieldsToUsers < ActiveRecord::Migration[7.2]\n  def change\n    add_column :users, :otp_secret, :string\n    add_column :users, :otp_required, :boolean, default: false, null: false\n    add_column :users, :otp_backup_codes, :string, array: true, default: []\n\n    add_index :users, :otp_secret, unique: true, where: \"otp_secret IS NOT NULL\"\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250206204404_add_constraints_to_account_holdings.rb",
    "content": "class AddConstraintsToAccountHoldings < ActiveRecord::Migration[7.2]\n  def up\n    # First, remove any holdings with nil values\n    execute <<-SQL\n      DELETE FROM account_holdings#{' '}\n      WHERE date IS NULL#{' '}\n         OR qty IS NULL#{' '}\n         OR price IS NULL#{' '}\n         OR amount IS NULL#{' '}\n         OR currency IS NULL;\n    SQL\n\n    # Remove any holdings where amount doesn't match qty * price\n    execute <<-SQL\n      DELETE FROM account_holdings#{' '}\n      WHERE ROUND(qty * price, 4) != ROUND(amount, 4);\n    SQL\n\n    # Remove any holdings with negative values\n    execute <<-SQL\n      DELETE FROM account_holdings#{' '}\n      WHERE qty < 0 OR price < 0 OR amount < 0;\n    SQL\n\n    # Now add NOT NULL constraints\n    change_column_null :account_holdings, :date, false\n    change_column_null :account_holdings, :qty, false\n    change_column_null :account_holdings, :price, false\n    change_column_null :account_holdings, :amount, false\n    change_column_null :account_holdings, :currency, false\n  end\n\n  def down\n    change_column_null :account_holdings, :date, true\n    change_column_null :account_holdings, :qty, true\n    change_column_null :account_holdings, :price, true\n    change_column_null :account_holdings, :amount, true\n    change_column_null :account_holdings, :currency, true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250207011850_add_exchange_operating_mic_to_securities.rb",
    "content": "class AddExchangeOperatingMicToSecurities < ActiveRecord::Migration[7.2]\n  def change\n    add_column :securities, :exchange_operating_mic, :string\n    add_index :securities, :exchange_operating_mic\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250207014022_add_number_format_to_imports.rb",
    "content": "class AddNumberFormatToImports < ActiveRecord::Migration[7.2]\n  def change\n    add_column :imports, :number_format, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250207194638_adjust_securities_indexes.rb",
    "content": "class AdjustSecuritiesIndexes < ActiveRecord::Migration[7.2]\n  def change\n    remove_index :securities, name: \"index_securities_on_ticker_and_exchange_mic\"\n    add_index :securities, [ :ticker, :exchange_operating_mic ], unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250211161238_make_ticker_not_null.rb",
    "content": "class MakeTickerNotNull < ActiveRecord::Migration[7.2]\n  def change\n    change_column_null :securities, :ticker, false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250212163624_add_status_to_plaid_items.rb",
    "content": "class AddStatusToPlaidItems < ActiveRecord::Migration[7.2]\n  def change\n    add_column :plaid_items, :status, :string, null: false, default: \"good\"\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250212213301_add_user_sidebar_preference.rb",
    "content": "class AddUserSidebarPreference < ActiveRecord::Migration[7.2]\n  def change\n    add_column :users, :show_sidebar, :boolean, default: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250220153958_update_imports_for_operating_mic_v2.rb",
    "content": "class UpdateImportsForOperatingMicV2 < ActiveRecord::Migration[7.2]\n  def change\n    add_column :import_rows, :exchange_operating_mic, :string\n    add_column :imports, :exchange_operating_mic_col_label, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250220200735_add_default_lucide_icon_to_categories.rb",
    "content": "class AddDefaultLucideIconToCategories < ActiveRecord::Migration[7.2]\n  def up\n    execute <<-SQL\n      UPDATE categories\n      SET lucide_icon = 'shapes'\n      WHERE lucide_icon IS NULL\n    SQL\n\n    change_column_null :categories, :lucide_icon, false\n    change_column_default :categories, :lucide_icon, 'shapes'\n  end\n\n  def down\n    change_column_default :categories, :lucide_icon, nil\n    change_column_null :categories, :lucide_icon, true\n\n    execute <<-SQL\n      UPDATE categories\n      SET lucide_icon = NULL\n      WHERE lucide_icon = 'shapes'\n    SQL\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250303141007_add_optional_account_for_import.rb",
    "content": "class AddOptionalAccountForImport < ActiveRecord::Migration[7.2]\n  def change\n    rename_column :imports, :original_account_id, :account_id\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250304140435_add_default_period_to_users.rb",
    "content": "class AddDefaultPeriodToUsers < ActiveRecord::Migration[7.2]\n  def change\n    add_column :users, :default_period, :string, default: \"last_30_days\", null: false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250304200956_add_signage_type_to_imports.rb",
    "content": "class AddSignageTypeToImports < ActiveRecord::Migration[7.2]\n  def change\n    change_column_default :imports, :date_col_label, from: \"date\", to: nil\n    change_column_default :imports, :amount_col_label, from: \"amount\", to: nil\n    change_column_default :imports, :name_col_label, from: \"name\", to: nil\n    change_column_default :imports, :category_col_label, from: \"category\", to: nil\n    change_column_default :imports, :tags_col_label, from: \"tags\", to: nil\n    change_column_default :imports, :account_col_label, from: \"account\", to: nil\n    change_column_default :imports, :qty_col_label, from: \"qty\", to: nil\n    change_column_default :imports, :ticker_col_label, from: \"ticker\", to: nil\n    change_column_default :imports, :price_col_label, from: \"price\", to: nil\n    change_column_default :imports, :entity_type_col_label, from: \"entity_type\", to: nil\n    change_column_default :imports, :notes_col_label, from: \"notes\", to: nil\n    change_column_default :imports, :currency_col_label, from: \"currency\", to: nil\n\n    # User can select \"custom\", then assign \"debit\" or \"credit\" (or custom value) to determine inflow/outflow of txn\n    add_column :imports, :amount_type_strategy, :string, default: \"signed_amount\"\n    add_column :imports, :amount_type_inflow_value, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250315191233_remove_ticker_from_security_prices.rb",
    "content": "class RemoveTickerFromSecurityPrices < ActiveRecord::Migration[7.2]\n  def change\n    remove_column :security_prices, :ticker\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250316103753_remove_issues.rb",
    "content": "class RemoveIssues < ActiveRecord::Migration[7.2]\n  def change\n    drop_table :issues do |t|\n      t.references :issuable, polymorphic: true, null: false\n      t.string :type, null: false\n      t.integer :severity, null: false\n      t.datetime :last_observed_at\n      t.datetime :resolved_at\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250316122019_security_price_unique_index.rb",
    "content": "class SecurityPriceUniqueIndex < ActiveRecord::Migration[7.2]\n  def change\n    # First, we have to delete duplicate prices from DB so we can apply the unique index\n    reversible do |dir|\n      dir.up do\n        execute <<~SQL\n          DELETE FROM security_prices\n          WHERE id IN (\n            SELECT id FROM (\n              SELECT id,\n              ROW_NUMBER() OVER (\n                PARTITION BY security_id, date, currency\n                ORDER BY updated_at DESC, id DESC\n              ) as row_num\n              FROM security_prices\n            ) as duplicates\n            WHERE row_num > 1\n          );\n        SQL\n      end\n    end\n\n    add_index :security_prices, [ :security_id, :date, :currency ], unique: true\n    change_column_null :security_prices, :date, false\n    change_column_null :security_prices, :price, false\n    change_column_null :security_prices, :currency, false\n\n    change_column_null :exchange_rates, :date, false\n    change_column_null :exchange_rates, :rate, false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250318212559_remove_good_job.rb",
    "content": "class RemoveGoodJob < ActiveRecord::Migration[7.2]\n  def up\n    drop_table :good_job_batches\n    drop_table :good_job_executions\n    drop_table :good_job_processes\n    drop_table :good_job_settings\n    drop_table :good_jobs\n  end\n\n  def down\n    # Add the tables back if needed - see schema.rb for the full table definitions\n    raise ActiveRecord::IrreversibleMigration\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250319145426_remove_self_host_upgrades.rb",
    "content": "class RemoveSelfHostUpgrades < ActiveRecord::Migration[7.2]\n  def change\n    remove_column :users, :last_prompted_upgrade_commit_sha\n    remove_column :users, :last_alerted_upgrade_commit_sha\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250319212839_create_ai_chats.rb",
    "content": "class CreateAiChats < ActiveRecord::Migration[7.2]\n  def change\n    create_table :chats, id: :uuid do |t|\n      t.references :user, null: false, foreign_key: true, type: :uuid\n      t.string :title, null: false\n      t.string :instructions\n      t.jsonb :error\n      t.string :latest_assistant_response_id\n      t.timestamps\n    end\n\n    create_table :messages, id: :uuid do |t|\n      t.references :chat, null: false, foreign_key: true, type: :uuid\n      t.string :type, null: false\n      t.string :status, null: false, default: \"complete\"\n      t.text :content\n      t.string :ai_model\n      t.timestamps\n\n      # Developer message fields\n      t.boolean :debug, default: false\n\n      # Assistant message fields\n      t.string :provider_id\n      t.boolean :reasoning, default: false\n    end\n\n    create_table :tool_calls, id: :uuid do |t|\n      t.references :message, null: false, foreign_key: true, type: :uuid\n      t.string :provider_id, null: false\n      t.string :provider_call_id\n      t.string :type, null: false\n\n      # Function specific fields\n      t.string :function_name\n      t.jsonb :function_arguments\n      t.jsonb :function_result\n\n      t.timestamps\n    end\n\n    add_reference :users, :last_viewed_chat, foreign_key: { to_table: :chats }, null: true, type: :uuid\n    add_column :users, :show_ai_sidebar, :boolean, default: true\n    add_column :users, :ai_enabled, :boolean, default: false, null: false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250405210514_add_initial_balance_field.rb",
    "content": "class AddInitialBalanceField < ActiveRecord::Migration[7.2]\n  def change\n    add_column :loans, :initial_balance, :decimal, precision: 19, scale: 4\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250410144939_add_theme_to_users.rb",
    "content": "class AddThemeToUsers < ActiveRecord::Migration[7.2]\n  def change\n    add_column :users, :theme, :string, default: \"system\"\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250411140604_add_parent_syncs.rb",
    "content": "class AddParentSyncs < ActiveRecord::Migration[7.2]\n  def change\n    add_reference :syncs, :parent, foreign_key: { to_table: :syncs }, type: :uuid\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250413141446_table_renames.rb",
    "content": "class TableRenames < ActiveRecord::Migration[7.2]\n  def change\n    # Entryables\n    rename_table :account_entries, :entries\n    rename_table :account_trades, :trades\n    rename_table :account_valuations, :valuations\n    rename_table :account_transactions, :transactions\n\n    rename_table :account_balances, :balances\n    rename_table :account_holdings, :holdings\n\n    reversible do |dir|\n      dir.up do\n        execute <<~SQL\n          UPDATE entries\n          SET entryable_type = CASE\n            WHEN entryable_type = 'Account::Transaction' THEN 'Transaction'\n            WHEN entryable_type = 'Account::Trade' THEN 'Trade'\n            WHEN entryable_type = 'Account::Valuation' THEN 'Valuation'\n          END\n        SQL\n\n        execute <<~SQL\n          UPDATE taggings\n          SET taggable_type = CASE\n            WHEN taggable_type = 'Account::Transaction' THEN 'Transaction'\n          END\n        SQL\n      end\n\n      dir.down do\n        execute <<~SQL\n          UPDATE entries\n          SET entryable_type = CASE\n            WHEN entryable_type = 'Transaction' THEN 'Account::Transaction'\n            WHEN entryable_type = 'Trade' THEN 'Account::Trade'\n            WHEN entryable_type = 'Valuation' THEN 'Account::Valuation'\n          END\n        SQL\n\n        execute <<~SQL\n          UPDATE taggings\n          SET taggable_type = CASE\n            WHEN taggable_type = 'Transaction' THEN 'Account::Transaction'\n          END\n        SQL\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250416235317_add_rules_engine.rb",
    "content": "class AddRulesEngine < ActiveRecord::Migration[7.2]\n  def change\n    create_table :rules, id: :uuid do |t|\n      t.references :family, null: false, foreign_key: true, type: :uuid\n\n      t.string :resource_type, null: false\n      t.date :effective_date\n      t.boolean :active, null: false, default: false\n      t.timestamps\n    end\n\n    create_table :rule_conditions, id: :uuid do |t|\n      t.references :rule, foreign_key: true, type: :uuid\n      t.references :parent, foreign_key: { to_table: :rule_conditions }, type: :uuid\n\n      t.string :condition_type, null: false\n      t.string :operator, null: false\n      t.string :value\n      t.timestamps\n    end\n\n    create_table :rule_actions, id: :uuid do |t|\n      t.references :rule, null: false, foreign_key: true, type: :uuid\n\n      t.string :action_type, null: false\n      t.string :value\n      t.timestamps\n    end\n\n    add_column :users, :rule_prompts_disabled, :boolean, default: false\n    add_column :users, :rule_prompt_dismissed_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250416235420_add_data_enrichments.rb",
    "content": "class AddDataEnrichments < ActiveRecord::Migration[7.2]\n  def change\n    create_table :data_enrichments, id: :uuid do |t|\n      t.references :enrichable, polymorphic: true, null: false, type: :uuid\n      t.string :source\n      t.string :attribute_name\n      t.jsonb :value\n      t.jsonb :metadata\n\n      t.timestamps\n    end\n\n    add_index :data_enrichments, [ :enrichable_id, :enrichable_type, :source, :attribute_name ], unique: true\n\n    # Entries\n    add_column :entries, :locked_attributes, :jsonb, default: {}\n    add_column :transactions, :locked_attributes, :jsonb, default: {}\n    add_column :trades, :locked_attributes, :jsonb, default: {}\n    add_column :valuations, :locked_attributes, :jsonb, default: {}\n\n    # Accounts\n    add_column :accounts, :locked_attributes, :jsonb, default: {}\n    add_column :depositories, :locked_attributes, :jsonb, default: {}\n    add_column :investments, :locked_attributes, :jsonb, default: {}\n    add_column :cryptos, :locked_attributes, :jsonb, default: {}\n    add_column :properties, :locked_attributes, :jsonb, default: {}\n    add_column :vehicles, :locked_attributes, :jsonb, default: {}\n    add_column :other_assets, :locked_attributes, :jsonb, default: {}\n    add_column :credit_cards, :locked_attributes, :jsonb, default: {}\n    add_column :loans, :locked_attributes, :jsonb, default: {}\n    add_column :other_liabilities, :locked_attributes, :jsonb, default: {}\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250416235758_merchant_and_category_enrichment.rb",
    "content": "class MerchantAndCategoryEnrichment < ActiveRecord::Migration[7.2]\n  def change\n    change_column_null :merchants, :family_id, true\n    change_column_null :merchants, :color, true\n    change_column_default :merchants, :color, from: \"#e99537\", to: nil\n    remove_column :merchants, :enriched_at, :datetime\n    rename_column :merchants, :icon_url, :logo_url\n\n    add_column :merchants, :website_url, :string\n    add_column :merchants, :type, :string\n    add_index :merchants, :type\n\n    reversible do |dir|\n      dir.up do\n        ActiveRecord::Base.transaction do\n          # 1) Mark all existing as FamilyMerchant\n          Merchant.update_all(type: \"FamilyMerchant\")\n\n          # 2) Find duplicate family merchants\n          Merchant\n            .where(type: 'FamilyMerchant')\n            .group(:family_id, :name)\n            .having(\"COUNT(*) > 1\")\n            .pluck(:family_id, :name)\n            .each do |family_id, name|\n            # 3) Grab sorted IDs, first is keeper\n            ids, duplicate_ids = Merchant\n              .where(family_id: family_id, name: name)\n              .order(:id)\n              .pluck(:id)\n              .then { |arr| [ arr.first, arr.drop(1) ] }\n\n            next if duplicate_ids.empty?\n\n            # 4) Reassign all transactions pointing at the duplicates\n            Transaction.where(merchant_id: duplicate_ids)\n                       .update_all(merchant_id: ids)\n\n            # 5) Delete the duplicate merchant rows\n            Merchant.where(id: duplicate_ids).delete_all\n          end\n        end\n      end\n    end\n\n    change_column_null :merchants, :type, false\n\n    add_column :merchants, :source, :string\n    add_column :merchants, :provider_merchant_id, :string\n\n    add_index :merchants, [ :family_id, :name ], unique: true, where: \"type = 'FamilyMerchant'\"\n    add_index :merchants, [ :source, :name ], unique: true, where: \"type = 'ProviderMerchant'\"\n\n    add_column :transactions, :plaid_category, :string\n    add_column :transactions, :plaid_category_detailed, :string\n\n    remove_column :entries, :enriched_name, :string\n    remove_column :entries, :enriched_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250429021255_add_name_to_rules.rb",
    "content": "class AddNameToRules < ActiveRecord::Migration[7.2]\n  def change\n    add_column :rules, :name, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250501172430_add_user_goals.rb",
    "content": "class AddUserGoals < ActiveRecord::Migration[7.2]\n  def change\n    add_column :users, :goals, :text, array: true, default: []\n    add_column :users, :set_onboarding_preferences_at, :datetime\n    add_column :users, :set_onboarding_goals_at, :datetime\n\n    add_column :families, :trial_started_at, :datetime\n    add_column :families, :early_access, :boolean, default: false\n\n    reversible do |dir|\n      # All existing families are marked as early access now that we're out of alpha\n      dir.up do\n        Family.update_all(early_access: true)\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250502164951_create_subscriptions.rb",
    "content": "class CreateSubscriptions < ActiveRecord::Migration[7.2]\n  def change\n    create_table :subscriptions, id: :uuid do |t|\n      t.references :family, null: false, foreign_key: true, type: :uuid\n\n      t.string :status, null: false\n\n      t.string :stripe_id\n      t.decimal :amount, precision: 19, scale: 4\n      t.string :currency\n      t.string :interval\n\n      t.datetime :current_period_ends_at\n      t.datetime :trial_ends_at\n\n      t.timestamps\n    end\n\n    reversible do |dir|\n      dir.up do\n        if Rails.application.config.app_mode.managed?\n          execute <<~SQL\n            INSERT INTO subscriptions (family_id, status, trial_ends_at, created_at, updated_at)\n            SELECT\n              f.id,\n              CASE\n                WHEN f.trial_started_at IS NOT NULL THEN 'trialing'\n                ELSE COALESCE(f.stripe_subscription_status, 'incomplete')\n              END,\n              CASE\n                WHEN f.trial_started_at IS NOT NULL THEN f.trial_started_at + INTERVAL '14 days'\n                ELSE NULL\n              END,\n              now(),\n              now()\n            FROM families f\n          SQL\n        end\n      end\n    end\n\n    remove_column :families, :stripe_subscription_status, :string\n    remove_column :families, :trial_started_at, :datetime\n    remove_column :families, :stripe_plan_id, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250509182903_dynamic_last_synced.rb",
    "content": "class DynamicLastSynced < ActiveRecord::Migration[7.2]\n  def change\n    remove_column :plaid_items, :last_synced_at, :datetime\n    remove_column :accounts, :last_synced_at, :datetime\n    remove_column :families, :last_synced_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250512171654_update_sync_timestamps.rb",
    "content": "class UpdateSyncTimestamps < ActiveRecord::Migration[7.2]\n  def change\n    # Timestamps, managed by aasm\n    add_column :syncs, :pending_at, :datetime\n    add_column :syncs, :syncing_at, :datetime\n    add_column :syncs, :completed_at, :datetime\n    add_column :syncs, :failed_at, :datetime\n\n    add_column :syncs, :window_start_date, :date\n    add_column :syncs, :window_end_date, :date\n\n    reversible do |dir|\n      dir.up do\n        execute <<-SQL\n          UPDATE syncs\n          SET\n            completed_at = CASE\n              WHEN status = 'completed' THEN last_ran_at\n              ELSE NULL\n            END,\n            failed_at = CASE\n              WHEN status = 'failed' THEN last_ran_at\n              ELSE NULL\n            END\n        SQL\n\n        execute <<-SQL\n          UPDATE syncs\n          SET window_start_date = start_date\n        SQL\n\n        # Due to some recent bugs, some self hosters have syncs that are stuck.\n        # This manually fails those syncs so they stop seeing syncing UI notices.\n        if Rails.application.config.app_mode.self_hosted?\n          puts \"Self hosted: Fail syncs older than 2 hours\"\n          execute <<-SQL\n            UPDATE syncs\n            SET status = 'failed'\n            WHERE (\n              status = 'syncing' AND\n              created_at < NOW() - INTERVAL '2 hours'\n            )\n          SQL\n        end\n      end\n\n      dir.down do\n        execute <<-SQL\n          UPDATE syncs\n          SET\n            last_ran_at = COALESCE(completed_at, failed_at)\n        SQL\n\n        execute <<-SQL\n          UPDATE syncs\n          SET start_date = window_start_date\n        SQL\n      end\n    end\n\n    remove_column :syncs, :start_date, :date\n    remove_column :syncs, :last_ran_at, :datetime\n    remove_column :syncs, :error_backtrace, :text, array: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250513122703_add_uniqueness_to_subscriptions.rb",
    "content": "class AddUniquenessToSubscriptions < ActiveRecord::Migration[7.2]\n  def change\n    remove_index :subscriptions, :family_id\n    add_index :subscriptions, :family_id, unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250514214242_add_metadata_to_session.rb",
    "content": "class AddMetadataToSession < ActiveRecord::Migration[7.2]\n  def change\n    add_column :sessions, :data, :jsonb, default: {}\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250516180846_remove_stock_exchanges.rb",
    "content": "class RemoveStockExchanges < ActiveRecord::Migration[7.2]\n  def change\n    drop_table :stock_exchanges do |t|\n      t.string :name, null: false\n      t.string :acronym\n      t.string :mic, null: false\n      t.string :country, null: false\n      t.string :country_code, null: false\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250518181619_add_auto_sync_preference_to_family.rb",
    "content": "class AddAutoSyncPreferenceToFamily < ActiveRecord::Migration[7.2]\n  def change\n    add_column :families, :auto_sync_on_login, :boolean, default: true, null: false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250521112347_add_security_resolver_fields.rb",
    "content": "class AddSecurityResolverFields < ActiveRecord::Migration[7.2]\n  def change\n    add_column :securities, :offline, :boolean, default: false, null: false\n    add_column :securities, :failed_fetch_at, :datetime\n    add_column :securities, :failed_fetch_count, :integer, default: 0, null: false\n    add_column :securities, :last_health_check_at, :datetime\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250522201031_stronger_unique_index_on_security.rb",
    "content": "class StrongerUniqueIndexOnSecurity < ActiveRecord::Migration[7.2]\n  def change\n    remove_index :securities, [ :ticker, :exchange_operating_mic ], unique: true\n\n    # Matches our ActiveRecord validation:\n    # - uppercase ticker\n    # - either exchange_operating_mic or empty string (unique index doesn't work with NULL values)\n    add_index :securities,\n              \"UPPER(ticker), COALESCE(UPPER(exchange_operating_mic), '')\",\n              unique: true,\n              name: \"index_securities_on_ticker_and_exchange_operating_mic_unique\"\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250523131455_add_raw_payloads_to_plaid_accounts.rb",
    "content": "class AddRawPayloadsToPlaidAccounts < ActiveRecord::Migration[7.2]\n  def change\n    add_column :plaid_items, :raw_payload, :jsonb, default: {}\n    add_column :plaid_items, :raw_institution_payload, :jsonb, default: {}\n\n    change_column_null :plaid_items, :plaid_id, false\n    add_index :plaid_items, :plaid_id, unique: true\n\n    add_column :plaid_accounts, :raw_payload, :jsonb, default: {}\n    add_column :plaid_accounts, :raw_transactions_payload, :jsonb, default: {}\n    add_column :plaid_accounts, :raw_investments_payload, :jsonb, default: {}\n    add_column :plaid_accounts, :raw_liabilities_payload, :jsonb, default: {}\n\n    change_column_null :plaid_accounts, :plaid_id, false\n    change_column_null :plaid_accounts, :plaid_type, false\n    change_column_null :plaid_accounts, :currency, false\n    change_column_null :plaid_accounts, :name, false\n    add_index :plaid_accounts, :plaid_id, unique: true\n\n    # No longer need to store on transaction model because it is stored in raw_transactions_payload\n    remove_column :transactions, :plaid_category, :string\n    remove_column :transactions, :plaid_category_detailed, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250605031616_add_index_to_sync_status.rb",
    "content": "class AddIndexToSyncStatus < ActiveRecord::Migration[7.2]\n  disable_ddl_transaction!\n\n  def change\n    add_index :syncs, :status, if_not_exists: true, algorithm: :concurrently\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250610181219_add_sync_timestamps_to_family.rb",
    "content": "class AddSyncTimestampsToFamily < ActiveRecord::Migration[7.2]\n  def change\n    add_column :families, :latest_sync_activity_at, :datetime, default: -> { \"CURRENT_TIMESTAMP\" }\n    add_column :families, :latest_sync_completed_at, :datetime, default: -> { \"CURRENT_TIMESTAMP\" }\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250612150749_create_doorkeeper_tables.rb",
    "content": "# frozen_string_literal: true\n\nclass CreateDoorkeeperTables < ActiveRecord::Migration[7.2]\n  def change\n    create_table :oauth_applications do |t|\n      t.string  :name,    null: false\n      t.string  :uid,     null: false\n      # Remove `null: false` or use conditional constraint if you are planning to use public clients.\n      t.string  :secret,  null: false\n\n      # Remove `null: false` if you are planning to use grant flows\n      # that doesn't require redirect URI to be used during authorization\n      # like Client Credentials flow or Resource Owner Password.\n      t.text    :redirect_uri, null: false\n      t.string  :scopes,       null: false, default: ''\n      t.boolean :confidential, null: false, default: true\n      t.timestamps             null: false\n    end\n\n    add_index :oauth_applications, :uid, unique: true\n\n    create_table :oauth_access_grants do |t|\n      t.references :resource_owner,  null: false\n      t.references :application,     null: false\n      t.string   :token,             null: false\n      t.integer  :expires_in,        null: false\n      t.text     :redirect_uri,      null: false\n      t.string   :scopes,            null: false, default: ''\n      t.datetime :created_at,        null: false\n      t.datetime :revoked_at\n    end\n\n    add_index :oauth_access_grants, :token, unique: true\n    add_foreign_key(\n      :oauth_access_grants,\n      :oauth_applications,\n      column: :application_id\n    )\n\n    create_table :oauth_access_tokens do |t|\n      t.references :resource_owner, index: true\n\n      # Remove `null: false` if you are planning to use Password\n      # Credentials Grant flow that doesn't require an application.\n      t.references :application,    null: false\n\n      # If you use a custom token generator you may need to change this column\n      # from string to text, so that it accepts tokens larger than 255\n      # characters. More info on custom token generators in:\n      # https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator\n      #\n      # t.text :token, null: false\n      t.string :token, null: false\n\n      t.string   :refresh_token\n      t.integer  :expires_in\n      t.string   :scopes\n      t.datetime :created_at, null: false\n      t.datetime :revoked_at\n\n      # The authorization server MAY issue a new refresh token, in which case\n      # *the client MUST discard the old refresh token* and replace it with the\n      # new refresh token. The authorization server MAY revoke the old\n      # refresh token after issuing a new refresh token to the client.\n      # @see https://datatracker.ietf.org/doc/html/rfc6749#section-6\n      #\n      # Doorkeeper implementation: if there is a `previous_refresh_token` column,\n      # refresh tokens will be revoked after a related access token is used.\n      # If there is no `previous_refresh_token` column, previous tokens are\n      # revoked as soon as a new access token is created.\n      #\n      # Comment out this line if you want refresh tokens to be instantly\n      # revoked after use.\n      t.string   :previous_refresh_token, null: false, default: \"\"\n    end\n\n    add_index :oauth_access_tokens, :token, unique: true\n\n    # See https://github.com/doorkeeper-gem/doorkeeper/issues/1592\n    if ActiveRecord::Base.connection.adapter_name == \"SQLServer\"\n      execute <<~SQL.squish\n        CREATE UNIQUE NONCLUSTERED INDEX index_oauth_access_tokens_on_refresh_token ON oauth_access_tokens(refresh_token)\n        WHERE refresh_token IS NOT NULL\n      SQL\n    else\n      add_index :oauth_access_tokens, :refresh_token, unique: true\n    end\n\n    add_foreign_key(\n      :oauth_access_tokens,\n      :oauth_applications,\n      column: :application_id\n    )\n\n    # Uncomment below to ensure a valid reference to the resource owner's table\n    # add_foreign_key :oauth_access_grants, <model>, column: :resource_owner_id\n    # add_foreign_key :oauth_access_tokens, <model>, column: :resource_owner_id\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250612154522_fix_doorkeeper_resource_owner_id_for_uuid.rb",
    "content": "class FixDoorkeeperResourceOwnerIdForUuid < ActiveRecord::Migration[7.1]\n  def up\n    change_column :oauth_access_tokens, :resource_owner_id, :string\n  end\n\n  def down\n    change_column :oauth_access_tokens, :resource_owner_id, :integer\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250613002027_create_api_keys.rb",
    "content": "class CreateApiKeys < ActiveRecord::Migration[7.2]\n  def change\n    create_table :api_keys, id: :uuid do |t|\n      t.string :key\n      t.string :name\n      t.references :user, null: false, foreign_key: true, type: :uuid\n      t.json :scopes\n      t.datetime :last_used_at\n      t.datetime :expires_at\n      t.datetime :revoked_at\n\n      t.timestamps\n    end\n    add_index :api_keys, :key\n    add_index :api_keys, :revoked_at\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250613100842_add_display_key_to_api_keys.rb",
    "content": "class AddDisplayKeyToApiKeys < ActiveRecord::Migration[7.2]\n  def change\n    add_column :api_keys, :display_key, :string, null: false\n    add_index :api_keys, :display_key, unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250613101036_remove_key_from_api_keys.rb",
    "content": "class RemoveKeyFromApiKeys < ActiveRecord::Migration[7.2]\n  def change\n    remove_column :api_keys, :key, :string\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250613101051_remove_key_index_from_api_keys.rb",
    "content": "class RemoveKeyIndexFromApiKeys < ActiveRecord::Migration[7.2]\n  def change\n    remove_index :api_keys, :key if index_exists?(:api_keys, :key)\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250613152743_fix_doorkeeper_access_grants_resource_owner_id_for_uuid.rb",
    "content": "class FixDoorkeeperAccessGrantsResourceOwnerIdForUuid < ActiveRecord::Migration[7.2]\n  def up\n    change_column :oauth_access_grants, :resource_owner_id, :string\n  end\n\n  def down\n    change_column :oauth_access_grants, :resource_owner_id, :bigint\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250616183654_add_kind_to_transactions.rb",
    "content": "class AddKindToTransactions < ActiveRecord::Migration[7.2]\n  def change\n    add_column :transactions, :kind, :string, null: false, default: \"standard\"\n    add_index :transactions, :kind\n\n    reversible do |dir|\n      dir.up do\n        # Update transaction kinds based on transfer relationships\n        execute <<~SQL\n          UPDATE transactions\n          SET kind = CASE\n            WHEN destination_accounts.accountable_type = 'Loan' AND entries.amount > 0 THEN 'loan_payment'\n            WHEN destination_accounts.accountable_type = 'CreditCard' AND entries.amount > 0 THEN 'cc_payment'\n            ELSE 'funds_movement'\n          END\n          FROM transfers t\n          JOIN entries ON (\n            entries.entryable_id = t.inflow_transaction_id OR\n            entries.entryable_id = t.outflow_transaction_id\n          )\n          LEFT JOIN entries inflow_entries ON (\n            inflow_entries.entryable_id = t.inflow_transaction_id\n            AND inflow_entries.entryable_type = 'Transaction'\n          )\n          LEFT JOIN accounts destination_accounts ON destination_accounts.id = inflow_entries.account_id\n          WHERE transactions.id = entries.entryable_id\n            AND entries.entryable_type = 'Transaction'\n        SQL\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250618104425_add_source_to_api_keys.rb",
    "content": "class AddSourceToApiKeys < ActiveRecord::Migration[7.2]\n  def change\n    add_column :api_keys, :source, :string, default: \"web\"\n    add_index :api_keys, [ :user_id, :source ]\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250618110104_create_mobile_devices.rb",
    "content": "class CreateMobileDevices < ActiveRecord::Migration[7.2]\n  def change\n    create_table :mobile_devices, id: :uuid do |t|\n      t.references :user, null: false, foreign_key: true, type: :uuid\n      t.string :device_id\n      t.string :device_name\n      t.string :device_type\n      t.string :os_version\n      t.string :app_version\n      t.datetime :last_seen_at\n\n      t.timestamps\n    end\n    add_index :mobile_devices, :device_id, unique: true\n    add_index :mobile_devices, [ :user_id, :device_id ], unique: true\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250618110736_add_owner_to_oauth_applications.rb",
    "content": "class AddOwnerToOauthApplications < ActiveRecord::Migration[7.2]\n  def change\n    add_column :oauth_applications, :owner_id, :uuid\n    add_column :oauth_applications, :owner_type, :string\n    add_index :oauth_applications, [ :owner_id, :owner_type ]\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250618120703_add_oauth_application_to_mobile_devices.rb",
    "content": "class AddOauthApplicationToMobileDevices < ActiveRecord::Migration[7.2]\n  def change\n    add_column :mobile_devices, :oauth_application_id, :integer\n    add_index :mobile_devices, :oauth_application_id\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250620204550_update_excluded_transactions_to_one_time.rb",
    "content": "class UpdateExcludedTransactionsToOneTime < ActiveRecord::Migration[7.2]\n  def change\n    reversible do |dir|\n      dir.up do\n        # Update all transactions that have excluded entries to be one_time\n        # They remain excluded as well since users were using excluded as \"one time\" before\n        execute <<~SQL\n          UPDATE transactions\n          SET kind = 'one_time'\n          FROM entries\n          WHERE entries.entryable_id = transactions.id\n            AND entries.entryable_type = 'Transaction'\n            AND entries.excluded = true\n            AND transactions.kind = 'standard'\n        SQL\n      end\n\n      dir.down do\n        # Revert one_time transactions back to standard if their entry is excluded\n        # This assumes these were the ones we migrated in the up method\n        execute <<~SQL\n          UPDATE transactions\n          SET kind = 'standard'\n          FROM entries\n          WHERE entries.entryable_id = transactions.id\n            AND entries.entryable_type = 'Transaction'\n            AND entries.excluded = true\n            AND transactions.kind = 'one_time'\n        SQL\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250623162207_update_outdated_timezones.rb",
    "content": "class UpdateOutdatedTimezones < ActiveRecord::Migration[7.2]\n  TIMEZONE_MAPPINGS = {\n    \"Europe/Kiev\" => \"Europe/Kyiv\",\n    \"Asia/Calcutta\" => \"Asia/Kolkata\",\n    \"Asia/Katmandu\" => \"Asia/Kathmandu\",\n    \"Asia/Rangoon\" => \"Asia/Yangon\",\n    \"Asia/Saigon\" => \"Asia/Ho_Chi_Minh\",\n    \"Pacific/Ponape\" => \"Pacific/Pohnpei\",\n    \"Pacific/Truk\" => \"Pacific/Chuuk\"\n  }.freeze\n\n  def up\n    TIMEZONE_MAPPINGS.each do |old_tz, new_tz|\n      execute <<-SQL\n        UPDATE families#{' '}\n        SET timezone = '#{new_tz}'#{' '}\n        WHERE timezone = '#{old_tz}'\n      SQL\n    end\n  end\n\n  def down\n    TIMEZONE_MAPPINGS.each do |old_tz, new_tz|\n      execute <<-SQL\n        UPDATE families#{' '}\n        SET timezone = '#{old_tz}'#{' '}\n        WHERE timezone = '#{new_tz}'\n      SQL\n    end\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250701161640_add_account_status.rb",
    "content": "class AddAccountStatus < ActiveRecord::Migration[7.2]\n  def up\n    add_column :accounts, :status, :string, default: \"active\"\n    change_column_null :entries, :amount, false\n\n    # Migrate existing data\n    execute <<-SQL\n      UPDATE accounts\n      SET status = CASE\n        WHEN scheduled_for_deletion = true THEN 'pending_deletion'\n        WHEN is_active = true THEN 'active'\n        WHEN is_active = false THEN 'disabled'\n        ELSE 'draft'\n      END\n    SQL\n\n    remove_column :accounts, :is_active\n    remove_column :accounts, :scheduled_for_deletion\n  end\n\n  def down\n    add_column :accounts, :is_active, :boolean, default: true, null: false\n    add_column :accounts, :scheduled_for_deletion, :boolean, default: false\n\n    # Restore the original boolean fields based on status\n    execute <<-SQL\n      UPDATE accounts\n      SET is_active = CASE\n        WHEN status = 'active' THEN true\n        WHEN status IN ('disabled', 'pending_deletion') THEN false\n        ELSE false\n      END,\n      scheduled_for_deletion = CASE\n        WHEN status = 'pending_deletion' THEN true\n        ELSE false\n      END\n    SQL\n\n    remove_column :accounts, :status\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250702173231_fix_mobile_devices_unique_constraint.rb",
    "content": "class FixMobileDevicesUniqueConstraint < ActiveRecord::Migration[7.2]\n  def change\n    # Remove the old unique index on device_id only\n    remove_index :mobile_devices, :device_id, if_exists: true\n\n    # The composite unique index on user_id and device_id already exists\n    # This allows the same device_id to be used by different users\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250710225721_add_valuation_kind.rb",
    "content": "class AddValuationKind < ActiveRecord::Migration[7.2]\n  def change\n    add_column :valuations, :kind, :string, default: \"reconciliation\", null: false\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250718120146_add_indexes_to_core_models.rb",
    "content": "class AddIndexesToCoreModels < ActiveRecord::Migration[7.2]\n  def change\n    # Accounts table indexes\n    add_index :accounts, [ :family_id, :status ]\n    add_index :accounts, :status\n    add_index :accounts, :currency\n\n    # Balances table indexes\n    add_index :balances, [ :account_id, :date ], order: { date: :desc }\n\n    # Entries table indexes\n    add_index :entries, [ :account_id, :date ]\n    add_index :entries, :date\n    add_index :entries, :entryable_type\n    add_index :entries, \"lower(name)\", name: \"index_entries_on_lower_name\"\n\n    # Transfers table indexes\n    add_index :transfers, :status\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250719121103_add_start_end_columns_to_balances.rb",
    "content": "class AddStartEndColumnsToBalances < ActiveRecord::Migration[7.2]\n  def up\n    # Add new columns for balance tracking\n    add_column :balances, :start_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0\n    add_column :balances, :start_non_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0\n\n    # Flow tracking columns (absolute values)\n    add_column :balances, :cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0\n    add_column :balances, :cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0\n    add_column :balances, :non_cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0\n    add_column :balances, :non_cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0\n\n    # Market value changes\n    add_column :balances, :net_market_flows, :decimal, precision: 19, scale: 4, null: false, default: 0.0\n\n    # Manual adjustments from valuations\n    add_column :balances, :cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0\n    add_column :balances, :non_cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0\n\n    # Flows factor determines *how* the flows affect the balance.\n    # Inflows increase asset accounts, while inflows decrease liability accounts (reducing debt via \"payment\")\n    add_column :balances, :flows_factor, :integer, null: false, default: 1\n\n    # Add generated columns\n    change_table :balances do |t|\n      t.virtual :start_balance, type: :decimal, precision: 19, scale: 4, stored: true,\n        as: \"start_cash_balance + start_non_cash_balance\"\n\n      t.virtual :end_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true,\n        as: \"start_cash_balance + ((cash_inflows - cash_outflows) * flows_factor) + cash_adjustments\"\n\n      t.virtual :end_non_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true,\n        as: \"start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * flows_factor) + net_market_flows + non_cash_adjustments\"\n\n      # Postgres doesn't support generated columns depending on other generated columns,\n      # but we want the integrity of the data to happen at the DB level, so this is the full formula.\n      # Formula: (cash components) + (non-cash components)\n      t.virtual :end_balance, type: :decimal, precision: 19, scale: 4, stored: true,\n        as: <<~SQL.squish\n          (\n            start_cash_balance +\n            ((cash_inflows - cash_outflows) * flows_factor) +\n            cash_adjustments\n          ) + (\n            start_non_cash_balance +\n            ((non_cash_inflows - non_cash_outflows) * flows_factor) +\n            net_market_flows +\n            non_cash_adjustments\n          )\n        SQL\n    end\n  end\n\n  def down\n    # Remove generated columns first (PostgreSQL requirement)\n    remove_column :balances, :start_balance\n    remove_column :balances, :end_cash_balance\n    remove_column :balances, :end_non_cash_balance\n    remove_column :balances, :end_balance\n\n    # Remove new columns\n    remove_column :balances, :start_cash_balance\n    remove_column :balances, :start_non_cash_balance\n    remove_column :balances, :cash_inflows\n    remove_column :balances, :cash_outflows\n    remove_column :balances, :non_cash_inflows\n    remove_column :balances, :non_cash_outflows\n    remove_column :balances, :net_market_flows\n    remove_column :balances, :cash_adjustments\n    remove_column :balances, :non_cash_adjustments\n  end\nend\n"
  },
  {
    "path": "db/migrate/20250724115507_create_family_exports.rb",
    "content": "class CreateFamilyExports < ActiveRecord::Migration[7.2]\n  def change\n    create_table :family_exports, id: :uuid do |t|\n      t.references :family, null: false, foreign_key: true, type: :uuid\n      t.string :status, default: \"pending\", null: false\n\n      t.timestamps\n    end\n  end\nend\n"
  },
  {
    "path": "db/schema.rb",
    "content": "# This file is auto-generated from the current state of the database. Instead\n# of editing this file, please use the migrations feature of Active Record to\n# incrementally modify your database, and then regenerate this schema definition.\n#\n# This file is the source Rails uses to define your schema when running `bin/rails\n# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to\n# be faster and is potentially less error prone than running all of your\n# migrations from scratch. Old migrations may fail to apply correctly if those\n# migrations use external dependencies or application code.\n#\n# It's strongly recommended that you check this file into your version control system.\n\nActiveRecord::Schema[7.2].define(version: 2025_07_24_115507) do\n  # These are extensions that must be enabled in order to support this database\n  enable_extension \"pgcrypto\"\n  enable_extension \"plpgsql\"\n\n  # Custom types defined in this database.\n  # Note that some types may not work with other database engines. Be careful if changing database.\n  create_enum \"account_status\", [\"ok\", \"syncing\", \"error\"]\n\n  create_table \"accounts\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"subtype\"\n    t.uuid \"family_id\", null: false\n    t.string \"name\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"accountable_type\"\n    t.uuid \"accountable_id\"\n    t.decimal \"balance\", precision: 19, scale: 4\n    t.string \"currency\"\n    t.virtual \"classification\", type: :string, as: \"\\nCASE\\n    WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\\n    ELSE 'asset'::text\\nEND\", stored: true\n    t.uuid \"import_id\"\n    t.uuid \"plaid_account_id\"\n    t.decimal \"cash_balance\", precision: 19, scale: 4, default: \"0.0\"\n    t.jsonb \"locked_attributes\", default: {}\n    t.string \"status\", default: \"active\"\n    t.index [\"accountable_id\", \"accountable_type\"], name: \"index_accounts_on_accountable_id_and_accountable_type\"\n    t.index [\"accountable_type\"], name: \"index_accounts_on_accountable_type\"\n    t.index [\"currency\"], name: \"index_accounts_on_currency\"\n    t.index [\"family_id\", \"accountable_type\"], name: \"index_accounts_on_family_id_and_accountable_type\"\n    t.index [\"family_id\", \"id\"], name: \"index_accounts_on_family_id_and_id\"\n    t.index [\"family_id\", \"status\"], name: \"index_accounts_on_family_id_and_status\"\n    t.index [\"family_id\"], name: \"index_accounts_on_family_id\"\n    t.index [\"import_id\"], name: \"index_accounts_on_import_id\"\n    t.index [\"plaid_account_id\"], name: \"index_accounts_on_plaid_account_id\"\n    t.index [\"status\"], name: \"index_accounts_on_status\"\n  end\n\n  create_table \"active_storage_attachments\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"name\", null: false\n    t.string \"record_type\", null: false\n    t.uuid \"record_id\", null: false\n    t.uuid \"blob_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.index [\"blob_id\"], name: \"index_active_storage_attachments_on_blob_id\"\n    t.index [\"record_type\", \"record_id\", \"name\", \"blob_id\"], name: \"index_active_storage_attachments_uniqueness\", unique: true\n  end\n\n  create_table \"active_storage_blobs\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"key\", null: false\n    t.string \"filename\", null: false\n    t.string \"content_type\"\n    t.text \"metadata\"\n    t.string \"service_name\", null: false\n    t.bigint \"byte_size\", null: false\n    t.string \"checksum\"\n    t.datetime \"created_at\", null: false\n    t.index [\"key\"], name: \"index_active_storage_blobs_on_key\", unique: true\n  end\n\n  create_table \"active_storage_variant_records\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"blob_id\", null: false\n    t.string \"variation_digest\", null: false\n    t.index [\"blob_id\", \"variation_digest\"], name: \"index_active_storage_variant_records_uniqueness\", unique: true\n  end\n\n  create_table \"addresses\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"addressable_type\"\n    t.uuid \"addressable_id\"\n    t.string \"line1\"\n    t.string \"line2\"\n    t.string \"county\"\n    t.string \"locality\"\n    t.string \"region\"\n    t.string \"country\"\n    t.integer \"postal_code\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"addressable_type\", \"addressable_id\"], name: \"index_addresses_on_addressable\"\n  end\n\n  create_table \"api_keys\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"name\"\n    t.uuid \"user_id\", null: false\n    t.json \"scopes\"\n    t.datetime \"last_used_at\"\n    t.datetime \"expires_at\"\n    t.datetime \"revoked_at\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"display_key\", null: false\n    t.string \"source\", default: \"web\"\n    t.index [\"display_key\"], name: \"index_api_keys_on_display_key\", unique: true\n    t.index [\"revoked_at\"], name: \"index_api_keys_on_revoked_at\"\n    t.index [\"user_id\", \"source\"], name: \"index_api_keys_on_user_id_and_source\"\n    t.index [\"user_id\"], name: \"index_api_keys_on_user_id\"\n  end\n\n  create_table \"balances\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.date \"date\", null: false\n    t.decimal \"balance\", precision: 19, scale: 4, null: false\n    t.string \"currency\", default: \"USD\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.decimal \"cash_balance\", precision: 19, scale: 4, default: \"0.0\"\n    t.decimal \"start_cash_balance\", precision: 19, scale: 4, default: \"0.0\", null: false\n    t.decimal \"start_non_cash_balance\", precision: 19, scale: 4, default: \"0.0\", null: false\n    t.decimal \"cash_inflows\", precision: 19, scale: 4, default: \"0.0\", null: false\n    t.decimal \"cash_outflows\", precision: 19, scale: 4, default: \"0.0\", null: false\n    t.decimal \"non_cash_inflows\", precision: 19, scale: 4, default: \"0.0\", null: false\n    t.decimal \"non_cash_outflows\", precision: 19, scale: 4, default: \"0.0\", null: false\n    t.decimal \"net_market_flows\", precision: 19, scale: 4, default: \"0.0\", null: false\n    t.decimal \"cash_adjustments\", precision: 19, scale: 4, default: \"0.0\", null: false\n    t.decimal \"non_cash_adjustments\", precision: 19, scale: 4, default: \"0.0\", null: false\n    t.integer \"flows_factor\", default: 1, null: false\n    t.virtual \"start_balance\", type: :decimal, precision: 19, scale: 4, as: \"(start_cash_balance + start_non_cash_balance)\", stored: true\n    t.virtual \"end_cash_balance\", type: :decimal, precision: 19, scale: 4, as: \"((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)\", stored: true\n    t.virtual \"end_non_cash_balance\", type: :decimal, precision: 19, scale: 4, as: \"(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)\", stored: true\n    t.virtual \"end_balance\", type: :decimal, precision: 19, scale: 4, as: \"(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))\", stored: true\n    t.index [\"account_id\", \"date\", \"currency\"], name: \"index_account_balances_on_account_id_date_currency_unique\", unique: true\n    t.index [\"account_id\", \"date\"], name: \"index_balances_on_account_id_and_date\", order: { date: :desc }\n    t.index [\"account_id\"], name: \"index_balances_on_account_id\"\n  end\n\n  create_table \"budget_categories\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"budget_id\", null: false\n    t.uuid \"category_id\", null: false\n    t.decimal \"budgeted_spending\", precision: 19, scale: 4, null: false\n    t.string \"currency\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"budget_id\", \"category_id\"], name: \"index_budget_categories_on_budget_id_and_category_id\", unique: true\n    t.index [\"budget_id\"], name: \"index_budget_categories_on_budget_id\"\n    t.index [\"category_id\"], name: \"index_budget_categories_on_category_id\"\n  end\n\n  create_table \"budgets\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"family_id\", null: false\n    t.date \"start_date\", null: false\n    t.date \"end_date\", null: false\n    t.decimal \"budgeted_spending\", precision: 19, scale: 4\n    t.decimal \"expected_income\", precision: 19, scale: 4\n    t.string \"currency\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"family_id\", \"start_date\", \"end_date\"], name: \"index_budgets_on_family_id_and_start_date_and_end_date\", unique: true\n    t.index [\"family_id\"], name: \"index_budgets_on_family_id\"\n  end\n\n  create_table \"categories\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"name\", null: false\n    t.string \"color\", default: \"#6172F3\", null: false\n    t.uuid \"family_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"parent_id\"\n    t.string \"classification\", default: \"expense\", null: false\n    t.string \"lucide_icon\", default: \"shapes\", null: false\n    t.index [\"family_id\"], name: \"index_categories_on_family_id\"\n  end\n\n  create_table \"chats\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"user_id\", null: false\n    t.string \"title\", null: false\n    t.string \"instructions\"\n    t.jsonb \"error\"\n    t.string \"latest_assistant_response_id\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"user_id\"], name: \"index_chats_on_user_id\"\n  end\n\n  create_table \"credit_cards\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.decimal \"available_credit\", precision: 10, scale: 2\n    t.decimal \"minimum_payment\", precision: 10, scale: 2\n    t.decimal \"apr\", precision: 10, scale: 2\n    t.date \"expiration_date\"\n    t.decimal \"annual_fee\", precision: 10, scale: 2\n    t.jsonb \"locked_attributes\", default: {}\n  end\n\n  create_table \"cryptos\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.jsonb \"locked_attributes\", default: {}\n  end\n\n  create_table \"data_enrichments\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"enrichable_type\", null: false\n    t.uuid \"enrichable_id\", null: false\n    t.string \"source\"\n    t.string \"attribute_name\"\n    t.jsonb \"value\"\n    t.jsonb \"metadata\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"enrichable_id\", \"enrichable_type\", \"source\", \"attribute_name\"], name: \"idx_on_enrichable_id_enrichable_type_source_attribu_5be5f63e08\", unique: true\n    t.index [\"enrichable_type\", \"enrichable_id\"], name: \"index_data_enrichments_on_enrichable\"\n  end\n\n  create_table \"depositories\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.jsonb \"locked_attributes\", default: {}\n  end\n\n  create_table \"entries\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.string \"entryable_type\"\n    t.uuid \"entryable_id\"\n    t.decimal \"amount\", precision: 19, scale: 4, null: false\n    t.string \"currency\"\n    t.date \"date\"\n    t.string \"name\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"import_id\"\n    t.text \"notes\"\n    t.boolean \"excluded\", default: false\n    t.string \"plaid_id\"\n    t.jsonb \"locked_attributes\", default: {}\n    t.index \"lower((name)::text)\", name: \"index_entries_on_lower_name\"\n    t.index [\"account_id\", \"date\"], name: \"index_entries_on_account_id_and_date\"\n    t.index [\"account_id\"], name: \"index_entries_on_account_id\"\n    t.index [\"date\"], name: \"index_entries_on_date\"\n    t.index [\"entryable_type\"], name: \"index_entries_on_entryable_type\"\n    t.index [\"import_id\"], name: \"index_entries_on_import_id\"\n  end\n\n  create_table \"exchange_rates\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"from_currency\", null: false\n    t.string \"to_currency\", null: false\n    t.decimal \"rate\", null: false\n    t.date \"date\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"from_currency\", \"to_currency\", \"date\"], name: \"index_exchange_rates_on_base_converted_date_unique\", unique: true\n    t.index [\"from_currency\"], name: \"index_exchange_rates_on_from_currency\"\n    t.index [\"to_currency\"], name: \"index_exchange_rates_on_to_currency\"\n  end\n\n  create_table \"families\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"name\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"currency\", default: \"USD\"\n    t.string \"locale\", default: \"en\"\n    t.string \"stripe_customer_id\"\n    t.string \"date_format\", default: \"%m-%d-%Y\"\n    t.string \"country\", default: \"US\"\n    t.string \"timezone\"\n    t.boolean \"data_enrichment_enabled\", default: false\n    t.boolean \"early_access\", default: false\n    t.boolean \"auto_sync_on_login\", default: true, null: false\n    t.datetime \"latest_sync_activity_at\", default: -> { \"CURRENT_TIMESTAMP\" }\n    t.datetime \"latest_sync_completed_at\", default: -> { \"CURRENT_TIMESTAMP\" }\n  end\n\n  create_table \"family_exports\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"family_id\", null: false\n    t.string \"status\", default: \"pending\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"family_id\"], name: \"index_family_exports_on_family_id\"\n  end\n\n  create_table \"holdings\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"account_id\", null: false\n    t.uuid \"security_id\", null: false\n    t.date \"date\", null: false\n    t.decimal \"qty\", precision: 19, scale: 4, null: false\n    t.decimal \"price\", precision: 19, scale: 4, null: false\n    t.decimal \"amount\", precision: 19, scale: 4, null: false\n    t.string \"currency\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"account_id\", \"security_id\", \"date\", \"currency\"], name: \"idx_on_account_id_security_id_date_currency_5323e39f8b\", unique: true\n    t.index [\"account_id\"], name: \"index_holdings_on_account_id\"\n    t.index [\"security_id\"], name: \"index_holdings_on_security_id\"\n  end\n\n  create_table \"impersonation_session_logs\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"impersonation_session_id\", null: false\n    t.string \"controller\"\n    t.string \"action\"\n    t.text \"path\"\n    t.string \"method\"\n    t.string \"ip_address\"\n    t.text \"user_agent\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"impersonation_session_id\"], name: \"index_impersonation_session_logs_on_impersonation_session_id\"\n  end\n\n  create_table \"impersonation_sessions\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"impersonator_id\", null: false\n    t.uuid \"impersonated_id\", null: false\n    t.string \"status\", default: \"pending\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"impersonated_id\"], name: \"index_impersonation_sessions_on_impersonated_id\"\n    t.index [\"impersonator_id\"], name: \"index_impersonation_sessions_on_impersonator_id\"\n  end\n\n  create_table \"import_mappings\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"type\", null: false\n    t.string \"key\"\n    t.string \"value\"\n    t.boolean \"create_when_empty\", default: true\n    t.uuid \"import_id\", null: false\n    t.string \"mappable_type\"\n    t.uuid \"mappable_id\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"import_id\"], name: \"index_import_mappings_on_import_id\"\n    t.index [\"mappable_type\", \"mappable_id\"], name: \"index_import_mappings_on_mappable\"\n  end\n\n  create_table \"import_rows\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"import_id\", null: false\n    t.string \"account\"\n    t.string \"date\"\n    t.string \"qty\"\n    t.string \"ticker\"\n    t.string \"price\"\n    t.string \"amount\"\n    t.string \"currency\"\n    t.string \"name\"\n    t.string \"category\"\n    t.string \"tags\"\n    t.string \"entity_type\"\n    t.text \"notes\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"exchange_operating_mic\"\n    t.index [\"import_id\"], name: \"index_import_rows_on_import_id\"\n  end\n\n  create_table \"imports\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.jsonb \"column_mappings\"\n    t.string \"status\"\n    t.string \"raw_file_str\"\n    t.string \"normalized_csv_str\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"col_sep\", default: \",\"\n    t.uuid \"family_id\", null: false\n    t.uuid \"account_id\"\n    t.string \"type\", null: false\n    t.string \"date_col_label\"\n    t.string \"amount_col_label\"\n    t.string \"name_col_label\"\n    t.string \"category_col_label\"\n    t.string \"tags_col_label\"\n    t.string \"account_col_label\"\n    t.string \"qty_col_label\"\n    t.string \"ticker_col_label\"\n    t.string \"price_col_label\"\n    t.string \"entity_type_col_label\"\n    t.string \"notes_col_label\"\n    t.string \"currency_col_label\"\n    t.string \"date_format\", default: \"%m/%d/%Y\"\n    t.string \"signage_convention\", default: \"inflows_positive\"\n    t.string \"error\"\n    t.string \"number_format\"\n    t.string \"exchange_operating_mic_col_label\"\n    t.string \"amount_type_strategy\", default: \"signed_amount\"\n    t.string \"amount_type_inflow_value\"\n    t.index [\"family_id\"], name: \"index_imports_on_family_id\"\n  end\n\n  create_table \"investments\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.jsonb \"locked_attributes\", default: {}\n  end\n\n  create_table \"invitations\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"email\"\n    t.string \"role\"\n    t.string \"token\"\n    t.uuid \"family_id\", null: false\n    t.uuid \"inviter_id\", null: false\n    t.datetime \"accepted_at\"\n    t.datetime \"expires_at\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"email\", \"family_id\"], name: \"index_invitations_on_email_and_family_id\", unique: true\n    t.index [\"email\"], name: \"index_invitations_on_email\"\n    t.index [\"family_id\"], name: \"index_invitations_on_family_id\"\n    t.index [\"inviter_id\"], name: \"index_invitations_on_inviter_id\"\n    t.index [\"token\"], name: \"index_invitations_on_token\", unique: true\n  end\n\n  create_table \"invite_codes\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"token\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"token\"], name: \"index_invite_codes_on_token\", unique: true\n  end\n\n  create_table \"loans\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"rate_type\"\n    t.decimal \"interest_rate\", precision: 10, scale: 3\n    t.integer \"term_months\"\n    t.decimal \"initial_balance\", precision: 19, scale: 4\n    t.jsonb \"locked_attributes\", default: {}\n  end\n\n  create_table \"merchants\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"name\", null: false\n    t.string \"color\"\n    t.uuid \"family_id\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"logo_url\"\n    t.string \"website_url\"\n    t.string \"type\", null: false\n    t.string \"source\"\n    t.string \"provider_merchant_id\"\n    t.index [\"family_id\", \"name\"], name: \"index_merchants_on_family_id_and_name\", unique: true, where: \"((type)::text = 'FamilyMerchant'::text)\"\n    t.index [\"family_id\"], name: \"index_merchants_on_family_id\"\n    t.index [\"source\", \"name\"], name: \"index_merchants_on_source_and_name\", unique: true, where: \"((type)::text = 'ProviderMerchant'::text)\"\n    t.index [\"type\"], name: \"index_merchants_on_type\"\n  end\n\n  create_table \"messages\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"chat_id\", null: false\n    t.string \"type\", null: false\n    t.string \"status\", default: \"complete\", null: false\n    t.text \"content\"\n    t.string \"ai_model\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.boolean \"debug\", default: false\n    t.string \"provider_id\"\n    t.boolean \"reasoning\", default: false\n    t.index [\"chat_id\"], name: \"index_messages_on_chat_id\"\n  end\n\n  create_table \"mobile_devices\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"user_id\", null: false\n    t.string \"device_id\"\n    t.string \"device_name\"\n    t.string \"device_type\"\n    t.string \"os_version\"\n    t.string \"app_version\"\n    t.datetime \"last_seen_at\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.integer \"oauth_application_id\"\n    t.index [\"oauth_application_id\"], name: \"index_mobile_devices_on_oauth_application_id\"\n    t.index [\"user_id\", \"device_id\"], name: \"index_mobile_devices_on_user_id_and_device_id\", unique: true\n    t.index [\"user_id\"], name: \"index_mobile_devices_on_user_id\"\n  end\n\n  create_table \"oauth_access_grants\", force: :cascade do |t|\n    t.string \"resource_owner_id\", null: false\n    t.bigint \"application_id\", null: false\n    t.string \"token\", null: false\n    t.integer \"expires_in\", null: false\n    t.text \"redirect_uri\", null: false\n    t.string \"scopes\", default: \"\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"revoked_at\"\n    t.index [\"application_id\"], name: \"index_oauth_access_grants_on_application_id\"\n    t.index [\"resource_owner_id\"], name: \"index_oauth_access_grants_on_resource_owner_id\"\n    t.index [\"token\"], name: \"index_oauth_access_grants_on_token\", unique: true\n  end\n\n  create_table \"oauth_access_tokens\", force: :cascade do |t|\n    t.string \"resource_owner_id\"\n    t.bigint \"application_id\", null: false\n    t.string \"token\", null: false\n    t.string \"refresh_token\"\n    t.integer \"expires_in\"\n    t.string \"scopes\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"revoked_at\"\n    t.string \"previous_refresh_token\", default: \"\", null: false\n    t.index [\"application_id\"], name: \"index_oauth_access_tokens_on_application_id\"\n    t.index [\"refresh_token\"], name: \"index_oauth_access_tokens_on_refresh_token\", unique: true\n    t.index [\"resource_owner_id\"], name: \"index_oauth_access_tokens_on_resource_owner_id\"\n    t.index [\"token\"], name: \"index_oauth_access_tokens_on_token\", unique: true\n  end\n\n  create_table \"oauth_applications\", force: :cascade do |t|\n    t.string \"name\", null: false\n    t.string \"uid\", null: false\n    t.string \"secret\", null: false\n    t.text \"redirect_uri\", null: false\n    t.string \"scopes\", default: \"\", null: false\n    t.boolean \"confidential\", default: true, null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"owner_id\"\n    t.string \"owner_type\"\n    t.index [\"owner_id\", \"owner_type\"], name: \"index_oauth_applications_on_owner_id_and_owner_type\"\n    t.index [\"uid\"], name: \"index_oauth_applications_on_uid\", unique: true\n  end\n\n  create_table \"other_assets\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.jsonb \"locked_attributes\", default: {}\n  end\n\n  create_table \"other_liabilities\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.jsonb \"locked_attributes\", default: {}\n  end\n\n  create_table \"plaid_accounts\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"plaid_item_id\", null: false\n    t.string \"plaid_id\", null: false\n    t.string \"plaid_type\", null: false\n    t.string \"plaid_subtype\"\n    t.decimal \"current_balance\", precision: 19, scale: 4\n    t.decimal \"available_balance\", precision: 19, scale: 4\n    t.string \"currency\", null: false\n    t.string \"name\", null: false\n    t.string \"mask\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.jsonb \"raw_payload\", default: {}\n    t.jsonb \"raw_transactions_payload\", default: {}\n    t.jsonb \"raw_investments_payload\", default: {}\n    t.jsonb \"raw_liabilities_payload\", default: {}\n    t.index [\"plaid_id\"], name: \"index_plaid_accounts_on_plaid_id\", unique: true\n    t.index [\"plaid_item_id\"], name: \"index_plaid_accounts_on_plaid_item_id\"\n  end\n\n  create_table \"plaid_items\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"family_id\", null: false\n    t.string \"access_token\"\n    t.string \"plaid_id\", null: false\n    t.string \"name\"\n    t.string \"next_cursor\"\n    t.boolean \"scheduled_for_deletion\", default: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"available_products\", default: [], array: true\n    t.string \"billed_products\", default: [], array: true\n    t.string \"plaid_region\", default: \"us\", null: false\n    t.string \"institution_url\"\n    t.string \"institution_id\"\n    t.string \"institution_color\"\n    t.string \"status\", default: \"good\", null: false\n    t.jsonb \"raw_payload\", default: {}\n    t.jsonb \"raw_institution_payload\", default: {}\n    t.index [\"family_id\"], name: \"index_plaid_items_on_family_id\"\n    t.index [\"plaid_id\"], name: \"index_plaid_items_on_plaid_id\", unique: true\n  end\n\n  create_table \"properties\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.integer \"year_built\"\n    t.integer \"area_value\"\n    t.string \"area_unit\"\n    t.jsonb \"locked_attributes\", default: {}\n  end\n\n  create_table \"rejected_transfers\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"inflow_transaction_id\", null: false\n    t.uuid \"outflow_transaction_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"inflow_transaction_id\", \"outflow_transaction_id\"], name: \"idx_on_inflow_transaction_id_outflow_transaction_id_412f8e7e26\", unique: true\n    t.index [\"inflow_transaction_id\"], name: \"index_rejected_transfers_on_inflow_transaction_id\"\n    t.index [\"outflow_transaction_id\"], name: \"index_rejected_transfers_on_outflow_transaction_id\"\n  end\n\n  create_table \"rule_actions\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"rule_id\", null: false\n    t.string \"action_type\", null: false\n    t.string \"value\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"rule_id\"], name: \"index_rule_actions_on_rule_id\"\n  end\n\n  create_table \"rule_conditions\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"rule_id\"\n    t.uuid \"parent_id\"\n    t.string \"condition_type\", null: false\n    t.string \"operator\", null: false\n    t.string \"value\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"parent_id\"], name: \"index_rule_conditions_on_parent_id\"\n    t.index [\"rule_id\"], name: \"index_rule_conditions_on_rule_id\"\n  end\n\n  create_table \"rules\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"family_id\", null: false\n    t.string \"resource_type\", null: false\n    t.date \"effective_date\"\n    t.boolean \"active\", default: false, null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"name\"\n    t.index [\"family_id\"], name: \"index_rules_on_family_id\"\n  end\n\n  create_table \"securities\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"ticker\", null: false\n    t.string \"name\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"country_code\"\n    t.string \"exchange_mic\"\n    t.string \"exchange_acronym\"\n    t.string \"logo_url\"\n    t.string \"exchange_operating_mic\"\n    t.boolean \"offline\", default: false, null: false\n    t.datetime \"failed_fetch_at\"\n    t.integer \"failed_fetch_count\", default: 0, null: false\n    t.datetime \"last_health_check_at\"\n    t.index \"upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)\", name: \"index_securities_on_ticker_and_exchange_operating_mic_unique\", unique: true\n    t.index [\"country_code\"], name: \"index_securities_on_country_code\"\n    t.index [\"exchange_operating_mic\"], name: \"index_securities_on_exchange_operating_mic\"\n  end\n\n  create_table \"security_prices\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.date \"date\", null: false\n    t.decimal \"price\", precision: 19, scale: 4, null: false\n    t.string \"currency\", default: \"USD\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"security_id\"\n    t.index [\"security_id\", \"date\", \"currency\"], name: \"index_security_prices_on_security_id_and_date_and_currency\", unique: true\n    t.index [\"security_id\"], name: \"index_security_prices_on_security_id\"\n  end\n\n  create_table \"sessions\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"user_id\", null: false\n    t.string \"user_agent\"\n    t.string \"ip_address\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"active_impersonator_session_id\"\n    t.datetime \"subscribed_at\"\n    t.jsonb \"prev_transaction_page_params\", default: {}\n    t.jsonb \"data\", default: {}\n    t.index [\"active_impersonator_session_id\"], name: \"index_sessions_on_active_impersonator_session_id\"\n    t.index [\"user_id\"], name: \"index_sessions_on_user_id\"\n  end\n\n  create_table \"settings\", force: :cascade do |t|\n    t.string \"var\", null: false\n    t.text \"value\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"var\"], name: \"index_settings_on_var\", unique: true\n  end\n\n  create_table \"subscriptions\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"family_id\", null: false\n    t.string \"status\", null: false\n    t.string \"stripe_id\"\n    t.decimal \"amount\", precision: 19, scale: 4\n    t.string \"currency\"\n    t.string \"interval\"\n    t.datetime \"current_period_ends_at\"\n    t.datetime \"trial_ends_at\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"family_id\"], name: \"index_subscriptions_on_family_id\", unique: true\n  end\n\n  create_table \"syncs\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"syncable_type\", null: false\n    t.uuid \"syncable_id\", null: false\n    t.string \"status\", default: \"pending\"\n    t.string \"error\"\n    t.jsonb \"data\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"parent_id\"\n    t.datetime \"pending_at\"\n    t.datetime \"syncing_at\"\n    t.datetime \"completed_at\"\n    t.datetime \"failed_at\"\n    t.date \"window_start_date\"\n    t.date \"window_end_date\"\n    t.index [\"parent_id\"], name: \"index_syncs_on_parent_id\"\n    t.index [\"status\"], name: \"index_syncs_on_status\"\n    t.index [\"syncable_type\", \"syncable_id\"], name: \"index_syncs_on_syncable\"\n  end\n\n  create_table \"taggings\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"tag_id\", null: false\n    t.string \"taggable_type\"\n    t.uuid \"taggable_id\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"tag_id\"], name: \"index_taggings_on_tag_id\"\n    t.index [\"taggable_type\", \"taggable_id\"], name: \"index_taggings_on_taggable\"\n  end\n\n  create_table \"tags\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.string \"name\"\n    t.string \"color\", default: \"#e99537\", null: false\n    t.uuid \"family_id\", null: false\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"family_id\"], name: \"index_tags_on_family_id\"\n  end\n\n  create_table \"tool_calls\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"message_id\", null: false\n    t.string \"provider_id\", null: false\n    t.string \"provider_call_id\"\n    t.string \"type\", null: false\n    t.string \"function_name\"\n    t.jsonb \"function_arguments\"\n    t.jsonb \"function_result\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"message_id\"], name: \"index_tool_calls_on_message_id\"\n  end\n\n  create_table \"trades\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"security_id\", null: false\n    t.decimal \"qty\", precision: 19, scale: 4\n    t.decimal \"price\", precision: 19, scale: 4\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"currency\"\n    t.jsonb \"locked_attributes\", default: {}\n    t.index [\"security_id\"], name: \"index_trades_on_security_id\"\n  end\n\n  create_table \"transactions\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.uuid \"category_id\"\n    t.uuid \"merchant_id\"\n    t.jsonb \"locked_attributes\", default: {}\n    t.string \"kind\", default: \"standard\", null: false\n    t.index [\"category_id\"], name: \"index_transactions_on_category_id\"\n    t.index [\"kind\"], name: \"index_transactions_on_kind\"\n    t.index [\"merchant_id\"], name: \"index_transactions_on_merchant_id\"\n  end\n\n  create_table \"transfers\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"inflow_transaction_id\", null: false\n    t.uuid \"outflow_transaction_id\", null: false\n    t.string \"status\", default: \"pending\", null: false\n    t.text \"notes\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.index [\"inflow_transaction_id\", \"outflow_transaction_id\"], name: \"idx_on_inflow_transaction_id_outflow_transaction_id_8cd07a28bd\", unique: true\n    t.index [\"inflow_transaction_id\"], name: \"index_transfers_on_inflow_transaction_id\"\n    t.index [\"outflow_transaction_id\"], name: \"index_transfers_on_outflow_transaction_id\"\n    t.index [\"status\"], name: \"index_transfers_on_status\"\n  end\n\n  create_table \"users\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.uuid \"family_id\", null: false\n    t.string \"first_name\"\n    t.string \"last_name\"\n    t.string \"email\"\n    t.string \"password_digest\"\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.string \"role\", default: \"member\", null: false\n    t.boolean \"active\", default: true, null: false\n    t.datetime \"onboarded_at\"\n    t.string \"unconfirmed_email\"\n    t.string \"otp_secret\"\n    t.boolean \"otp_required\", default: false, null: false\n    t.string \"otp_backup_codes\", default: [], array: true\n    t.boolean \"show_sidebar\", default: true\n    t.string \"default_period\", default: \"last_30_days\", null: false\n    t.uuid \"last_viewed_chat_id\"\n    t.boolean \"show_ai_sidebar\", default: true\n    t.boolean \"ai_enabled\", default: false, null: false\n    t.string \"theme\", default: \"system\"\n    t.boolean \"rule_prompts_disabled\", default: false\n    t.datetime \"rule_prompt_dismissed_at\"\n    t.text \"goals\", default: [], array: true\n    t.datetime \"set_onboarding_preferences_at\"\n    t.datetime \"set_onboarding_goals_at\"\n    t.index [\"email\"], name: \"index_users_on_email\", unique: true\n    t.index [\"family_id\"], name: \"index_users_on_family_id\"\n    t.index [\"last_viewed_chat_id\"], name: \"index_users_on_last_viewed_chat_id\"\n    t.index [\"otp_secret\"], name: \"index_users_on_otp_secret\", unique: true, where: \"(otp_secret IS NOT NULL)\"\n  end\n\n  create_table \"valuations\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.jsonb \"locked_attributes\", default: {}\n    t.string \"kind\", default: \"reconciliation\", null: false\n  end\n\n  create_table \"vehicles\", id: :uuid, default: -> { \"gen_random_uuid()\" }, force: :cascade do |t|\n    t.datetime \"created_at\", null: false\n    t.datetime \"updated_at\", null: false\n    t.integer \"year\"\n    t.integer \"mileage_value\"\n    t.string \"mileage_unit\"\n    t.string \"make\"\n    t.string \"model\"\n    t.jsonb \"locked_attributes\", default: {}\n  end\n\n  add_foreign_key \"accounts\", \"families\"\n  add_foreign_key \"accounts\", \"imports\"\n  add_foreign_key \"accounts\", \"plaid_accounts\"\n  add_foreign_key \"active_storage_attachments\", \"active_storage_blobs\", column: \"blob_id\"\n  add_foreign_key \"active_storage_variant_records\", \"active_storage_blobs\", column: \"blob_id\"\n  add_foreign_key \"api_keys\", \"users\"\n  add_foreign_key \"balances\", \"accounts\", on_delete: :cascade\n  add_foreign_key \"budget_categories\", \"budgets\"\n  add_foreign_key \"budget_categories\", \"categories\"\n  add_foreign_key \"budgets\", \"families\"\n  add_foreign_key \"categories\", \"families\"\n  add_foreign_key \"chats\", \"users\"\n  add_foreign_key \"entries\", \"accounts\"\n  add_foreign_key \"entries\", \"imports\"\n  add_foreign_key \"family_exports\", \"families\"\n  add_foreign_key \"holdings\", \"accounts\"\n  add_foreign_key \"holdings\", \"securities\"\n  add_foreign_key \"impersonation_session_logs\", \"impersonation_sessions\"\n  add_foreign_key \"impersonation_sessions\", \"users\", column: \"impersonated_id\"\n  add_foreign_key \"impersonation_sessions\", \"users\", column: \"impersonator_id\"\n  add_foreign_key \"import_rows\", \"imports\"\n  add_foreign_key \"imports\", \"families\"\n  add_foreign_key \"invitations\", \"families\"\n  add_foreign_key \"invitations\", \"users\", column: \"inviter_id\"\n  add_foreign_key \"merchants\", \"families\"\n  add_foreign_key \"messages\", \"chats\"\n  add_foreign_key \"mobile_devices\", \"users\"\n  add_foreign_key \"oauth_access_grants\", \"oauth_applications\", column: \"application_id\"\n  add_foreign_key \"oauth_access_tokens\", \"oauth_applications\", column: \"application_id\"\n  add_foreign_key \"plaid_accounts\", \"plaid_items\"\n  add_foreign_key \"plaid_items\", \"families\"\n  add_foreign_key \"rejected_transfers\", \"transactions\", column: \"inflow_transaction_id\"\n  add_foreign_key \"rejected_transfers\", \"transactions\", column: \"outflow_transaction_id\"\n  add_foreign_key \"rule_actions\", \"rules\"\n  add_foreign_key \"rule_conditions\", \"rule_conditions\", column: \"parent_id\"\n  add_foreign_key \"rule_conditions\", \"rules\"\n  add_foreign_key \"rules\", \"families\"\n  add_foreign_key \"security_prices\", \"securities\"\n  add_foreign_key \"sessions\", \"impersonation_sessions\", column: \"active_impersonator_session_id\"\n  add_foreign_key \"sessions\", \"users\"\n  add_foreign_key \"subscriptions\", \"families\"\n  add_foreign_key \"syncs\", \"syncs\", column: \"parent_id\"\n  add_foreign_key \"taggings\", \"tags\"\n  add_foreign_key \"tags\", \"families\"\n  add_foreign_key \"tool_calls\", \"messages\"\n  add_foreign_key \"trades\", \"securities\"\n  add_foreign_key \"transactions\", \"categories\", on_delete: :nullify\n  add_foreign_key \"transactions\", \"merchants\"\n  add_foreign_key \"transfers\", \"transactions\", column: \"inflow_transaction_id\", on_delete: :cascade\n  add_foreign_key \"transfers\", \"transactions\", column: \"outflow_transaction_id\", on_delete: :cascade\n  add_foreign_key \"users\", \"chats\", column: \"last_viewed_chat_id\"\n  add_foreign_key \"users\", \"families\"\nend\n"
  },
  {
    "path": "db/seeds/.keep",
    "content": ""
  },
  {
    "path": "db/seeds/oauth_applications.rb",
    "content": "# Create OAuth applications for Maybe's first-party apps\n# These are the only OAuth apps that will exist - external developers use API keys\n\n# Maybe iOS App\nios_app = Doorkeeper::Application.find_or_create_by(name: \"Maybe iOS\") do |app|\n  app.redirect_uri = \"maybe://oauth/callback\"\n  app.scopes = \"read_accounts read_transactions read_balances\"\n  app.confidential = false # Public client (mobile app)\nend\n\nputs \"Created OAuth applications:\"\nputs \"iOS App - Client ID: #{ios_app.uid}\"\nputs \"\"\nputs \"External developers should use API keys instead of OAuth.\"\n"
  },
  {
    "path": "db/seeds.rb",
    "content": "# This file should ensure the existence of records required to run the application in every environment (production,\n# development, test). The code here should be idempotent so that it can be executed at any point in every environment.\n# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).\n\nputs 'Run the following command to create demo data: `rake demo_data:default`' if Rails.env.development?\n\nDir[Rails.root.join('db', 'seeds', '*.rb')].sort.each do |file|\n  puts \"Loading seed file: #{File.basename(file)}\"\n  require file\nend\n"
  },
  {
    "path": "docs/api/chats.md",
    "content": "# Chat API Documentation\n\nThe Chat API allows external applications to interact with Maybe's AI chat functionality.\n\n## Authentication\n\nAll chat endpoints require authentication via OAuth2 or API keys. The chat endpoints also require the user to have AI features enabled (`ai_enabled: true`).\n\n## Endpoints\n\n### List Chats\n```\nGET /api/v1/chats\n```\n\n**Required Scope:** `read`\n\n**Response:**\n```json\n{\n  \"chats\": [\n    {\n      \"id\": \"uuid\",\n      \"title\": \"Chat title\",\n      \"last_message_at\": \"2024-01-01T00:00:00Z\",\n      \"message_count\": 5,\n      \"error\": null,\n      \"created_at\": \"2024-01-01T00:00:00Z\",\n      \"updated_at\": \"2024-01-01T00:00:00Z\"\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"per_page\": 20,\n    \"total_count\": 50,\n    \"total_pages\": 3\n  }\n}\n```\n\n### Get Chat\n```\nGET /api/v1/chats/:id\n```\n\n**Required Scope:** `read`\n\n**Response:**\n```json\n{\n  \"id\": \"uuid\",\n  \"title\": \"Chat title\",\n  \"error\": null,\n  \"created_at\": \"2024-01-01T00:00:00Z\",\n  \"updated_at\": \"2024-01-01T00:00:00Z\",\n  \"messages\": [\n    {\n      \"id\": \"uuid\",\n      \"type\": \"user_message\",\n      \"role\": \"user\",\n      \"content\": \"Hello AI\",\n      \"created_at\": \"2024-01-01T00:00:00Z\",\n      \"updated_at\": \"2024-01-01T00:00:00Z\"\n    },\n    {\n      \"id\": \"uuid\",\n      \"type\": \"assistant_message\",\n      \"role\": \"assistant\",\n      \"content\": \"Hello! How can I help you?\",\n      \"model\": \"gpt-4\",\n      \"created_at\": \"2024-01-01T00:00:00Z\",\n      \"updated_at\": \"2024-01-01T00:00:00Z\",\n      \"tool_calls\": []\n    }\n  ],\n  \"pagination\": {\n    \"page\": 1,\n    \"per_page\": 50,\n    \"total_count\": 2,\n    \"total_pages\": 1\n  }\n}\n```\n\n### Create Chat\n```\nPOST /api/v1/chats\n```\n\n**Required Scope:** `write`\n\n**Request Body:**\n```json\n{\n  \"title\": \"Optional chat title\",\n  \"message\": \"Initial message to AI\",\n  \"model\": \"gpt-4\" // optional, defaults to gpt-4\n}\n```\n\n**Response:** Same as Get Chat endpoint\n\n### Update Chat\n```\nPATCH /api/v1/chats/:id\n```\n\n**Required Scope:** `write`\n\n**Request Body:**\n```json\n{\n  \"title\": \"New chat title\"\n}\n```\n\n**Response:** Same as Get Chat endpoint\n\n### Delete Chat\n```\nDELETE /api/v1/chats/:id\n```\n\n**Required Scope:** `write`\n\n**Response:** 204 No Content\n\n### Create Message\n```\nPOST /api/v1/chats/:chat_id/messages\n```\n\n**Required Scope:** `write`\n\n**Request Body:**\n```json\n{\n  \"content\": \"User message\",\n  \"model\": \"gpt-4\" // optional, defaults to gpt-4\n}\n```\n\n**Response:**\n```json\n{\n  \"id\": \"uuid\",\n  \"chat_id\": \"uuid\",\n  \"type\": \"user_message\",\n  \"role\": \"user\",\n  \"content\": \"User message\",\n  \"created_at\": \"2024-01-01T00:00:00Z\",\n  \"updated_at\": \"2024-01-01T00:00:00Z\",\n  \"ai_response_status\": \"pending\",\n  \"ai_response_message\": \"AI response is being generated\"\n}\n```\n\n### Retry Last Message\n```\nPOST /api/v1/chats/:chat_id/messages/retry\n```\n\n**Required Scope:** `write`\n\nRetries the last assistant message in the chat.\n\n**Response:**\n```json\n{\n  \"message\": \"Retry initiated\",\n  \"message_id\": \"uuid\"\n}\n```\n\n## AI Response Handling\n\nAI responses are processed asynchronously. When you create a message or chat with an initial message, the API returns immediately with the user message. The AI response is generated in the background.\n\n### Checking for AI Responses\n\nCurrently, you need to poll the chat endpoint to check for new AI responses. Look for new messages with `type: \"assistant_message\"`.\n\n### Available AI Models\n\n- `gpt-4` (default)\n- `gpt-4-turbo`\n- `gpt-3.5-turbo`\n\n### Tool Calls\n\nThe AI assistant can make tool calls to access user financial data. These appear in the `tool_calls` array of assistant messages:\n\n```json\n{\n  \"tool_calls\": [\n    {\n      \"id\": \"uuid\",\n      \"function_name\": \"get_accounts\",\n      \"function_arguments\": {},\n      \"function_result\": { ... },\n      \"created_at\": \"2024-01-01T00:00:00Z\"\n    }\n  ]\n}\n```\n\n## Error Handling\n\nAll endpoints return standard error responses:\n\n```json\n{\n  \"error\": \"error_code\",\n  \"message\": \"Human readable error message\",\n  \"details\": [\"Additional error details\"] // optional\n}\n```\n\nCommon error codes:\n- `unauthorized` - Invalid or missing authentication\n- `forbidden` - Insufficient permissions or AI not enabled\n- `not_found` - Resource not found\n- `unprocessable_entity` - Invalid request data\n- `rate_limit_exceeded` - Too many requests\n\n## Rate Limits\n\nChat API endpoints are subject to the standard API rate limits based on your API key tier."
  },
  {
    "path": "docs/hosting/docker.md",
    "content": "# Self Hosting Maybe with Docker\n\nThis guide will help you setup, update, and maintain your self-hosted Maybe application with Docker Compose. Docker Compose is the most popular and recommended way to self-host the Maybe app.\n\n## Setup Guide\n\nFollow the guide below to get your app running.\n\n### Step 1: Install Docker\n\nComplete the following steps:\n\n1. Install Docker Engine by following [the official guide](https://docs.docker.com/engine/install/)\n2. Start the Docker service on your machine\n3. Verify that Docker is installed correctly and is running by opening up a terminal and running the following command:\n\n```bash\n# If Docker is setup correctly, this command will succeed\ndocker run hello-world\n```\n\n### Step 2: Configure your Docker Compose file and environment\n\n#### Create a directory for your app to run\n\nOpen your terminal and create a directory where your app will run. Below is an example command with a recommended directory:\n\n```bash\n# Create a directory on your computer for Docker files (name whatever you'd like)\nmkdir -p ~/docker-apps/maybe\n\n# Once created, navigate your current working directory to the new folder\ncd ~/docker-apps/maybe\n```\n\n#### Copy our sample Docker Compose file\n\nMake sure you are in the directory you just created and run the following command:\n\n```bash\n# Download the sample compose.yml file from the Maybe Github repository\ncurl -o compose.yml https://raw.githubusercontent.com/maybe-finance/maybe/main/compose.example.yml\n```\n\nThis command will do the following:\n\n1. Fetch the sample docker compose file from our public Github repository\n2. Creates a file in your current directory called `compose.yml` with the contents of the example file\n\nAt this point, the only file in your current working directory should be `compose.yml`.\n\n### Step 3 (optional): Configure your environment\n\nBy default, our `compose.example.yml` file runs without any configuration.  That said, if you would like extra security (important if you're running outside of a local network), you can follow the steps below to set things up.\n\nIf you're running the app locally and don't care much about security, you can skip this step.\n\n#### Create your environment file\n\nIn order to configure the app, you will need to create a file called `.env`, which is where Docker will read environment variables from.\n\nTo do this, run the following command:\n\n```bash\ntouch .env\n```\n\n#### Generate the app secret key\n\nThe app requires an environment variable called `SECRET_KEY_BASE` to run.\n\nWe will first need to generate this in the terminal. If you have `openssl` installed on your computer, you can generate it with the following command:\n\n```bash\nopenssl rand -hex 64\n```\n\n_Alternatively_, you can generate a key without openssl or any external dependencies by pasting the following bash command in your terminal and running it:\n\n```bash\nhead -c 64 /dev/urandom | od -An -tx1 | tr -d ' \\n' && echo\n```\n\nOnce you have generated a key, save it and move on to the next step.\n\n#### Fill in your environment file\n\nOpen the file named `.env` that we created in a prior step using your favorite text editor.\n\nFill in this file with the following variables:\n\n```txt\nSECRET_KEY_BASE=\"replacemewiththegeneratedstringfromthepriorstep\"\nPOSTGRES_PASSWORD=\"replacemewithyourdesireddatabasepassword\"\n```\n\n### Step 4: Run the app\n\nYou are now ready to run the app. Start with the following command to make sure everything is working:\n\n```bash\ndocker compose up\n```\n\nThis will pull our official Docker image and start the app. You will see logs in your terminal.\n\nOpen your browser, and navigate to `http://localhost:3000`.\n\nIf everything is working, you will see the Maybe login screen.\n\n### Step 5: Create your account\n\nThe first time you run the app, you will need to register a new account by hitting \"create your account\" on the login page.\n\n1. Enter your email\n2. Enter a password\n\n### Step 6: Run the app in the background\n\nMost self-hosting users will want the Maybe app to run in the background on their computer so they can access it at all times. To do this, hit `Ctrl+C` to stop the running process, and then run the following command:\n\n```bash\ndocker compose up -d\n```\n\nThe `-d` flag will run Docker Compose in \"detached\" mode. To verify it is running, you can run the following command:\n\n```\ndocker compose ls\n```\n\n### Step 7: Enjoy!\n\nYour app is now set up. You can visit it at `http://localhost:3000` in your browser.\n\nIf you find bugs or have a feature request, be sure to read through our [contributing guide here](https://github.com/maybe-finance/maybe/wiki/How-to-Contribute-Effectively-to-this-Project).\n\n## How to update your app\n\nThe mechanism that updates your self-hosted Maybe app is the GHCR (Github Container Registry) Docker image that you see in the `compose.yml` file:\n\n```yml\nimage: ghcr.io/maybe-finance/maybe:latest\n```\n\nWe recommend using one of the following images, but you can pin your app to whatever version you'd like (see [packages](https://github.com/maybe-finance/maybe/pkgs/container/maybe)):\n\n- `ghcr.io/maybe-finance/maybe:latest` (latest commit)\n- `ghcr.io/maybe-finance/maybe:stable` (latest release)\n\nBy default, your app _will\nNOT_ automatically update. To update your self-hosted app, run the following commands in your terminal:\n\n```bash\ncd ~/docker-apps/maybe # Navigate to whatever directory you configured the app in\ndocker compose pull # This pulls the \"latest\" published image from GHCR\ndocker compose build # This rebuilds the app with updates\ndocker compose up --no-deps -d web worker # This restarts the app using the newest version\n```\n\n## How to change which updates your app receives\n\nIf you'd like to pin the app to a specific version or tag, all you need to do is edit the `compose.yml` file:\n\n```yml\nimage: ghcr.io/maybe-finance/maybe:stable\n```\n\nAfter doing this, make sure and restart the app:\n\n```bash\ndocker compose pull # This pulls the \"latest\" published image from GHCR\ndocker compose build # This rebuilds the app with updates\ndocker compose up --no-deps -d app # This restarts the app using the newest version\n```\n\n## Troubleshooting\n\n### ActiveRecord::DatabaseConnectionError\n\nIf you are trying to get Maybe started for the **first time** and run into database connection issues, it is likely because Docker has already initialized the Postgres database with a _different_ default role (usually from a previous attempt to start the app).\n\nIf you run into this issue, you can optionally **reset the database**.\n\n**PLEASE NOTE: this will delete any existing data that you have in your Maybe database, so proceed with caution.**  For first-time users of the app just trying to get started, you're generally safe to run the commands below.\n\nBy running the commands below, you will delete your existing Maybe database and \"reset\" it.\n\n```\ndocker compose down\ndocker volume rm maybe_postgres-data # this is the name of the volume the DB is mounted to\ndocker compose up\ndocker exec -it maybe-postgres-1 psql -U maybe -d maybe_production -c \"SELECT 1;\" # This will verify that the issue is fixed\n```\n"
  },
  {
    "path": "lib/assets/.keep",
    "content": ""
  },
  {
    "path": "lib/money/arithmetic.rb",
    "content": "module Money::Arithmetic\n  CoercedNumeric = Struct.new(:value)\n\n  def +(other)\n    if other.is_a?(Money)\n      self.class.new(amount + other.amount, currency)\n    else\n      value = other.is_a?(CoercedNumeric) ? other.value : other\n      self.class.new(amount + value, currency)\n    end\n  end\n\n  def -(other)\n    if other.is_a?(Money)\n      self.class.new(amount - other.amount, currency)\n    else\n      value = other.is_a?(CoercedNumeric) ? other.value : other\n      self.class.new(amount - value, currency)\n    end\n  end\n\n  def -@\n    self.class.new(-amount, currency)\n  end\n\n  def *(other)\n    raise TypeError, \"Can't multiply Money by Money, use Numeric instead\" if other.is_a?(self.class)\n    value = other.is_a?(CoercedNumeric) ? other.value : other\n    self.class.new(amount * value, currency)\n  end\n\n  def /(other)\n    if other.is_a?(self.class)\n      amount / other.amount\n    else\n      raise TypeError, \"can't divide Numeric by Money\" if other.is_a?(CoercedNumeric)\n      self.class.new(amount / other, currency)\n    end\n  end\n\n  def abs\n    self.class.new(amount.abs, currency)\n  end\n\n  def zero?\n    amount.zero?\n  end\n\n  def negative?\n    amount.negative?\n  end\n\n  def positive?\n    amount.positive?\n  end\n\n  def to_f\n    amount.to_f\n  end\n\n  # Override Ruby's coerce method so the order of operands doesn't matter\n  # Wrap in Coerced so we can distinguish between Money and other types\n  def coerce(other)\n    [ self, CoercedNumeric.new(other) ]\n  end\nend\n"
  },
  {
    "path": "lib/money/currency.rb",
    "content": "class Money::Currency\n  include Comparable\n\n  class UnknownCurrencyError < ArgumentError; end\n\n  CURRENCIES_FILE_PATH = Rails.root.join(\"config\", \"currencies.yml\")\n\n  # Cached instances by iso code\n  @@instances = {}\n\n  class << self\n    def new(object)\n      iso_code = case object\n      when String, Symbol\n        object.to_s.downcase\n      when Money::Currency\n        object.iso_code.downcase\n      else\n        raise ArgumentError, \"Invalid argument type\"\n      end\n\n      @@instances[iso_code] ||= super(iso_code)\n    end\n\n    def all\n      @all ||= YAML.safe_load(\n        File.read(CURRENCIES_FILE_PATH),\n        permitted_classes: [],\n        permitted_symbols: [],\n        aliases: true\n      )\n    end\n\n    def all_instances\n      all.values.map { |currency_data| new(currency_data[\"iso_code\"]) }\n    end\n\n    def as_options\n      all_instances.sort_by do |currency|\n        [ currency.priority, currency.name ]\n      end\n    end\n\n    def popular\n      all.values.sort_by { |currency| currency[\"priority\"] }.first(12).map { |currency_data| new(currency_data[\"iso_code\"]) }\n    end\n  end\n\n  attr_reader :name, :priority, :iso_code, :iso_numeric, :html_code,\n              :symbol, :minor_unit, :minor_unit_conversion, :smallest_denomination,\n              :separator, :delimiter, :default_format, :default_precision\n\n  def initialize(iso_code)\n    currency_data = self.class.all[iso_code]\n    raise UnknownCurrencyError if currency_data.nil?\n\n    @name = currency_data[\"name\"]\n    @priority = currency_data[\"priority\"]\n    @iso_code = currency_data[\"iso_code\"]\n    @iso_numeric = currency_data[\"iso_numeric\"]\n    @html_code = currency_data[\"html_code\"]\n    @symbol = currency_data[\"symbol\"]\n    @minor_unit = currency_data[\"minor_unit\"]\n    @minor_unit_conversion = currency_data[\"minor_unit_conversion\"]\n    @smallest_denomination = currency_data[\"smallest_denomination\"]\n    @separator = currency_data[\"separator\"]\n    @delimiter = currency_data[\"delimiter\"]\n    @default_format = currency_data[\"default_format\"]\n    @default_precision = currency_data[\"default_precision\"]\n  end\n\n  def step\n    (1.0/10**default_precision)\n  end\n\n  def <=>(other)\n    return nil unless other.is_a?(Money::Currency)\n    @iso_code <=> other.iso_code\n  end\nend\n"
  },
  {
    "path": "lib/money/formatting.rb",
    "content": "module Money::Formatting\n  include ActiveSupport::NumberHelper\n\n  def format(options = {})\n    locale = options[:locale] || I18n.locale\n    default_opts = format_options(locale)\n\n    number_to_currency(amount, default_opts.merge(options))\n  end\n  alias_method :to_s, :format\n\n  def format_options(locale = nil)\n    local_option_overrides = locale_options(locale)\n\n    {\n      unit: get_symbol,\n      precision: currency.default_precision,\n      delimiter: currency.delimiter,\n      separator: currency.separator,\n      format: currency.default_format\n    }.merge(local_option_overrides)\n  end\n\n  private\n    def get_symbol\n      if currency.symbol == \"$\" && currency.iso_code != \"USD\"\n        [ currency.iso_code.first(2), currency.symbol ].join\n      else\n        currency.symbol\n      end\n    end\n\n    def locale_options(locale)\n      case [ currency.iso_code, locale.to_sym ]\n      when [ \"EUR\", :nl ], [ \"EUR\", :pt ]\n        { delimiter: \".\", separator: \",\", format: \"%u %n\" }\n      when [ \"EUR\", :en ], [ \"EUR\", :en_IE ]\n        { delimiter: \",\", separator: \".\" }\n      else\n        {}\n      end\n    end\nend\n"
  },
  {
    "path": "lib/money.rb",
    "content": "class Money\n  include Comparable, Arithmetic, Formatting\n  include ActiveModel::Validations\n\n  class ConversionError < StandardError\n    attr_reader :from_currency, :to_currency, :date\n\n    def initialize(from_currency:, to_currency:, date:)\n      @from_currency = from_currency\n      @to_currency = to_currency\n      @date = date\n\n      error_message = message || \"Couldn't find exchange rate from #{from_currency} to #{to_currency} on #{date}\"\n\n      super(error_message)\n    end\n  end\n\n  attr_reader :amount, :currency, :store\n\n  validate :source_must_be_of_known_type\n\n  class << self\n    def default_currency\n      @default ||= Money::Currency.new(:usd)\n    end\n\n    def default_currency=(object)\n      @default = Money::Currency.new(object)\n    end\n  end\n\n  def initialize(obj, currency = Money.default_currency, store: ExchangeRate)\n    @source = obj\n    @amount = obj.is_a?(Money) ? obj.amount : BigDecimal(obj.to_s)\n    @currency = obj.is_a?(Money) ? obj.currency : Money::Currency.new(currency)\n    @store = store\n\n    validate!\n  end\n\n  def exchange_to(other_currency, date: Date.current, fallback_rate: nil)\n    iso_code = currency.iso_code\n    other_iso_code = Money::Currency.new(other_currency).iso_code\n\n    if iso_code == other_iso_code\n      self\n    else\n      exchange_rate = store.find_or_fetch_rate(from: iso_code, to: other_iso_code, date: date)&.rate || fallback_rate\n\n      raise ConversionError.new(from_currency: iso_code, to_currency: other_iso_code, date: date) unless exchange_rate\n\n      Money.new(amount * exchange_rate, other_iso_code)\n    end\n  end\n\n  def as_json\n    { amount: amount, currency: currency.iso_code, formatted: format }.as_json\n  end\n\n  def <=>(other)\n    raise TypeError, \"Money can only be compared with other Money objects except for 0\" unless other.is_a?(Money) || other.eql?(0)\n\n    if other.is_a?(Numeric)\n      amount <=> other\n    else\n      amount_comparison = amount <=> other.amount\n\n      if amount_comparison == 0\n        currency <=> other.currency\n      else\n        amount_comparison\n      end\n    end\n  end\n\n  private\n    def source_must_be_of_known_type\n      unless @source.is_a?(Money) || @source.is_a?(Numeric) || @source.is_a?(BigDecimal)\n        errors.add :source, \"must be a Money, Numeric, or BigDecimal\"\n      end\n    end\nend\n"
  },
  {
    "path": "lib/semver.rb",
    "content": "# Light wrapper around Gem::Version to support tag parsing\nclass Semver\n  attr_reader :version\n\n  def initialize(version_string)\n    @version_string = version_string\n    @version = Gem::Version.new(version_string)\n  end\n\n  def > (other)\n    @version > other.version\n  end\n\n  def < (other)\n    @version < other.version\n  end\n\n  def == (other)\n    @version == other.version\n  end\n\n  def to_s\n    @version_string\n  end\n\n  def to_release_tag\n    \"v#{@version_string}\"\n  end\n\n  def self.from_release_tag(tag)\n    new(tag.sub(/^v/, \"\"))\n  end\nend\n"
  },
  {
    "path": "lib/tasks/benchmarking.rake",
    "content": "# Benchmarking requires a production-like data sample, so requires some up-front setup.\n#\n# 1. Load a scrubbed production-like slice of data into maybe_benchmarking DB locally\n# 2. Setup .env.production so that the Rails app can boot with RAILS_ENV=production and connect to local maybe_benchmarking DB\n# 3. Run `rake benchmark_dump:06_setup_bench_user`\n# 4. Run locally, find endpoint needed\n# 5. Run an endpoint, example: `ENDPOINT=/budgets/jun-2025/budget_categories/245637cb-129f-4612-b0a8-1de57559372b RAILS_ENV=production BENCHMARKING_ENABLED=true RAILS_LOG_LEVEL=debug rake benchmarking:ips`\nnamespace :benchmarking do\n  desc \"Benchmark specific code\"\n  task code: :environment do\n    Benchmark.ips do |x|\n      x.config(time: 30, warmup: 10)\n\n      family = User.find_by(email: \"user@maybe.local\").family\n      scope = family.transactions.active\n\n      # x.report(\"IncomeStatement::Totals\") do\n      #   IncomeStatement::Totals.new(family, transactions_scope: scope).call\n      # end\n\n      # x.report(\"IncomeStatement::CategoryStats\") do\n      #   IncomeStatement::CategoryStats.new(family).call\n      # end\n\n      # x.report(\"IncomeStatement::FamilyStats\") do\n      #   IncomeStatement::FamilyStats.new(family).call\n      # end\n\n      puts family.entries.count\n\n      x.report(\"Transaction::Totals\") do\n        search = Transaction::Search.new(family)\n        search.totals\n      end\n\n      x.compare!\n    end\n  end\n\n  desc \"Shorthand task for running warm/cold benchmark\"\n  task endpoint: :environment do\n    system(\n      \"RAILS_ENV=production BENCHMARKING_ENABLED=true ENDPOINT=#{ENV.fetch(\"ENDPOINT\", \"/\")} rake benchmarking:warm_cold_endpoint_ips\"\n    )\n  end\n\n  # When to use: Track overall endpoint speed improvements over time (recommended, most practical test)\n  desc \"Run cold & warm performance benchmarks and append to history\"\n  task warm_cold_endpoint_ips: :environment do\n    path = ENV.fetch(\"ENDPOINT\", \"/\")\n\n    # 🚫 Fail fast unless the benchmark is run in production mode\n    unless Rails.env.production?\n      raise \"benchmark:ips must be run with RAILS_ENV=production (current: #{Rails.env})\"\n    end\n\n    # ---------------------------------------------------------------------------\n    # Tunable parameters – override with environment variables if needed\n    # ---------------------------------------------------------------------------\n    cold_warmup     = Integer(ENV.fetch(\"COLD_WARMUP\", 0))  # seconds to warm up before *cold* timing (0 == true cold)\n    cold_iterations = Integer(ENV.fetch(\"COLD_ITERATIONS\", 1)) # requests to measure for the cold run\n\n    warm_warmup     = Integer(ENV.fetch(\"WARM_WARMUP\", 5))  # seconds benchmark-ips uses to stabilise JIT/caches\n    warm_time       = Integer(ENV.fetch(\"WARM_TIME\", 10))   # seconds benchmark-ips samples for warm statistics\n    # ---------------------------------------------------------------------------\n\n    setup_benchmark_env(path)\n    FileUtils.mkdir_p(\"tmp/benchmarks\")\n\n    timestamp  = Time.current.strftime(\"%Y-%m-%d %H:%M:%S\")\n    commit_sha = `git rev-parse --short HEAD 2>/dev/null`.strip rescue \"unknown\"\n    puts \"🕒 Starting benchmark run at #{timestamp} (#{commit_sha})\"\n\n    # 🚿  Flush application caches so the first request is a *true* cold hit\n    Rails.cache&.clear if defined?(Rails)\n\n    # ---------------------------\n    # 1️⃣  Cold measurement\n    # ---------------------------\n    puts \"❄️  Running cold benchmark for #{path} (#{cold_iterations} iteration)...\"\n    cold_cmd = \"IPS_WARMUP=#{cold_warmup} IPS_TIME=0 IPS_ITERATIONS=#{cold_iterations} \" \\\n               \"bundle exec derailed exec perf:ips\"\n    cold_output = `#{cold_cmd} 2>&1`\n\n    puts \"Cold output:\"\n    puts cold_output\n\n    cold_result = extract_clean_results(cold_output)\n\n    # ---------------------------\n    # 2️⃣  Warm measurement\n    # ---------------------------\n    puts \"🔥 Running warm benchmark for #{path} (#{warm_time}s sample)...\"\n    warm_cmd = \"IPS_WARMUP=#{warm_warmup} IPS_TIME=#{warm_time} \" \\\n               \"bundle exec derailed exec perf:ips\"\n    warm_output = `#{warm_cmd} 2>&1`\n\n    puts \"Warm output:\"\n    puts warm_output\n\n    warm_result = extract_clean_results(warm_output)\n\n    # ---------------------------------------------------------------------------\n    # Persist results\n    # ---------------------------------------------------------------------------\n    separator        = \"\\n\" + \"=\" * 70 + \"\\n\"\n    timestamp_header = \"#{separator}📊 BENCHMARK RUN - #{timestamp} (#{commit_sha})#{separator}\"\n\n    # Table header\n    table_header    = \"| Type | IPS | Deviation | Time/Iteration | Iterations | Total Time |\\n\"\n    table_separator = \"|------|-----|-----------|----------------|------------|------------|\\n\"\n\n    cold_row        = format_table_row(\"COLD\", cold_result)\n    warm_row        = format_table_row(\"WARM\", warm_result)\n\n    combined_result = table_header + table_separator + cold_row + warm_row + \"\\n\"\n\n    File.open(benchmark_file(path), \"a\") { |f| f.write(timestamp_header + combined_result) }\n\n    puts \"✅ Results saved to #{benchmark_file(path)}\"\n  end\n\n  private\n    def setup_benchmark_env(path)\n      ENV[\"USE_AUTH\"]      = \"true\"\n      ENV[\"USE_SERVER\"]    = \"puma\"\n      ENV[\"PATH_TO_HIT\"]   = path\n      ENV[\"HTTP_METHOD\"]   = \"GET\"\n      ENV[\"RAILS_LOG_LEVEL\"] ||= \"error\" # keep output clean\n    end\n\n    def benchmark_file(path)\n      filename = case path\n      when \"/\" then \"dashboard\"\n      else\n        path.gsub(\"/\", \"_\").gsub(/^_+/, \"\")\n      end\n      \"tmp/benchmarks/#{filename}.txt\"\n    end\n\n    def extract_clean_results(output)\n      lines = output.split(\"\\n\")\n\n      # Example benchmark-ips output line:\n      # \"         SomeLabel    14.416k (± 3.8%) i/s -     72.000k in   5.004618s\"\n      result_line = lines.find { |line| line.match(/\\d[\\d\\.kM]*\\s+\\(±\\s*[0-9\\.]+%\\)\\s+i\\/s/) }\n\n      if result_line\n        if (match = result_line.match(/(\\d[\\d\\.kM]*)\\s+\\(±\\s*([0-9\\.]+)%\\)\\s+i\\/s\\s+(?:\\(([^)]+)\\)\\s+)?-\\s+(\\d[\\d\\.kM]*)\\s+in\\s+(\\d+\\.\\d+)s/))\n          ips_value          = match[1]\n          deviation_percent  = match[2].to_f\n          time_per_iteration = match[3] || \"-\"\n          iterations         = match[4]\n          total_time         = \"#{match[5]}s\"\n\n          {\n            ips:                ips_value,\n            deviation:          \"± %.2f%%\" % deviation_percent,\n            time_per_iteration: time_per_iteration,\n            iterations:         iterations,\n            total_time:         total_time\n          }\n        else\n          no_data_hash\n        end\n      else\n        no_data_hash(\"No results\")\n      end\n    end\n\n    def format_table_row(type, data)\n      # Wider deviation column accommodates strings like \"± 0.12%\"\n      \"| %-4s | %-5s | %-11s | %-14s | %-10s | %-10s |\\n\" % [\n        type,\n        data[:ips],\n        data[:deviation],\n        data[:time_per_iteration],\n        data[:iterations],\n        data[:total_time]\n      ]\n    end\n\n    def no_data_hash(ips_msg = \"No data\")\n      {\n        ips:                ips_msg,\n        deviation:          \"-\",\n        time_per_iteration: \"-\",\n        iterations:         \"-\",\n        total_time:         \"-\"\n      }\n    end\nend\n"
  },
  {
    "path": "lib/tasks/data_migration.rake",
    "content": "namespace :data_migration do\n  desc \"Migrate EU Plaid webhooks\"\n  # 2025-02-07: EU Plaid items need to be moved over to a new webhook URL so that we can\n  # instantiate the correct Plaid client for verification based on which Plaid instance it comes from\n  task eu_plaid_webhooks: :environment do\n    provider = Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu)\n\n    eu_items = PlaidItem.where(plaid_region: \"eu\")\n\n    eu_items.find_each do |item|\n      request = Plaid::ItemWebhookUpdateRequest.new(\n        access_token: item.access_token,\n        webhook: \"https://app.maybefinance.com/webhooks/plaid_eu\"\n      )\n\n      provider.client.item_webhook_update(request)\n\n      puts \"Updated webhook for Plaid item #{item.plaid_id}\"\n    rescue => error\n      puts \"Error updating webhook for Plaid item #{item.plaid_id}: #{error.message}\"\n    end\n  end\n\n  desc \"Migrate duplicate securities\"\n  # 2025-05-22: older data allowed multiple rows with the same\n  # ticker / exchange_operating_mic (case-insensitive, NULLs collapsed).\n  # This task:\n  #   1. Finds each duplicate group\n  #   2. Chooses the earliest-created row as the keeper\n  #   3. Re-points holdings and trades to the keeper\n  #   4. Destroys the duplicate (which also removes its prices)\n  task migrate_duplicate_securities: :environment do\n    puts \"==> Scanning for duplicate securities…\"\n\n    duplicate_sets = Security\n      .select(\"UPPER(ticker) AS up_ticker,\n               COALESCE(UPPER(exchange_operating_mic), '') AS up_mic,\n               COUNT(*) AS dup_count\")\n      .group(\"up_ticker, up_mic\")\n      .having(\"COUNT(*) > 1\")\n      .to_a\n\n    puts \"Found #{duplicate_sets.size} duplicate groups.\"\n\n    duplicate_sets.each_with_index do |set, idx|\n      # Fetch duplicates ordered by creation; the first row becomes keeper\n      duplicates_scope = Security\n                           .where(\"UPPER(ticker) = ? AND COALESCE(UPPER(exchange_operating_mic), '') = ?\",\n                                  set.up_ticker, set.up_mic)\n                           .order(:created_at)\n\n      keeper = duplicates_scope.first\n      next unless keeper\n\n      duplicates = duplicates_scope.offset(1)\n\n      dup_ids    = duplicates.ids\n\n      # Skip if nothing to merge (defensive; shouldn't occur)\n      next if dup_ids.empty?\n\n      begin\n        ActiveRecord::Base.transaction do\n          # --------------------------------------------------------------\n          # HOLDINGS\n          # --------------------------------------------------------------\n          holdings_moved   = 0\n          holdings_deleted = 0\n\n          dup_ids.each do |dup_id|\n            Holding.where(security_id: dup_id).find_each(batch_size: 1_000) do |holding|\n              # Will this holding collide with an existing keeper row?\n              conflict_exists = Holding.where(\n                account_id: holding.account_id,\n                security_id: keeper.id,\n                date:        holding.date,\n                currency:    holding.currency\n              ).exists?\n\n              if conflict_exists\n                holding.destroy!\n                holdings_deleted += 1\n              else\n                holding.update!(security_id: keeper.id)\n                holdings_moved += 1\n              end\n            end\n          end\n\n          # --------------------------------------------------------------\n          # TRADES — no uniqueness constraints -> bulk update is fine\n          # --------------------------------------------------------------\n          trades_moved = Trade.where(security_id: dup_ids).update_all(security_id: keeper.id)\n\n          # Ensure no rows remain pointing at duplicates before deletion\n          raise \"Leftover holdings detected\" if Holding.where(security_id: dup_ids).exists?\n          raise \"Leftover trades detected\"   if Trade.where(security_id: dup_ids).exists?\n\n          duplicates.each(&:destroy!)   # destroys its security_prices via dependent: :destroy\n\n          # Log inside the transaction so counters are in-scope\n          total_holdings = holdings_moved + holdings_deleted\n          puts \"[#{idx + 1}/#{duplicate_sets.size}] Merged #{dup_ids.join(', ')} → #{keeper.id} \" \\\n               \"(#{total_holdings} holdings → #{holdings_moved} moved, #{holdings_deleted} removed, \" \\\n               \"#{trades_moved} trades)\"\n        end\n      rescue => e\n        puts \"ERROR migrating #{dup_ids.join(', ')}: #{e.message}\"\n      end\n    end\n\n    puts \"✅  Duplicate security migration complete.\"\n  end\n\n  desc \"Migrate account valuation anchors\"\n  # 2025-07-10: Set opening_anchor kinds for valuations to support event-sourced ledger model.\n  # Manual accounts get their oldest valuation marked as opening_anchor, which acts as the\n  # starting balance for the account. Current anchors are only used for Plaid accounts.\n  task migrate_account_valuation_anchors: :environment do\n    puts \"==> Migrating account valuation anchors...\"\n\n    manual_accounts = Account.manual.includes(valuations: :entry)\n    total_accounts = manual_accounts.count\n    accounts_processed = 0\n    opening_anchors_set = 0\n\n    manual_accounts.find_each do |account|\n      accounts_processed += 1\n\n      # Find oldest account entry\n      oldest_entry = account.entries\n                           .order(\"date ASC, created_at ASC\")\n                           .first\n\n      # Check if it's a valuation that isn't already an anchor\n      if oldest_entry && oldest_entry.valuation?\n        derived_valuation_name = Valuation.build_opening_anchor_name(account.accountable_type)\n\n        Account.transaction do\n          oldest_entry.valuation.update!(kind: \"opening_anchor\")\n          oldest_entry.update!(name: derived_valuation_name)\n        end\n        opening_anchors_set += 1\n      end\n\n      if accounts_processed % 100 == 0\n        puts \"[#{accounts_processed}/#{total_accounts}] Processed #{accounts_processed} accounts...\"\n      end\n    rescue => e\n      puts \"ERROR processing account #{account.id}: #{e.message}\"\n    end\n\n    puts \"✅  Account valuation anchor migration complete.\"\n    puts \"    Processed: #{accounts_processed} accounts\"\n    puts \"    Opening anchors set: #{opening_anchors_set}\"\n  end\n\n  desc \"Migrate balance components\"\n  # 2025-07-20: Migrate balance components to support event-sourced ledger model.\n  # This task:\n  #   1. Sets the flows_factor for each account based on the account's classification\n  #   2. Sets the start_cash_balance, start_non_cash_balance, and start_balance for each balance\n  #   3. Sets the cash_inflows, cash_outflows, non_cash_inflows, non_cash_outflows, net_market_flows, cash_adjustments, and non_cash_adjustments for each balance\n  #   4. Sets the end_cash_balance, end_non_cash_balance, and end_balance for each balance\n  task migrate_balance_components: :environment do\n    puts \"==> Migrating balance components...\"\n\n    BalanceComponentMigrator.run\n\n    puts \"✅  Balance component migration complete.\"\n  end\nend\n"
  },
  {
    "path": "lib/tasks/demo_data.rake",
    "content": "namespace :demo_data do\n  desc \"Load empty demo dataset (no financial data)\"\n  task empty: :environment do\n    start = Time.now\n    puts \"🚀 Loading EMPTY demo data…\"\n\n    Demo::Generator.new.generate_empty_data!\n\n    puts \"✅ Done in #{(Time.now - start).round(2)}s\"\n  end\n\n  desc \"Load new-user demo dataset (family created but not onboarded)\"\n  task new_user: :environment do\n    start = Time.now\n    puts \"🚀 Loading NEW-USER demo data…\"\n\n    Demo::Generator.new.generate_new_user_data!\n\n    puts \"✅ Done in #{(Time.now - start).round(2)}s\"\n  end\n\n  desc \"Load full realistic demo dataset\"\n  task default: :environment do\n    start    = Time.now\n    seed     = ENV.fetch(\"SEED\", Random.new_seed)\n    puts \"🚀 Loading FULL demo data (seed=#{seed})…\"\n\n    generator = Demo::Generator.new(seed: seed)\n    generator.generate_default_data!\n\n    validate_demo_data\n\n    elapsed = Time.now - start\n    puts \"🎉 Demo data ready in #{elapsed.round(2)}s\"\n  end\n\n  # ---------------------------------------------------------------------------\n  # Validation helpers\n  # ---------------------------------------------------------------------------\n  def validate_demo_data\n    total_entries   = Entry.count\n    trade_entries   = Entry.where(entryable_type: \"Trade\").count\n    categorized_txn = Transaction.joins(:category).count\n    txn_total       = Transaction.count\n\n    coverage = ((categorized_txn.to_f / txn_total) * 100).round(1)\n\n    puts \"\\n📊 Validation Summary\".ljust(40, \"-\")\n    puts \"Entries total:              #{total_entries}\"\n    puts \"Trade entries:             #{trade_entries} (#{trade_entries.between?(500, 1000) ? '✅' : '❌'})\"\n    puts \"Txn categorization:        #{coverage}% (>=75% ✅)\"\n\n    unless total_entries.between?(8_000, 12_000)\n      puts \"Total entries #{total_entries} outside 8k–12k range\"\n    end\n\n    unless trade_entries.between?(500, 1000)\n      puts \"Trade entries #{trade_entries} outside 500–1 000 range\"\n    end\n\n    unless coverage >= 75\n      puts \"Categorization coverage below 75%\"\n    end\n  end\nend\n"
  },
  {
    "path": "lib/tasks/invites.rake",
    "content": "namespace :invites do\n  desc \"Create invite code(s). Usage: rake invites:create[count]\"\n  task :create, [ :count ] => :environment do |_, args|\n    count = (args[:count] || 1).to_i\n    count.times do\n      puts InviteCode.generate!\n    end\n  end\nend\n"
  },
  {
    "path": "lib/tasks/securities.rake",
    "content": "# frozen_string_literal: true\n\nnamespace :securities do\n  desc \"Backfill exchange_operating_mic for securities using Synth API\"\n  task backfill_exchange_mic: :environment do\n    puts \"Starting exchange_operating_mic backfill...\"\n\n    api_key = Rails.application.config.app_mode.self_hosted? ? Setting.synth_api_key : ENV[\"SYNTH_API_KEY\"]\n    unless api_key.present?\n      puts \"ERROR: No Synth API key found. Please set SYNTH_API_KEY env var or configure it in Settings for self-hosted mode.\"\n      exit 1\n    end\n\n    securities = Security.where(exchange_operating_mic: nil).where.not(ticker: nil)\n    total = securities.count\n    processed = 0\n    errors = []\n\n    securities.find_each do |security|\n      processed += 1\n      print \"\\rProcessing #{processed}/#{total} (#{(processed.to_f/total * 100).round(1)}%)\"\n\n      begin\n        response = Faraday.get(\"https://api.synthfinance.com/tickers/#{security.ticker}\") do |req|\n          req.params[\"country_code\"] = security.country_code if security.country_code.present?\n          req.headers[\"Authorization\"] = \"Bearer #{api_key}\"\n        end\n\n        if response.success?\n          data = JSON.parse(response.body).dig(\"data\")\n          exchange_data = data[\"exchange\"]\n\n          # Update security with exchange info and other metadata\n          security.update!(\n            exchange_operating_mic: exchange_data[\"operating_mic_code\"],\n            exchange_mic: exchange_data[\"mic_code\"],\n            exchange_acronym: exchange_data[\"acronym\"],\n            name: data[\"name\"],\n            logo_url: data[\"logo_url\"],\n            country_code: exchange_data[\"country_code\"]\n          )\n        else\n          errors << \"#{security.ticker}: HTTP #{response.status} - #{response.body}\"\n        end\n      rescue => e\n        errors << \"#{security.ticker}: #{e.message}\"\n      end\n\n      # Add a small delay to not overwhelm the API\n      sleep(0.1)\n    end\n\n    puts \"\\n\\nBackfill complete!\"\n    puts \"Processed #{processed} securities\"\n\n    if errors.any?\n      puts \"\\nErrors encountered:\"\n      errors.each { |error| puts \"  - #{error}\" }\n    end\n  end\n\n  desc \"De-duplicate securities based on ticker + exchange_operating_mic\"\n  task :deduplicate, [ :dry_run ] => :environment do |_t, args|\n    dry_run = args[:dry_run].present?\n    puts \"Starting securities de-duplication... #{dry_run ? '(DRY RUN)' : ''}\"\n\n    # First handle securities without exchange_operating_mic\n    securities_without_mic = Security.where(exchange_operating_mic: nil).where.not(ticker: nil)\n    puts \"\\nFound #{securities_without_mic.count} securities without exchange_operating_mic\"\n\n    securities_without_mic.find_each do |security|\n      # Find if there's a security with the same ticker that has an exchange_operating_mic\n      canonical = Security.where.not(exchange_operating_mic: nil)\n                        .where(ticker: security.ticker)\n                        .order(created_at: :asc)\n                        .first\n\n      if canonical\n        puts \"\\nProcessing #{security.ticker} (no MIC):\"\n        puts \"  Canonical: #{canonical.id} (created: #{canonical.created_at}, MIC: #{canonical.exchange_operating_mic})\"\n        puts \"  Duplicate without MIC: #{security.id}\"\n\n        # Count affected records\n        holdings_count = Holding.where(security_id: security.id).count\n        trades_count = Trade.where(security_id: security.id).count\n        prices_count = Security::Price.where(security_id: security.id).count\n\n        puts \"  Would update:\"\n        puts \"    - #{holdings_count} holdings\"\n        puts \"    - #{trades_count} trades\"\n        puts \"    - #{prices_count} prices\"\n\n        unless dry_run\n          begin\n            ActiveRecord::Base.transaction do\n              # Update all references to point to the canonical security\n              Holding.where(security_id: security.id).update_all(security_id: canonical.id)\n              Trade.where(security_id: security.id).update_all(security_id: canonical.id)\n              Security::Price.where(security_id: security.id).update_all(security_id: canonical.id)\n\n              # Delete the duplicate\n              security.destroy!\n            end\n            puts \"  ✓ Successfully merged and removed duplicate\"\n          rescue => e\n            puts \"  ✗ Error processing #{security.ticker}: #{e.message}\"\n          end\n        end\n      end\n    end\n\n    # Now handle duplicates with same ticker + exchange_operating_mic\n    duplicates = Security\n      .where.not(ticker: nil)\n      .where.not(exchange_operating_mic: nil)\n      .group(:ticker, :exchange_operating_mic)\n      .having(\"COUNT(*) > 1\")\n      .pluck(:ticker, :exchange_operating_mic)\n\n    puts \"\\nFound #{duplicates.length} sets of duplicate securities with same ticker + MIC\"\n    total_holdings = 0\n    total_trades = 0\n    total_prices = 0\n\n    duplicates.each do |ticker, exchange_operating_mic|\n      securities = Security.where(ticker: ticker, exchange_operating_mic: exchange_operating_mic)\n        .order(created_at: :asc)\n\n      canonical = securities.first\n      duplicates = securities[1..]\n\n      puts \"\\nProcessing #{ticker} (#{exchange_operating_mic}):\"\n      puts \"  Canonical: #{canonical.id} (created: #{canonical.created_at})\"\n      puts \"  Duplicates: #{duplicates.map(&:id).join(', ')}\"\n\n      # Count affected records\n      holdings_count = Holding.where(security_id: duplicates).count\n      trades_count = Trade.where(security_id: duplicates).count\n      prices_count = Security::Price.where(security_id: duplicates).count\n\n      total_holdings += holdings_count\n      total_trades += trades_count\n      total_prices += prices_count\n\n      puts \"  Would update:\"\n      puts \"    - #{holdings_count} holdings\"\n      puts \"    - #{trades_count} trades\"\n      puts \"    - #{prices_count} prices\"\n\n      unless dry_run\n        begin\n          ActiveRecord::Base.transaction do\n            # Update all references to point to the canonical security\n            Holding.where(security_id: duplicates).update_all(security_id: canonical.id)\n            Trade.where(security_id: duplicates).update_all(security_id: canonical.id)\n            Security::Price.where(security_id: duplicates).update_all(security_id: canonical.id)\n\n            # Delete the duplicates\n            duplicates.each(&:destroy!)\n          end\n          puts \"  ✓ Successfully merged and removed duplicates\"\n        rescue => e\n          puts \"  ✗ Error processing #{ticker}: #{e.message}\"\n        end\n      end\n    end\n\n    puts \"\\nSummary:\"\n    puts \"  Total duplicate sets: #{duplicates.length}\"\n    puts \"  Total affected records:\"\n    puts \"    - #{total_holdings} holdings\"\n    puts \"    - #{total_trades} trades\"\n    puts \"    - #{total_prices} prices\"\n    puts \"  Mode: #{dry_run ? 'Dry run (no changes made)' : 'Live run (changes applied)'}\"\n    puts \"\\nDe-duplication complete!\"\n  end\nend\n"
  },
  {
    "path": "lib/tasks/stripe.rake",
    "content": "namespace :stripe do\n  desc \"Sync legacy Stripe subscriptions\"\n  task sync_legacy_subscriptions: :environment do\n    cli = Stripe::StripeClient.new(ENV[\"STRIPE_SECRET_KEY\"])\n\n    subs = cli.v1.subscriptions.list\n\n    subs.auto_paging_each do |sub|\n      details = sub.items.data.first\n\n      family = Family.find_by(stripe_customer_id: sub.customer)\n\n      if family.nil?\n        puts \"Family not found for Stripe customer ID: #{sub.customer}, skipping\"\n        next\n      end\n\n      family.subscription.update!(\n        stripe_id: sub.id,\n        status: sub.status,\n        interval: details.plan.interval,\n        amount: details.plan.amount / 100.0,\n        currency: details.plan.currency.upcase,\n        current_period_ends_at: Time.at(details.current_period_end)\n      )\n    end\n  end\nend\n"
  },
  {
    "path": "log/.keep",
    "content": ""
  },
  {
    "path": "package.json",
    "content": "{\n\t\"devDependencies\": {\n\t\t\"@biomejs/biome\": \"^1.9.3\"\n\t},\n\t\"name\": \"maybe\",\n\t\"version\": \"1.0.0\",\n\t\"description\": \"The OS for your personal finances\",\n\t\"scripts\": {\n\t\t\"style:check\": \"biome check\",\n\t\t\"style:fix\": \"biome check --write\",\n\t\t\"lint\": \"biome lint\",\n\t\t\"lint:fix\": \"biome lint --write\",\n\t\t\"format:check\": \"biome format\",\n\t\t\"format\": \"biome format --write\"\n\t},\n\t\"author\": \"\",\n\t\"license\": \"ISC\"\n}\n"
  },
  {
    "path": "perf.rake",
    "content": "# Must be in root of repo for derailed_benchmarks to read the benchmark file\n\nrequire 'bundler'\nBundler.setup\n\nrequire 'derailed_benchmarks'\nrequire 'derailed_benchmarks/tasks'\n\n# Custom auth helper for Maybe's session-based authentication\nclass CustomAuth < DerailedBenchmarks::AuthHelper\n  def setup\n    # No setup needed\n  end\n\n  def call(env)\n    # Make sure this user is created in the DB with realistic data before running benchmarks\n    user = User.find_by!(email: \"user@maybe.local\")\n\n    Rails.logger.debug \"Found user for benchmarking: #{user.email}\"\n\n    # Mimic the way Rails handles browser cookies\n    session = user.sessions.create!\n    key_generator = Rails.application.key_generator\n    secret = key_generator.generate_key('signed cookie')\n    verifier = ActiveSupport::MessageVerifier.new(secret)\n    signed_value = verifier.generate(session.id)\n\n    env['HTTP_COOKIE'] = \"session_token=#{signed_value}\"\n\n    Rails.logger.debug \"Setting up session for user: #{user.email}\"\n\n    app.call(env)\n  end\nend\n\n# Tells derailed_benchmarks to use our custom auth helper\nDerailedBenchmarks.auth = CustomAuth.new\n"
  },
  {
    "path": "public/404.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <title>The page you were looking for doesn't exist (404)</title>\n  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n  <style>\n  .rails-default-error-page {\n    background-color: #EFEFEF;\n    color: #2E2F30;\n    text-align: center;\n    font-family: arial, sans-serif;\n    margin: 0;\n  }\n\n  .rails-default-error-page div.dialog {\n    width: 95%;\n    max-width: 33em;\n    margin: 4em auto 0;\n  }\n\n  .rails-default-error-page div.dialog > div {\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #BBB;\n    border-top: #B00100 solid 4px;\n    border-top-left-radius: 9px;\n    border-top-right-radius: 9px;\n    background-color: white;\n    padding: 7px 12% 0;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n\n  .rails-default-error-page h1 {\n    font-size: 100%;\n    color: #730E15;\n    line-height: 1.5em;\n  }\n\n  .rails-default-error-page div.dialog > p {\n    margin: 0 0 1em;\n    padding: 1em;\n    background-color: #F7F7F7;\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #999;\n    border-bottom-left-radius: 4px;\n    border-bottom-right-radius: 4px;\n    border-top-color: #DADADA;\n    color: #666;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n  </style>\n</head>\n\n<body class=\"rails-default-error-page\">\n  <!-- This file lives in public/404.html -->\n  <div class=\"dialog\">\n    <div>\n      <h1>The page you were looking for doesn't exist.</h1>\n      <p>You may have mistyped the address or the page may have moved.</p>\n    </div>\n    <p>If you are the application owner check the logs for more information.</p>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "public/406-unsupported-browser.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <title>Your browser is not supported (426)</title>\n  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n  <style>\n  .rails-default-error-page {\n    background-color: #EFEFEF;\n    color: #2E2F30;\n    text-align: center;\n    font-family: arial, sans-serif;\n    margin: 0;\n  }\n\n  .rails-default-error-page div.dialog {\n    width: 95%;\n    max-width: 50em;\n    margin: 4em auto 0;\n  }\n\n  .rails-default-error-page div.dialog > div {\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #BBB;\n    border-top: #B00100 solid 4px;\n    border-top-left-radius: 9px;\n    border-top-right-radius: 9px;\n    background-color: white;\n    padding: 7px 12% 0;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n\n  .rails-default-error-page h1 {\n    font-size: 100%;\n    color: #730E15;\n    line-height: 1.5em;\n  }\n\n  .rails-default-error-page div.dialog > p {\n    margin: 0 0 1em;\n    padding: 1em;\n    background-color: #F7F7F7;\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #999;\n    border-bottom-left-radius: 4px;\n    border-bottom-right-radius: 4px;\n    border-top-color: #DADADA;\n    color: #666;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n\n  .browser-links {\n      display: inline-block;\n      margin: 10px 0;\n    }\n\n    .browser-links a {\n      color: #007bff;\n      text-decoration: none;\n      padding: 0 10px;\n    }\n\n    .browser-links a:hover {\n      text-decoration: underline;\n    }\n\n    .separator {\n      padding: 0 5px;\n    }\n  </style>\n</head>\n\n<body class=\"rails-default-error-page\">\n  <!-- This file lives in public/426.html -->\n  <div class=\"dialog\">\n    <div>\n      <h1>Your browser is not supported.</h1>\n      <p>To continue using our site, please upgrade your browser to a modern version.</p>\n      <p>We recommend using the following browsers:</p>\n      <div class=\"browser-links\">\n        <a href=\"https://www.google.com/chrome/\" target=\"_blank\">Google Chrome</a>\n        <span class=\"separator\">|</span>\n        <a href=\"https://www.mozilla.org/firefox/\" target=\"_blank\">Mozilla Firefox</a>\n        <span class=\"separator\">|</span>\n        <a href=\"https://www.microsoft.com/edge/\" target=\"_blank\">Microsoft Edge</a>\n        <span class=\"separator\">|</span>\n        <a href=\"https://www.apple.com/safari/\" target=\"_blank\">Safari</a>\n        <span class=\"separator\">|</span>\n        <a href=\"https://www.opera.com/\" target=\"_blank\">Opera</a>\n      </div>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "public/422.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <title>The change you wanted was rejected (422)</title>\n  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n  <style>\n  .rails-default-error-page {\n    background-color: #EFEFEF;\n    color: #2E2F30;\n    text-align: center;\n    font-family: arial, sans-serif;\n    margin: 0;\n  }\n\n  .rails-default-error-page div.dialog {\n    width: 95%;\n    max-width: 33em;\n    margin: 4em auto 0;\n  }\n\n  .rails-default-error-page div.dialog > div {\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #BBB;\n    border-top: #B00100 solid 4px;\n    border-top-left-radius: 9px;\n    border-top-right-radius: 9px;\n    background-color: white;\n    padding: 7px 12% 0;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n\n  .rails-default-error-page h1 {\n    font-size: 100%;\n    color: #730E15;\n    line-height: 1.5em;\n  }\n\n  .rails-default-error-page div.dialog > p {\n    margin: 0 0 1em;\n    padding: 1em;\n    background-color: #F7F7F7;\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #999;\n    border-bottom-left-radius: 4px;\n    border-bottom-right-radius: 4px;\n    border-top-color: #DADADA;\n    color: #666;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n  </style>\n</head>\n\n<body class=\"rails-default-error-page\">\n  <!-- This file lives in public/422.html -->\n  <div class=\"dialog\">\n    <div>\n      <h1>The change you wanted was rejected.</h1>\n      <p>Maybe you tried to change something you didn't have access to.</p>\n    </div>\n    <p>If you are the application owner check the logs for more information.</p>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "public/426.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <title>Your browser is not supported (426)</title>\n  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n  <style>\n  .rails-default-error-page {\n    background-color: #EFEFEF;\n    color: #2E2F30;\n    text-align: center;\n    font-family: arial, sans-serif;\n    margin: 0;\n  }\n\n  .rails-default-error-page div.dialog {\n    width: 95%;\n    max-width: 33em;\n    margin: 4em auto 0;\n  }\n\n  .rails-default-error-page div.dialog > div {\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #BBB;\n    border-top: #B00100 solid 4px;\n    border-top-left-radius: 9px;\n    border-top-right-radius: 9px;\n    background-color: white;\n    padding: 7px 12% 0;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n\n  .rails-default-error-page h1 {\n    font-size: 100%;\n    color: #730E15;\n    line-height: 1.5em;\n  }\n\n  .rails-default-error-page div.dialog > p {\n    margin: 0 0 1em;\n    padding: 1em;\n    background-color: #F7F7F7;\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #999;\n    border-bottom-left-radius: 4px;\n    border-bottom-right-radius: 4px;\n    border-top-color: #DADADA;\n    color: #666;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n  </style>\n</head>\n\n<body class=\"rails-default-error-page\">\n  <!-- This file lives in public/426.html -->\n  <div class=\"dialog\">\n    <div>\n      <h1>Your browser is not supported.</h1>\n      <p>Please upgrade your browser to continue.</p>\n    </div>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "public/500.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <title>We're sorry, but something went wrong (500)</title>\n  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n  <style>\n  .rails-default-error-page {\n    background-color: #EFEFEF;\n    color: #2E2F30;\n    text-align: center;\n    font-family: arial, sans-serif;\n    margin: 0;\n  }\n\n  .rails-default-error-page div.dialog {\n    width: 95%;\n    max-width: 33em;\n    margin: 4em auto 0;\n  }\n\n  .rails-default-error-page div.dialog > div {\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #BBB;\n    border-top: #B00100 solid 4px;\n    border-top-left-radius: 9px;\n    border-top-right-radius: 9px;\n    background-color: white;\n    padding: 7px 12% 0;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n\n  .rails-default-error-page h1 {\n    font-size: 100%;\n    color: #730E15;\n    line-height: 1.5em;\n  }\n\n  .rails-default-error-page div.dialog > p {\n    margin: 0 0 1em;\n    padding: 1em;\n    background-color: #F7F7F7;\n    border: 1px solid #CCC;\n    border-right-color: #999;\n    border-left-color: #999;\n    border-bottom-color: #999;\n    border-bottom-left-radius: 4px;\n    border-bottom-right-radius: 4px;\n    border-top-color: #DADADA;\n    color: #666;\n    box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);\n  }\n  </style>\n</head>\n\n<body class=\"rails-default-error-page\">\n  <!-- This file lives in public/500.html -->\n  <div class=\"dialog\">\n    <div>\n      <h1>We're sorry, but something went wrong.</h1>\n    </div>\n    <p>If you are the application owner check the logs for more information.</p>\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "public/browserconfig.xml",
    "content": "<?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": "public/robots.txt",
    "content": "# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file\n"
  },
  {
    "path": "public/site.webmanifest",
    "content": "{\n    \"name\": \"\",\n    \"short_name\": \"\",\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\": \"#ffffff\",\n    \"background_color\": \"#ffffff\",\n    \"display\": \"fullscreen\"\n}\n"
  },
  {
    "path": "storage/.keep",
    "content": ""
  },
  {
    "path": "test/application_system_test_case.rb",
    "content": "require \"test_helper\"\n\nclass ApplicationSystemTestCase < ActionDispatch::SystemTestCase\n  setup do\n    Capybara.default_max_wait_time = 5\n  end\n\n  driven_by :selenium, using: ENV[\"CI\"].present? ? :headless_chrome : ENV.fetch(\"E2E_BROWSER\", :chrome).to_sym, screen_size: [ 1400, 1400 ]\n\n  private\n\n    def sign_in(user)\n      visit new_session_path\n      within \"form\" do\n        fill_in \"Email\", with: user.email\n        fill_in \"Password\", with: user_password_test\n        click_on \"Log in\"\n      end\n\n      # Trigger Capybara's wait mechanism to avoid timing issues with logins\n      find(\"h1\", text: \"Welcome back, #{user.first_name}\")\n    end\n\n    def login_as(user)\n      sign_in(user)\n    end\n\n    def sign_out\n      find(\"#user-menu\").click\n      click_button \"Logout\"\n\n      # Trigger Capybara's wait mechanism to avoid timing issues with logout\n      find(\"h2\", text: \"Sign in to your account\")\n    end\n\n    def within_testid(testid)\n      within \"[data-testid='#{testid}']\" do\n        yield\n      end\n    end\nend\n"
  },
  {
    "path": "test/channels/application_cable/connection_test.rb",
    "content": "require \"test_helper\"\n\nmodule ApplicationCable\n  class ConnectionTest < ActionCable::Connection::TestCase\n    # test \"connects with cookies\" do\n    #   cookies.signed[:user_id] = 42\n    #\n    #   connect\n    #\n    #   assert_equal connection.user_id, \"42\"\n    # end\n  end\nend\n"
  },
  {
    "path": "test/components/previews/alert_component_preview.rb",
    "content": "class AlertComponentPreview < Lookbook::Preview\n  # @param message text\n  # @param variant select [info, success, warning, error]\n  def default(message: \"This is an alert message.\", variant: :info)\n    render DS::Alert.new(message: message, variant: variant.to_sym)\n  end\nend\n"
  },
  {
    "path": "test/components/previews/button_component_preview.rb",
    "content": "class ButtonComponentPreview < ViewComponent::Preview\n  # @param variant select {{ DS::Button::VARIANTS.keys }}\n  # @param size select {{ DS::Button::SIZES.keys }}\n  # @param disabled toggle\n  # @param icon select [\"plus\", \"circle\"]\n  def default(variant: \"primary\", size: \"md\", disabled: false, icon: \"plus\")\n    render DS::Button.new(\n      text: \"Sample button\",\n      variant: variant,\n      size: size,\n      disabled: disabled,\n      icon: icon,\n      data: { menu_target: \"button\" }\n    )\n  end\nend\n"
  },
  {
    "path": "test/components/previews/dialog_component_preview.rb",
    "content": "class DialogComponentPreview < ViewComponent::Preview\n  # @param show_overflow toggle\n  def modal(show_overflow: false)\n    render DS::Dialog.new(variant: \"modal\") do |dialog|\n      dialog.with_header(title: \"Sample modal title\")\n\n      dialog.with_body do\n        \"Welcome to Maybe!  This is some test modal content.\"\n      end\n\n      dialog.with_action(cancel_action: true, text: \"Cancel\", variant: \"outline\")\n      dialog.with_action(text: \"Submit\")\n\n      if show_overflow\n        content_tag(:div, class: \"p-4 font-semibold h-[800px] bg-surface-inset\") do\n          \"Example of overflow content\"\n        end\n      end\n    end\n  end\n\n  # @param show_overflow toggle\n  def drawer(show_overflow: false)\n    render DS::Dialog.new(variant: \"drawer\") do |dialog|\n      dialog.with_header(title: \"Drawer title\")\n\n      dialog.with_body do\n        dialog.with_section(title: \"Section 1\", open: true) do\n          content_tag(:div, \"Section 1 content\", class: \"p-2\")\n        end\n\n        dialog.with_section(title: \"Section 2\", open: true) do\n          content_tag(:div, \"Section 2 content\", class: \"p-2\")\n        end\n      end\n\n      dialog.with_action(text: \"Example action\")\n\n      if show_overflow\n        content_tag(:div, class: \"p-4 font-semibold h-[800px] bg-surface-inset\") do\n          \"Example of overflow content\"\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/components/previews/disclosure_component_preview.rb",
    "content": "class DisclosureComponentPreview < ViewComponent::Preview\n  # @display container_classes max-w-[400px]\n  # @param align select [\"left\", \"right\"]\n  def default(align: \"right\")\n    render DS::Disclosure.new(title: \"Title\", align: align, open: true) do |disclosure|\n      disclosure.with_summary_content do\n        content_tag(:p, \"$200.25\", class: \"text-xs font-mono font-medium\")\n      end\n\n      content_tag(:p, \"Sample disclosure content\", class: \"text-sm\")\n    end\n  end\nend\n"
  },
  {
    "path": "test/components/previews/filled_icon_component_preview.rb",
    "content": "class FilledIconComponentPreview < ViewComponent::Preview\n  # @param size select [\"sm\", \"md\", \"lg\"]\n  def default(size: \"md\")\n    render DS::FilledIcon.new(icon: \"home\", variant: :default, size: size)\n  end\n\n  # @param size select [\"sm\", \"md\", \"lg\"]\n  def text(size: \"md\")\n    render DS::FilledIcon.new(variant: :text, text: \"Test\", size: size, rounded: true)\n  end\nend\n"
  },
  {
    "path": "test/components/previews/link_component_preview.rb",
    "content": "class LinkComponentPreview < ViewComponent::Preview\n  # Usage\n  # -------------\n  #\n  # DS::Link is a small abstraction on top of the `link_to` helper.\n  #\n  # It can be used as a regular link or styled as a \"Link button\" using any of the available DS::Button variants.\n  #\n  # @param variant select {{ DS::Link::VARIANTS.keys }}\n  # @param size select {{ DS::Link::SIZES.keys }}\n  # @param icon select [\"\", \"plus\", \"arrow-right\"]\n  # @param icon_position select [\"left\", \"right\"]\n  # @param full_width toggle\n  def default(variant: \"default\", size: \"md\", icon: \"plus\", icon_position: \"left\", full_width: false)\n    render DS::Link.new(\n      href: \"#\",\n      text: \"Preview link\",\n      variant: variant,\n      size: size,\n      icon: icon,\n      icon_position: icon_position,\n      full_width: full_width\n    )\n  end\nend\n"
  },
  {
    "path": "test/components/previews/menu_component_preview.rb",
    "content": "class MenuComponentPreview < ViewComponent::Preview\n  def icon\n    render DS::Menu.new(variant: \"icon\") do |menu|\n      menu_contents(menu)\n    end\n  end\n\n  def button\n    render DS::Menu.new(variant: \"button\") do |menu|\n      menu.with_button(text: \"Open menu\", variant: \"secondary\")\n      menu_contents(menu)\n    end\n  end\n\n  def avatar\n    render DS::Menu.new(variant: \"avatar\") do |menu|\n      menu_contents(menu)\n    end\n  end\n\n  private\n    def menu_contents(menu)\n      menu.with_header do\n        content_tag(:div, class: \"p-3\") do\n          content_tag(:h3, \"Menu header\", class: \"font-medium text-gray-900\")\n        end\n      end\n\n      menu.with_item(variant: \"link\", text: \"Link\", href: \"#\", icon: \"plus\")\n      menu.with_item(variant: \"button\", text: \"Action\", href: \"#\", method: :post, icon: \"circle\")\n      menu.with_item(variant: \"button\", text: \"Action destructive\", href: \"#\", method: :delete, icon: \"circle\")\n\n      menu.with_item(variant: \"divider\")\n\n      menu.with_custom_content do\n        content_tag(:div, class: \"p-4\") do\n          safe_join([\n            content_tag(:h3, \"Custom content header\", class: \"font-medium text-gray-900\"),\n            content_tag(:p, \"Some custom content\", class: \"text-sm text-gray-500\")\n          ])\n        end\n      end\n    end\nend\n"
  },
  {
    "path": "test/components/previews/tabs_component_preview/custom.html.erb",
    "content": "<%= render TabsComponent.new(\n  variant: :unstyled,\n  active_tab: \"tab1\", \n  active_btn_classes: \"bg-white text-primary\", \n  inactive_btn_classes: \"text-secondary\",\n) do |tabs| %>\n  <div class=\"flex border border-secondary rounded-lg h-full max-w-[400px]\">\n    <%= tabs.with_nav(classes: \"flex flex-col py-2 px-3 border-r border-secondary\") do |nav| %>\n      <%= nav.with_btn(id: \"tab1\", label: \"Tab 1\", classes: \"px-2 py-1 rounded-md w-full whitespace-nowrap\") %>\n      <%= nav.with_btn(id: \"tab2\", label: \"Tab 2\", classes: \"px-2 py-1 rounded-md w-full whitespace-nowrap\") %>\n    <% end %>\n\n    <div class=\"flex flex-col w-full\">\n      <div class=\"h-[200px] p-4\">\n        <%= tabs.with_panel(tab_id: \"tab1\") do %>\n          <%= content_tag(:p, \"Content for tab 1\") %>\n        <% end %>\n\n        <%= tabs.with_panel(tab_id: \"tab2\") do %>\n          <%= content_tag(:p, \"Content for tab 2\") %>\n        <% end %>\n      </div>\n\n      <div class=\"w-full border-t border-secondary p-4\">\n        Footer\n      </div>\n    </div>\n  </div>\n<% end %>\n"
  },
  {
    "path": "test/components/previews/tabs_component_preview/default.html.erb",
    "content": "<div class=\"max-w-[400px]\">\n  <%= render TabsComponent.new(active_tab: \"tab1\") do |tabs| %>\n    <%= tabs.with_nav do |tab_nav| %>\n      <%= tab_nav.with_btn(id: \"tab1\", label: \"Tab 1\") %>\n      <%= tab_nav.with_btn(id: \"tab2\", label: \"Tab 2\") %>\n    <% end %>\n\n    <%= tabs.with_panel(tab_id: \"tab1\") do %>\n      <%= content_tag(:p, \"Content for tab 1\") %>\n    <% end %>\n\n    <%= tabs.with_panel(tab_id: \"tab2\") do %>\n      <%= content_tag(:p, \"Content for tab 2\") %>\n    <% end %>\n  <% end %>\n</div>"
  },
  {
    "path": "test/components/previews/tabs_component_preview.rb",
    "content": "class TabsComponentPreview < ViewComponent::Preview\n  def default\n  end\n\n  def custom\n  end\nend\n"
  },
  {
    "path": "test/components/previews/toggle_component_preview.rb",
    "content": "class ToggleComponentPreview < ViewComponent::Preview\n  # @param disabled toggle\n  def default(disabled: false)\n    render(\n      DS::Toggle.new(\n        id: \"toggle-component-id\",\n        name: \"toggle-component-name\",\n        checked: false,\n        disabled: disabled,\n        checked_value: \"on\",\n        unchecked_value: \"off\"\n      )\n    )\n  end\nend\n"
  },
  {
    "path": "test/components/previews/tooltip_component_preview.rb",
    "content": "class TooltipComponentPreview < ViewComponent::Preview\n  # @param text text\n  # @param placement select [top, right, bottom, left]\n  # @param offset number\n  # @param cross_axis number\n  # @param icon text\n  # @param size select [xs, sm, md, lg, xl, 2xl]\n  # @param color select [default, white, success, warning, destructive, current]\n  def default(text: \"This is helpful information\", placement: \"top\", offset: 10, cross_axis: 0, icon: \"info\", size: \"sm\", color: \"default\")\n    render DS::Tooltip.new(\n      text: text,\n      placement: placement,\n      offset: offset,\n      cross_axis: cross_axis,\n      icon: icon,\n      size: size,\n      color: color\n    )\n  end\n\n  def with_block_content\n    render DS::Tooltip.new(icon: \"help-circle\", color: \"warning\") do\n      tag.div do\n        tag.p(\"Custom content with formatting:\", class: \"font-medium mb-1\") +\n        tag.ul(class: \"list-disc list-inside text-xs\") do\n          tag.li(\"First item\") +\n          tag.li(\"Second item\")\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/accountable_sparklines_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass AccountableSparklinesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"should get show for depository\" do\n    get accountable_sparkline_url(\"depository\")\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/accounts_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass AccountsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n    @account = accounts(:depository)\n  end\n\n  test \"should get index\" do\n    get accounts_url\n    assert_response :success\n  end\n\n  test \"should get show\" do\n    get account_url(@account)\n    assert_response :success\n  end\n\n  test \"should sync account\" do\n    post sync_account_url(@account)\n    assert_redirected_to account_url(@account)\n  end\n\n  test \"should get sparkline\" do\n    get sparkline_account_url(@account)\n    assert_response :success\n  end\n\n  test \"destroys account\" do\n    delete account_url(@account)\n    assert_redirected_to accounts_path\n    assert_enqueued_with job: DestroyJob\n    assert_equal \"Account scheduled for deletion\", flash[:notice]\n  end\nend\n"
  },
  {
    "path": "test/controllers/api/v1/accounts_controller_test.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nclass Api::V1::AccountsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_admin) # dylan_family user\n    @other_family_user = users(:family_member)\n    @other_family_user.update!(family: families(:empty))\n\n    @oauth_app = Doorkeeper::Application.create!(\n      name: \"Test API App\",\n      redirect_uri: \"https://example.com/callback\",\n      scopes: \"read read_write\"\n    )\n  end\n\n  test \"should require authentication\" do\n    get \"/api/v1/accounts\"\n    assert_response :unauthorized\n\n    response_body = JSON.parse(response.body)\n    assert_equal \"unauthorized\", response_body[\"error\"]\n  end\n\n  test \"should require read_accounts scope\" do\n  # TODO: Re-enable this test after fixing scope checking\n  skip \"Scope checking temporarily disabled - needs configuration fix\"\n\n  # Create token with wrong scope - using a non-existent scope to test rejection\n  access_token = Doorkeeper::AccessToken.create!(\n    application: @oauth_app,\n    resource_owner_id: @user.id,\n    scopes: \"invalid_scope\" # Wrong scope\n  )\n\n  get \"/api/v1/accounts\", params: {}, headers: {\n    \"Authorization\" => \"Bearer #{access_token.token}\"\n  }\n\n  assert_response :forbidden\n\n  # Doorkeeper returns a standard OAuth error response\n  response_body = JSON.parse(response.body)\n  assert_equal \"insufficient_scope\", response_body[\"error\"]\nend\n\n  test \"should return user's family accounts successfully\" do\n    access_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read\"\n    )\n\n    get \"/api/v1/accounts\", params: {}, headers: {\n      \"Authorization\" => \"Bearer #{access_token.token}\"\n    }\n\n    assert_response :success\n    response_body = JSON.parse(response.body)\n\n    # Should have accounts array\n    assert response_body.key?(\"accounts\")\n    assert response_body[\"accounts\"].is_a?(Array)\n\n    # Should have pagination metadata\n    assert response_body.key?(\"pagination\")\n    assert response_body[\"pagination\"].key?(\"page\")\n    assert response_body[\"pagination\"].key?(\"per_page\")\n    assert response_body[\"pagination\"].key?(\"total_count\")\n    assert response_body[\"pagination\"].key?(\"total_pages\")\n\n    # All accounts should belong to user's family\n    response_body[\"accounts\"].each do |account|\n      # We'll validate this by checking the user's family has these accounts\n      family_account_names = @user.family.accounts.pluck(:name)\n      assert_includes family_account_names, account[\"name\"]\n    end\n  end\n\n  test \"should only return active accounts\" do\n    # Make one account inactive\n    inactive_account = accounts(:depository)\n    inactive_account.disable!\n\n    access_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read\"\n    )\n\n    get \"/api/v1/accounts\", params: {}, headers: {\n      \"Authorization\" => \"Bearer #{access_token.token}\"\n    }\n\n    assert_response :success\n    response_body = JSON.parse(response.body)\n\n    # Should not include the inactive account\n    account_names = response_body[\"accounts\"].map { |a| a[\"name\"] }\n    assert_not_includes account_names, inactive_account.name\n  end\n\n  test \"should not return other family's accounts\" do\n    access_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @other_family_user.id,  # User from different family\n      scopes: \"read\"\n    )\n\n    get \"/api/v1/accounts\", params: {}, headers: {\n      \"Authorization\" => \"Bearer #{access_token.token}\"\n    }\n\n    assert_response :success\n    response_body = JSON.parse(response.body)\n\n    # Should return empty array since other family has no accounts in fixtures\n    assert_equal [], response_body[\"accounts\"]\n    assert_equal 0, response_body[\"pagination\"][\"total_count\"]\n  end\n\n  test \"should handle pagination parameters\" do\n    access_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read\"\n    )\n\n    # Test with pagination params\n    get \"/api/v1/accounts\", params: { page: 1, per_page: 2 }, headers: {\n      \"Authorization\" => \"Bearer #{access_token.token}\"\n    }\n\n    assert_response :success\n    response_body = JSON.parse(response.body)\n\n    # Should respect per_page limit\n    assert response_body[\"accounts\"].length <= 2\n    assert_equal 1, response_body[\"pagination\"][\"page\"]\n    assert_equal 2, response_body[\"pagination\"][\"per_page\"]\n  end\n\n  test \"should return proper account data structure\" do\n    access_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read\"\n    )\n\n    get \"/api/v1/accounts\", params: {}, headers: {\n      \"Authorization\" => \"Bearer #{access_token.token}\"\n    }\n\n    assert_response :success\n    response_body = JSON.parse(response.body)\n\n    # Should have at least one account from fixtures\n    assert response_body[\"accounts\"].length > 0\n\n    account = response_body[\"accounts\"].first\n\n    # Check required fields are present\n    required_fields = %w[id name balance currency classification account_type]\n    required_fields.each do |field|\n      assert account.key?(field), \"Account should have #{field} field\"\n    end\n\n    # Check data types\n    assert account[\"id\"].is_a?(String), \"ID should be string (UUID)\"\n    assert account[\"name\"].is_a?(String), \"Name should be string\"\n    assert account[\"balance\"].is_a?(String), \"Balance should be string (money)\"\n    assert account[\"currency\"].is_a?(String), \"Currency should be string\"\n    assert %w[asset liability].include?(account[\"classification\"]), \"Classification should be asset or liability\"\n  end\n\n  test \"should handle invalid pagination parameters gracefully\" do\n    access_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read\"\n    )\n\n    # Test with invalid page number\n    get \"/api/v1/accounts\", params: { page: -1, per_page: \"invalid\" }, headers: {\n      \"Authorization\" => \"Bearer #{access_token.token}\"\n    }\n\n    # Should still return success with default pagination\n    assert_response :success\n    response_body = JSON.parse(response.body)\n\n    # Should have pagination info (with defaults applied)\n    assert response_body.key?(\"pagination\")\n    assert response_body[\"pagination\"][\"page\"] >= 1\n    assert response_body[\"pagination\"][\"per_page\"] > 0\n  end\n\n  test \"should sort accounts alphabetically\" do\n    access_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read\"\n    )\n\n    get \"/api/v1/accounts\", params: {}, headers: {\n      \"Authorization\" => \"Bearer #{access_token.token}\"\n    }\n\n    assert_response :success\n    response_body = JSON.parse(response.body)\n\n    # Should be sorted alphabetically by name\n    account_names = response_body[\"accounts\"].map { |a| a[\"name\"] }\n    assert_equal account_names.sort, account_names\n  end\nend\n"
  },
  {
    "path": "test/controllers/api/v1/auth_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Api::V1::AuthControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    # Clean up any existing invite codes\n    InviteCode.destroy_all\n    @device_info = {\n      device_id: \"test-device-123\",\n      device_name: \"Test iPhone\",\n      device_type: \"ios\",\n      os_version: \"17.0\",\n      app_version: \"1.0.0\"\n    }\n  end\n\n  test \"should signup new user and return OAuth tokens\" do\n    assert_difference(\"User.count\", 1) do\n      assert_difference(\"MobileDevice.count\", 1) do\n        assert_difference(\"Doorkeeper::Application.count\", 1) do\n          assert_difference(\"Doorkeeper::AccessToken.count\", 1) do\n            post \"/api/v1/auth/signup\", params: {\n              user: {\n                email: \"newuser@example.com\",\n                password: \"SecurePass123!\",\n                first_name: \"New\",\n                last_name: \"User\"\n              },\n              device: @device_info\n            }\n          end\n        end\n      end\n    end\n\n    assert_response :created\n    response_data = JSON.parse(response.body)\n\n    assert response_data[\"user\"][\"id\"].present?\n    assert_equal \"newuser@example.com\", response_data[\"user\"][\"email\"]\n    assert_equal \"New\", response_data[\"user\"][\"first_name\"]\n    assert_equal \"User\", response_data[\"user\"][\"last_name\"]\n\n    # OAuth token assertions\n    assert response_data[\"access_token\"].present?\n    assert response_data[\"refresh_token\"].present?\n    assert_equal \"Bearer\", response_data[\"token_type\"]\n    assert_equal 2592000, response_data[\"expires_in\"] # 30 days\n    assert response_data[\"created_at\"].present?\n\n    # Verify the device was created\n    new_user = User.find(response_data[\"user\"][\"id\"])\n    device = new_user.mobile_devices.first\n    assert_equal @device_info[:device_id], device.device_id\n    assert_equal @device_info[:device_name], device.device_name\n    assert_equal @device_info[:device_type], device.device_type\n  end\n\n  test \"should not signup without device info\" do\n    assert_no_difference(\"User.count\") do\n      post \"/api/v1/auth/signup\", params: {\n        user: {\n          email: \"newuser@example.com\",\n          password: \"SecurePass123!\",\n          first_name: \"New\",\n          last_name: \"User\"\n        }\n      }\n    end\n\n    assert_response :bad_request\n    response_data = JSON.parse(response.body)\n    assert_equal \"Device information is required\", response_data[\"error\"]\n  end\n\n  test \"should not signup with invalid password\" do\n    assert_no_difference(\"User.count\") do\n      post \"/api/v1/auth/signup\", params: {\n        user: {\n          email: \"newuser@example.com\",\n          password: \"weak\",\n          first_name: \"New\",\n          last_name: \"User\"\n        },\n        device: @device_info\n      }\n    end\n\n    assert_response :unprocessable_entity\n    response_data = JSON.parse(response.body)\n    assert response_data[\"errors\"].include?(\"Password must be at least 8 characters\")\n  end\n\n  test \"should not signup with duplicate email\" do\n    existing_user = users(:family_admin)\n\n    assert_no_difference(\"User.count\") do\n      post \"/api/v1/auth/signup\", params: {\n        user: {\n          email: existing_user.email,\n          password: \"SecurePass123!\",\n          first_name: \"Duplicate\",\n          last_name: \"User\"\n        },\n        device: @device_info\n      }\n    end\n\n    assert_response :unprocessable_entity\n  end\n\n  test \"should create user with admin role and family\" do\n    post \"/api/v1/auth/signup\", params: {\n      user: {\n        email: \"newuser@example.com\",\n        password: \"SecurePass123!\",\n        first_name: \"New\",\n        last_name: \"User\"\n      },\n      device: @device_info\n    }\n\n    assert_response :created\n    response_data = JSON.parse(response.body)\n\n    new_user = User.find(response_data[\"user\"][\"id\"])\n    assert_equal \"admin\", new_user.role\n    assert new_user.family.present?\n  end\n\n  test \"should require invite code when enabled\" do\n    # Mock invite code requirement\n    Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(true)\n\n    assert_no_difference(\"User.count\") do\n      post \"/api/v1/auth/signup\", params: {\n        user: {\n          email: \"newuser@example.com\",\n          password: \"SecurePass123!\",\n          first_name: \"New\",\n          last_name: \"User\"\n        },\n        device: @device_info\n      }\n    end\n\n    assert_response :forbidden\n    response_data = JSON.parse(response.body)\n    assert_equal \"Invite code is required\", response_data[\"error\"]\n  end\n\n  test \"should signup with valid invite code when required\" do\n    # Create a valid invite code\n    invite_code = InviteCode.create!\n\n    # Mock invite code requirement\n    Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(true)\n\n    assert_difference(\"User.count\", 1) do\n      assert_difference(\"InviteCode.count\", -1) do\n        post \"/api/v1/auth/signup\", params: {\n          user: {\n            email: \"newuser@example.com\",\n            password: \"SecurePass123!\",\n            first_name: \"New\",\n            last_name: \"User\"\n          },\n          device: @device_info,\n          invite_code: invite_code.token\n        }\n      end\n    end\n\n    assert_response :created\n  end\n\n  test \"should reject invalid invite code\" do\n    # Mock invite code requirement\n    Api::V1::AuthController.any_instance.stubs(:invite_code_required?).returns(false)\n\n    assert_no_difference(\"User.count\") do\n      post \"/api/v1/auth/signup\", params: {\n        user: {\n          email: \"newuser@example.com\",\n          password: \"SecurePass123!\",\n          first_name: \"New\",\n          last_name: \"User\"\n        },\n        device: @device_info,\n        invite_code: \"invalid_code\"\n      }\n    end\n\n    assert_response :forbidden\n    response_data = JSON.parse(response.body)\n    assert_equal \"Invalid invite code\", response_data[\"error\"]\n  end\n\n  test \"should login existing user and return OAuth tokens\" do\n    user = users(:family_admin)\n    password = user_password_test\n\n    # Ensure user has no mobile devices\n    user.mobile_devices.destroy_all\n\n    assert_difference(\"MobileDevice.count\", 1) do\n      assert_difference(\"Doorkeeper::AccessToken.count\", 1) do\n        post \"/api/v1/auth/login\", params: {\n          email: user.email,\n          password: password,\n          device: @device_info\n        }\n      end\n    end\n\n    assert_response :success\n    response_data = JSON.parse(response.body)\n\n    assert_equal user.id.to_s, response_data[\"user\"][\"id\"]\n    assert_equal user.email, response_data[\"user\"][\"email\"]\n\n    # OAuth token assertions\n    assert response_data[\"access_token\"].present?\n    assert response_data[\"refresh_token\"].present?\n    assert_equal \"Bearer\", response_data[\"token_type\"]\n    assert_equal 2592000, response_data[\"expires_in\"] # 30 days\n\n    # Verify the device\n    device = user.mobile_devices.where(device_id: @device_info[:device_id]).first\n    assert device.present?\n    assert device.active?\n  end\n\n  test \"should require MFA when enabled\" do\n    user = users(:family_admin)\n    password = user_password_test\n\n    # Enable MFA for user\n    user.setup_mfa!\n    user.enable_mfa!\n\n    post \"/api/v1/auth/login\", params: {\n      email: user.email,\n      password: password,\n      device: @device_info\n    }\n\n    assert_response :unauthorized\n    response_data = JSON.parse(response.body)\n    assert_equal \"Two-factor authentication required\", response_data[\"error\"]\n    assert response_data[\"mfa_required\"]\n  end\n\n  test \"should login with valid MFA code\" do\n    user = users(:family_admin)\n    password = user_password_test\n\n    # Enable MFA for user\n    user.setup_mfa!\n    user.enable_mfa!\n    totp = ROTP::TOTP.new(user.otp_secret)\n\n    assert_difference(\"Doorkeeper::AccessToken.count\", 1) do\n      post \"/api/v1/auth/login\", params: {\n        email: user.email,\n        password: password,\n        otp_code: totp.now,\n        device: @device_info\n      }\n    end\n\n    assert_response :success\n    response_data = JSON.parse(response.body)\n    assert response_data[\"access_token\"].present?\n  end\n\n  test \"should revoke existing tokens for same device on login\" do\n    user = users(:family_admin)\n    password = user_password_test\n\n    # Create an existing device and token\n    device = user.mobile_devices.create!(@device_info)\n    oauth_app = device.create_oauth_application!\n    existing_token = Doorkeeper::AccessToken.create!(\n      application: oauth_app,\n      resource_owner_id: user.id,\n      expires_in: 30.days.to_i,\n      scopes: \"read_write\"\n    )\n\n    assert existing_token.accessible?\n\n    post \"/api/v1/auth/login\", params: {\n      email: user.email,\n      password: password,\n      device: @device_info\n    }\n\n    assert_response :success\n\n    # Check that old token was revoked\n    existing_token.reload\n    assert existing_token.revoked?\n  end\n\n  test \"should not login with invalid password\" do\n    user = users(:family_admin)\n\n    assert_no_difference(\"Doorkeeper::AccessToken.count\") do\n      post \"/api/v1/auth/login\", params: {\n        email: user.email,\n        password: \"wrong_password\",\n        device: @device_info\n      }\n    end\n\n    assert_response :unauthorized\n    response_data = JSON.parse(response.body)\n    assert_equal \"Invalid email or password\", response_data[\"error\"]\n  end\n\n  test \"should not login with non-existent email\" do\n    assert_no_difference(\"Doorkeeper::AccessToken.count\") do\n      post \"/api/v1/auth/login\", params: {\n        email: \"nonexistent@example.com\",\n        password: user_password_test,\n        device: @device_info\n      }\n    end\n\n    assert_response :unauthorized\n    response_data = JSON.parse(response.body)\n    assert_equal \"Invalid email or password\", response_data[\"error\"]\n  end\n\n  test \"should not login without device info\" do\n    user = users(:family_admin)\n\n    assert_no_difference(\"Doorkeeper::AccessToken.count\") do\n      post \"/api/v1/auth/login\", params: {\n        email: user.email,\n        password: user_password_test\n      }\n    end\n\n    assert_response :bad_request\n    response_data = JSON.parse(response.body)\n    assert_equal \"Device information is required\", response_data[\"error\"]\n  end\n\n  test \"should refresh access token with valid refresh token\" do\n    user = users(:family_admin)\n    device = user.mobile_devices.create!(@device_info)\n    oauth_app = device.create_oauth_application!\n\n    # Create initial token\n    initial_token = Doorkeeper::AccessToken.create!(\n      application: oauth_app,\n      resource_owner_id: user.id,\n      expires_in: 30.days.to_i,\n      scopes: \"read_write\",\n      use_refresh_token: true\n    )\n\n    # Wait to ensure different timestamps\n    sleep 0.1\n\n    assert_difference(\"Doorkeeper::AccessToken.count\", 1) do\n      post \"/api/v1/auth/refresh\", params: {\n        refresh_token: initial_token.refresh_token,\n        device: @device_info\n      }\n    end\n\n    assert_response :success\n    response_data = JSON.parse(response.body)\n\n    # New token assertions\n    assert response_data[\"access_token\"].present?\n    assert response_data[\"refresh_token\"].present?\n    assert_not_equal initial_token.token, response_data[\"access_token\"]\n    assert_equal 2592000, response_data[\"expires_in\"]\n\n    # Old token should be revoked\n    initial_token.reload\n    assert initial_token.revoked?\n  end\n\n  test \"should not refresh with invalid refresh token\" do\n    assert_no_difference(\"Doorkeeper::AccessToken.count\") do\n      post \"/api/v1/auth/refresh\", params: {\n        refresh_token: \"invalid_token\",\n        device: @device_info\n      }\n    end\n\n    assert_response :unauthorized\n    response_data = JSON.parse(response.body)\n    assert_equal \"Invalid refresh token\", response_data[\"error\"]\n  end\n\n  test \"should not refresh without refresh token\" do\n    post \"/api/v1/auth/refresh\", params: {\n      device: @device_info\n    }\n\n    assert_response :bad_request\n    response_data = JSON.parse(response.body)\n    assert_equal \"Refresh token is required\", response_data[\"error\"]\n  end\nend\n"
  },
  {
    "path": "test/controllers/api/v1/base_controller_test.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nclass Api::V1::BaseControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_admin)\n    @oauth_app = Doorkeeper::Application.create!(\n      name: \"Test API App\",\n      redirect_uri: \"https://example.com/callback\",\n      scopes: \"read read_write\"\n    )\n\n    # Clean up any existing API keys for the test user\n    @user.api_keys.destroy_all\n\n    # Create a test API key\n    @plain_api_key = \"base_test_#{SecureRandom.hex(8)}\"\n    @api_key = ApiKey.create!(\n      user: @user,\n      name: \"Test API Key\",\n      display_key: @plain_api_key,\n      scopes: [ \"read_write\" ]\n    )\n\n    # Clear any existing rate limit data\n    Redis.new.del(\"api_rate_limit:#{@api_key.id}\")\n  end\n\n  teardown do\n    # Clean up Redis data after each test\n    Redis.new.del(\"api_rate_limit:#{@api_key.id}\")\n  end\n\n  test \"should require authentication\" do\n    # Test that endpoints require OAuth tokens\n    get \"/api/v1/test\"\n    assert_response :unauthorized\n\n    response_body = JSON.parse(response.body)\n    assert_equal \"unauthorized\", response_body[\"error\"]\n  end\n\n  test \"should authenticate with valid access token\" do\n    # Create a valid access token\n    access_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read\"\n    )\n\n    get \"/api/v1/test\", params: {}, headers: {\n      \"Authorization\" => \"Bearer #{access_token.token}\"\n    }\n\n    # Should not be unauthorized when token is valid\n    assert_response :success\n    response_body = JSON.parse(response.body)\n    assert_equal \"test_success\", response_body[\"message\"]\n    assert_equal @user.email, response_body[\"user\"]\n  end\n\n  test \"should reject invalid access token\" do\n    get \"/api/v1/test\", params: {}, headers: {\n      \"Authorization\" => \"Bearer invalid_token\"\n    }\n\n    assert_response :unauthorized\n    response_body = JSON.parse(response.body)\n    assert_equal \"unauthorized\", response_body[\"error\"]\n  end\n\n  test \"should authenticate with valid API key\" do\n    get \"/api/v1/test\", params: {}, headers: {\n      \"X-Api-Key\" => @plain_api_key\n    }\n\n    assert_response :success\n    response_body = JSON.parse(response.body)\n    assert_equal \"test_success\", response_body[\"message\"]\n    assert_equal @user.email, response_body[\"user\"]\n  end\n\n  test \"should reject invalid API key\" do\n    get \"/api/v1/test\", params: {}, headers: {\n      \"X-Api-Key\" => \"invalid_api_key\"\n    }\n\n    assert_response :unauthorized\n    response_body = JSON.parse(response.body)\n    assert_equal \"unauthorized\", response_body[\"error\"]\n    assert_includes response_body[\"message\"], \"Access token or API key\"\n  end\n\n  test \"should reject expired API key\" do\n    @api_key.update!(expires_at: 1.day.ago)\n\n    get \"/api/v1/test\", params: {}, headers: {\n      \"X-Api-Key\" => @plain_api_key\n    }\n\n    assert_response :unauthorized\n    response_body = JSON.parse(response.body)\n    assert_equal \"unauthorized\", response_body[\"error\"]\n  end\n\n  test \"should reject revoked API key\" do\n    @api_key.revoke!\n\n    get \"/api/v1/test\", params: {}, headers: {\n      \"X-Api-Key\" => @plain_api_key\n    }\n\n    assert_response :unauthorized\n    response_body = JSON.parse(response.body)\n    assert_equal \"unauthorized\", response_body[\"error\"]\n  end\n\n  test \"should update last_used_at when API key is used\" do\n    original_time = @api_key.last_used_at\n\n    get \"/api/v1/test\", params: {}, headers: {\n      \"X-Api-Key\" => @plain_api_key\n    }\n\n    assert_response :success\n    @api_key.reload\n    assert_not_equal original_time, @api_key.last_used_at\n    assert @api_key.last_used_at > (original_time || Time.at(0))\n  end\n\n  test \"should prioritize OAuth over API key when both are provided\" do\n    access_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read\"\n    )\n\n    # Capture log output to verify OAuth is used\n    logs = capture_log do\n      get \"/api/v1/test\", params: {}, headers: {\n        \"Authorization\" => \"Bearer #{access_token.token}\",\n        \"X-Api-Key\" => @plain_api_key\n      }\n    end\n\n    assert_response :success\n    assert_includes logs, \"OAuth Token\"\n    assert_not_includes logs, \"API Key:\"\n  end\n\n  test \"should provide current_scopes for API key authentication\" do\n    get \"/api/v1/test_scope_required\", params: {}, headers: {\n      \"X-Api-Key\" => @plain_api_key\n    }\n\n    assert_response :success\n    response_body = JSON.parse(response.body)\n    assert_equal \"scope_authorized\", response_body[\"message\"]\n    assert_includes response_body[\"scopes\"], \"read_write\"\n  end\n\n  test \"should authorize API key with required scope\" do\n    get \"/api/v1/test_scope_required\", params: {}, headers: {\n      \"X-Api-Key\" => @plain_api_key\n    }\n\n    assert_response :success\n    response_body = JSON.parse(response.body)\n    assert_equal \"scope_authorized\", response_body[\"message\"]\n    assert_equal \"write\", response_body[\"required_scope\"]\n  end\n\n  test \"should reject API key without required scope\" do\n    # Revoke existing API key and create one with limited scopes\n    @api_key.revoke!\n    limited_api_key = ApiKey.create!(\n      user: @user,\n      name: \"Limited API Key\",\n      display_key: \"limited_key_#{SecureRandom.hex(8)}\",\n      scopes: [ \"read\" ]  # Only read scope\n    )\n\n    get \"/api/v1/test_scope_required\", params: {}, headers: {\n      \"X-Api-Key\" => limited_api_key.display_key\n    }\n\n    assert_response :forbidden\n    response_body = JSON.parse(response.body)\n    assert_equal \"insufficient_scope\", response_body[\"error\"]\n    assert_includes response_body[\"message\"], \"write\"\n  end\n\n  test \"should authorize API key with multiple required scopes\" do\n    get \"/api/v1/test_multiple_scopes_required\", params: {}, headers: {\n      \"X-Api-Key\" => @plain_api_key\n    }\n\n    assert_response :success\n    response_body = JSON.parse(response.body)\n    assert_equal \"read_scope_authorized\", response_body[\"message\"]\n    assert_includes response_body[\"scopes\"], \"read_write\"\n  end\n\n  test \"should reject API key missing one of multiple required scopes\" do\n    # The multiple scopes test now just checks for \"read\" permission,\n    # so we need to create an API key without any scopes at all.\n    # First revoke the existing key, then create one with empty scopes array won't work due to validation.\n    # Instead, we'll test by trying to access the write endpoint with a read-only key.\n    @api_key.revoke!\n\n    read_only_key = ApiKey.create!(\n      user: @user,\n      name: \"Read Only API Key\",\n      display_key: \"read_only_key_#{SecureRandom.hex(8)}\",\n      scopes: [ \"read\" ]  # Only read scope, no write\n    )\n\n    # Try to access the write-requiring endpoint with read-only key\n    get \"/api/v1/test_scope_required\", params: {}, headers: {\n      \"X-Api-Key\" => read_only_key.display_key\n    }\n\n    assert_response :forbidden\n    response_body = JSON.parse(response.body)\n    assert_equal \"insufficient_scope\", response_body[\"error\"]\n  end\n\n  test \"should log API access with API key information\" do\n    logs = capture_log do\n      get \"/api/v1/test\", params: {}, headers: {\n        \"X-Api-Key\" => @plain_api_key\n      }\n    end\n\n    assert_includes logs, \"API Request\"\n    assert_includes logs, \"GET /api/v1/test\"\n    assert_includes logs, @user.email\n  end\n\n  test \"should provide current_resource_owner method\" do\n    # This will be tested through the test controller once implemented\n    skip \"Will test via test controller implementation\"\n  end\n\n  test \"should handle ActiveRecord::RecordNotFound errors\" do\n    access_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read\"\n    )\n\n    # This will trigger a not found error in the test controller\n    get \"/api/v1/test_not_found\", params: {}, headers: {\n      \"Authorization\" => \"Bearer #{access_token.token}\"\n    }\n\n    assert_response :not_found\n    response_body = JSON.parse(response.body)\n    assert_equal \"record_not_found\", response_body[\"error\"]\n  end\n\n  test \"should log API access\" do\n    access_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read\"\n    )\n\n    # Capture log output\n    logs = capture_log do\n      get \"/api/v1/test\", params: {}, headers: {\n        \"Authorization\" => \"Bearer #{access_token.token}\"\n      }\n    end\n\n    assert_includes logs, \"API Request\"\n    assert_includes logs, \"GET /api/v1/test\"\n    assert_includes logs, @user.email\n  end\n\n  test \"should enforce family-based access control\" do\n    # Create another family user\n    other_family = families(:dylan_family)\n    other_user = users(:family_member)\n    other_user.update!(family: other_family)\n\n    access_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: other_user.id,\n      scopes: \"read\"\n    )\n\n    # Try to access data from a different family\n    get \"/api/v1/test_family_access\", params: {}, headers: {\n      \"Authorization\" => \"Bearer #{access_token.token}\"\n    }\n\n    assert_response :forbidden\n    response_body = JSON.parse(response.body)\n    assert_equal \"forbidden\", response_body[\"error\"]\n  end\n\n  test \"should enforce family-based access control with API key\" do\n    # Create API key for a user in a different family\n    other_family = families(:dylan_family)\n    other_user = users(:family_member)\n    other_user.update!(family: other_family)\n    other_user.api_keys.destroy_all\n\n    other_user_api_key = ApiKey.create!(\n      user: other_user,\n      name: \"Other User API Key\",\n      display_key: \"other_user_key_#{SecureRandom.hex(8)}\",\n      scopes: [ \"read\" ]\n    )\n\n    # Try to access data from a different family\n    get \"/api/v1/test_family_access\", params: {}, headers: {\n      \"X-Api-Key\" => other_user_api_key.display_key\n    }\n\n    assert_response :forbidden\n    response_body = JSON.parse(response.body)\n    assert_equal \"forbidden\", response_body[\"error\"]\n  end\n\n  test \"should include rate limit headers on successful API key requests\" do\n    get \"/api/v1/test\", headers: { \"X-Api-Key\" => @plain_api_key }\n\n    assert_response :success\n    assert_not_nil response.headers[\"X-RateLimit-Limit\"]\n    assert_not_nil response.headers[\"X-RateLimit-Remaining\"]\n    assert_not_nil response.headers[\"X-RateLimit-Reset\"]\n\n    assert_equal \"100\", response.headers[\"X-RateLimit-Limit\"]\n    assert_equal \"99\", response.headers[\"X-RateLimit-Remaining\"]\n  end\n\n  test \"should increment rate limit count with each request\" do\n    # First request\n    get \"/api/v1/test\", headers: { \"X-Api-Key\" => @plain_api_key }\n    assert_response :success\n    assert_equal \"99\", response.headers[\"X-RateLimit-Remaining\"]\n\n    # Second request\n    get \"/api/v1/test\", headers: { \"X-Api-Key\" => @plain_api_key }\n    assert_response :success\n    assert_equal \"98\", response.headers[\"X-RateLimit-Remaining\"]\n  end\n\n  test \"should return 429 when rate limit exceeded\" do\n    # Make 100 requests to exhaust the rate limit\n    100.times do\n      get \"/api/v1/test\", headers: { \"X-Api-Key\" => @plain_api_key }\n      assert_response :success\n    end\n\n    # 101st request should be rate limited\n    get \"/api/v1/test\", headers: { \"X-Api-Key\" => @plain_api_key }\n    assert_response :too_many_requests\n\n    response_body = JSON.parse(response.body)\n    assert_equal \"rate_limit_exceeded\", response_body[\"error\"]\n    assert_includes response_body[\"message\"], \"Rate limit exceeded\"\n\n    # Check response headers\n    assert_equal \"100\", response.headers[\"X-RateLimit-Limit\"]\n    assert_equal \"0\", response.headers[\"X-RateLimit-Remaining\"]\n    assert_not_nil response.headers[\"X-RateLimit-Reset\"]\n    assert_not_nil response.headers[\"Retry-After\"]\n  end\n\n  test \"should not apply rate limiting to OAuth requests\" do\n    # This would need to be implemented based on your OAuth setup\n    # For now, just verify that requests without API keys don't trigger rate limiting\n    get \"/api/v1/test\"\n    assert_response :unauthorized\n\n    # Should not have rate limit headers for unauthorized requests\n    assert_nil response.headers[\"X-RateLimit-Limit\"]\n  end\n\n  test \"should provide detailed rate limit information in 429 response\" do\n    # Exhaust the rate limit\n    100.times do\n      get \"/api/v1/test\", headers: { \"X-Api-Key\" => @plain_api_key }\n    end\n\n    # Make the rate-limited request\n    get \"/api/v1/test\", headers: { \"X-Api-Key\" => @plain_api_key }\n    assert_response :too_many_requests\n\n    response_body = JSON.parse(response.body)\n    assert_equal \"rate_limit_exceeded\", response_body[\"error\"]\n    assert response_body[\"details\"][\"limit\"] == 100\n    assert response_body[\"details\"][\"current\"] >= 100\n    assert response_body[\"details\"][\"reset_in_seconds\"] > 0\n  end\n\n  test \"rate limiting should be per API key\" do\n    # Create a second user for independent API keys\n    other_user = users(:family_member)\n    other_api_key = ApiKey.create!(\n      user: other_user,\n      name: \"Other Test API Key\",\n      scopes: [ \"read\" ],\n      display_key: \"other_rate_test_#{SecureRandom.hex(8)}\"\n    )\n\n    begin\n      # Make 50 requests with first API key\n      50.times do\n        get \"/api/v1/test\", headers: { \"X-Api-Key\" => @plain_api_key }\n        assert_response :success\n      end\n\n      # Should still be able to make requests with second API key\n      get \"/api/v1/test\", headers: { \"X-Api-Key\" => other_api_key.display_key }\n      assert_response :success\n      assert_equal \"99\", response.headers[\"X-RateLimit-Remaining\"]\n    ensure\n      Redis.new.del(\"api_rate_limit:#{other_api_key.id}\")\n      other_api_key.destroy\n    end\n  end\n\nprivate\n\n  def capture_log(&block)\n    io = StringIO.new\n    original_logger = Rails.logger\n    Rails.logger = Logger.new(io)\n\n    yield\n\n    io.string\n  ensure\n    Rails.logger = original_logger\n  end\nend\n"
  },
  {
    "path": "test/controllers/api/v1/chats_controller_test.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nclass Api::V1::ChatsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_admin)\n    @user.update!(ai_enabled: true)\n\n    @oauth_app = Doorkeeper::Application.create!(\n      name: \"Test API App\",\n      redirect_uri: \"https://example.com/callback\",\n      scopes: \"read write read_write\"\n    )\n\n    @read_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read\"\n    )\n\n    @write_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read_write\"\n    )\n\n    @chat = chats(:one)\n  end\n\n  test \"should require authentication\" do\n    get \"/api/v1/chats\"\n    assert_response :unauthorized\n  end\n\n  test \"should require AI to be enabled\" do\n    @user.update!(ai_enabled: false)\n\n    get \"/api/v1/chats\", headers: bearer_auth_header(@read_token)\n    assert_response :forbidden\n\n    response_body = JSON.parse(response.body)\n    assert_equal \"feature_disabled\", response_body[\"error\"]\n  end\n\n  test \"should list chats with read scope\" do\n    get \"/api/v1/chats\", headers: bearer_auth_header(@read_token)\n    assert_response :success\n\n    response_body = JSON.parse(response.body)\n    assert response_body[\"chats\"].is_a?(Array)\n    assert response_body[\"pagination\"].present?\n  end\n\n  test \"should show chat with messages\" do\n    get \"/api/v1/chats/#{@chat.id}\", headers: bearer_auth_header(@read_token)\n    assert_response :success\n\n    response_body = JSON.parse(response.body)\n    assert_equal @chat.id, response_body[\"id\"]\n    assert response_body[\"messages\"].is_a?(Array)\n  end\n\n  test \"should create chat with write scope\" do\n    assert_difference \"Chat.count\" do\n      post \"/api/v1/chats\",\n        params: { title: \"New chat\", message: \"Hello AI\" },\n        headers: bearer_auth_header(@write_token)\n    end\n\n    assert_response :created\n    response_body = JSON.parse(response.body)\n    assert_equal \"New chat\", response_body[\"title\"]\n  end\n\n  test \"should not create chat with read scope\" do\n    post \"/api/v1/chats\",\n      params: { title: \"New chat\" },\n      headers: bearer_auth_header(@read_token)\n\n    assert_response :forbidden\n  end\n\n  test \"should update chat\" do\n    patch \"/api/v1/chats/#{@chat.id}\",\n      params: { title: \"Updated title\" },\n      headers: bearer_auth_header(@write_token)\n\n    assert_response :success\n    response_body = JSON.parse(response.body)\n    assert_equal \"Updated title\", response_body[\"title\"]\n  end\n\n  test \"should delete chat\" do\n    assert_difference \"Chat.count\", -1 do\n      delete \"/api/v1/chats/#{@chat.id}\", headers: bearer_auth_header(@write_token)\n    end\n\n    assert_response :no_content\n  end\n\n  test \"should not access other user's chat\" do\n    other_user = users(:family_member)\n    other_user.update!(family: families(:empty))\n    other_chat = chats(:two)\n    other_chat.update!(user: other_user)\n\n    get \"/api/v1/chats/#{other_chat.id}\", headers: bearer_auth_header(@read_token)\n    assert_response :not_found\n  end\n\n  test \"should support API key authentication\" do\n    # Remove any existing API keys for this user\n    @user.api_keys.destroy_all\n\n    plain_key = ApiKey.generate_secure_key\n    api_key = @user.api_keys.build(\n      name: \"Test API Key\",\n      scopes: [ \"read_write\" ]\n    )\n    api_key.key = plain_key\n    api_key.save!\n\n    get \"/api/v1/chats\", headers: { \"X-Api-Key\" => plain_key }\n    assert_response :success\n  end\n\n  private\n\n    def bearer_auth_header(token)\n      { \"Authorization\" => \"Bearer #{token.token}\" }\n    end\nend\n"
  },
  {
    "path": "test/controllers/api/v1/messages_controller_test.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nclass Api::V1::MessagesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_admin)\n    @user.update!(ai_enabled: true)\n\n    @oauth_app = Doorkeeper::Application.create!(\n      name: \"Test API App\",\n      redirect_uri: \"https://example.com/callback\",\n      scopes: \"read write read_write\"\n    )\n\n    @write_token = Doorkeeper::AccessToken.create!(\n      application: @oauth_app,\n      resource_owner_id: @user.id,\n      scopes: \"read_write\"\n    )\n\n    @chat = chats(:one)\n  end\n\n  test \"should require authentication\" do\n    post \"/api/v1/chats/#{@chat.id}/messages\"\n    assert_response :unauthorized\n  end\n\n  test \"should require AI to be enabled\" do\n    @user.update!(ai_enabled: false)\n\n    post \"/api/v1/chats/#{@chat.id}/messages\",\n      params: { content: \"Hello\" },\n      headers: bearer_auth_header(@write_token)\n    assert_response :forbidden\n  end\n\n  test \"should create message with write scope\" do\n    assert_difference \"Message.count\" do\n      post \"/api/v1/chats/#{@chat.id}/messages\",\n        params: { content: \"Test message\", model: \"gpt-4\" },\n        headers: bearer_auth_header(@write_token)\n    end\n\n    assert_response :created\n    response_body = JSON.parse(response.body)\n    assert_equal \"Test message\", response_body[\"content\"]\n    assert_equal \"user_message\", response_body[\"type\"]\n    assert_equal \"pending\", response_body[\"ai_response_status\"]\n  end\n\n  test \"should enqueue assistant response job\" do\n    assert_enqueued_with(job: AssistantResponseJob) do\n      post \"/api/v1/chats/#{@chat.id}/messages\",\n        params: { content: \"Test message\" },\n        headers: bearer_auth_header(@write_token)\n    end\n  end\n\n  test \"should retry last assistant message\" do\n    skip \"Retry functionality needs debugging\"\n\n    # Create an assistant message to retry\n    assistant_message = @chat.messages.create!(\n      type: \"AssistantMessage\",\n      content: \"Previous response\",\n      ai_model: \"gpt-4\"\n    )\n\n    assert_enqueued_with(job: AssistantResponseJob) do\n      post \"/api/v1/chats/#{@chat.id}/messages/retry\",\n        headers: bearer_auth_header(@write_token)\n    end\n\n    assert_response :accepted\n    response_body = JSON.parse(response.body)\n    assert response_body[\"message_id\"].present?\n  end\n\n  test \"should not retry if no assistant message exists\" do\n    # Remove all assistant messages\n    @chat.messages.where(type: \"AssistantMessage\").destroy_all\n\n    post \"/api/v1/chats/#{@chat.id}/messages/retry.json\",\n      headers: bearer_auth_header(@write_token)\n\n    assert_response :unprocessable_entity\n    response_body = JSON.parse(response.body)\n    assert_equal \"No assistant message to retry\", response_body[\"error\"]\n  end\n\n  test \"should not access messages in other user's chat\" do\n    other_user = users(:family_member)\n    other_user.update!(family: families(:empty))\n    other_chat = chats(:two)\n    other_chat.update!(user: other_user)\n\n    post \"/api/v1/chats/#{other_chat.id}/messages\",\n      params: { content: \"Test\" },\n      headers: bearer_auth_header(@write_token)\n\n    assert_response :not_found\n  end\n\n  private\n\n    def bearer_auth_header(token)\n      { \"Authorization\" => \"Bearer #{token.token}\" }\n    end\nend\n"
  },
  {
    "path": "test/controllers/api/v1/transactions_controller_test.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nclass Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_admin)\n    @family = @user.family\n    @account = @family.accounts.first\n    @transaction = @family.transactions.first\n\n    # Destroy existing active API keys to avoid validation errors\n    @user.api_keys.active.destroy_all\n\n    # Create fresh API keys instead of using fixtures to avoid parallel test conflicts (rate limiting in test)\n    @api_key = ApiKey.create!(\n      user: @user,\n      name: \"Test Read-Write Key\",\n      scopes: [ \"read_write\" ],\n      display_key: \"test_rw_#{SecureRandom.hex(8)}\"\n    )\n\n    @read_only_api_key = ApiKey.create!(\n      user: @user,\n      name: \"Test Read-Only Key\",\n      scopes: [ \"read\" ],\n      display_key: \"test_ro_#{SecureRandom.hex(8)}\",\n      source: \"mobile\"  # Use different source to allow multiple keys\n    )\n\n    # Clear any existing rate limit data\n    Redis.new.del(\"api_rate_limit:#{@api_key.id}\")\n    Redis.new.del(\"api_rate_limit:#{@read_only_api_key.id}\")\n  end\n\n  # INDEX action tests\n  test \"should get index with valid API key\" do\n    get api_v1_transactions_url, headers: api_headers(@api_key)\n    assert_response :success\n\n    response_data = JSON.parse(response.body)\n    assert response_data.key?(\"transactions\")\n    assert response_data.key?(\"pagination\")\n    assert response_data[\"pagination\"].key?(\"page\")\n    assert response_data[\"pagination\"].key?(\"per_page\")\n    assert response_data[\"pagination\"].key?(\"total_count\")\n    assert response_data[\"pagination\"].key?(\"total_pages\")\n  end\n\n  test \"should get index with read-only API key\" do\n    get api_v1_transactions_url, headers: api_headers(@read_only_api_key)\n    assert_response :success\n  end\n\n  test \"should filter transactions by account_id\" do\n    get api_v1_transactions_url, params: { account_id: @account.id }, headers: api_headers(@api_key)\n    assert_response :success\n\n    response_data = JSON.parse(response.body)\n    response_data[\"transactions\"].each do |transaction|\n      assert_equal @account.id, transaction[\"account\"][\"id\"]\n    end\n  end\n\n  test \"should filter transactions by date range\" do\n    start_date = 1.month.ago.to_date\n    end_date = Date.current\n\n    get api_v1_transactions_url,\n        params: { start_date: start_date, end_date: end_date },\n        headers: api_headers(@api_key)\n    assert_response :success\n\n    response_data = JSON.parse(response.body)\n    response_data[\"transactions\"].each do |transaction|\n      transaction_date = Date.parse(transaction[\"date\"])\n      assert transaction_date >= start_date\n      assert transaction_date <= end_date\n    end\n  end\n\n  test \"should search transactions\" do\n    # Create a transaction with a specific name for testing\n    entry = @account.entries.create!(\n      name: \"Test Coffee Purchase\",\n      amount: 5.50,\n      currency: \"USD\",\n      date: Date.current,\n      entryable: Transaction.new\n    )\n\n    get api_v1_transactions_url,\n        params: { search: \"Coffee\" },\n        headers: api_headers(@api_key)\n    assert_response :success\n\n    response_data = JSON.parse(response.body)\n    found_transaction = response_data[\"transactions\"].find { |t| t[\"id\"] == entry.transaction.id }\n    assert_not_nil found_transaction, \"Should find the coffee transaction\"\n  end\n\n  test \"should paginate transactions\" do\n    get api_v1_transactions_url,\n        params: { page: 1, per_page: 5 },\n        headers: api_headers(@api_key)\n    assert_response :success\n\n    response_data = JSON.parse(response.body)\n    assert response_data[\"transactions\"].size <= 5\n    assert_equal 1, response_data[\"pagination\"][\"page\"]\n    assert_equal 5, response_data[\"pagination\"][\"per_page\"]\n  end\n\n  test \"should reject index request without API key\" do\n    get api_v1_transactions_url\n    assert_response :unauthorized\n  end\n\n  test \"should reject index request with invalid API key\" do\n    get api_v1_transactions_url, headers: { \"X-Api-Key\" => \"invalid-key\" }\n    assert_response :unauthorized\n  end\n\n  # SHOW action tests\n  test \"should show transaction with valid API key\" do\n    get api_v1_transaction_url(@transaction), headers: api_headers(@api_key)\n    assert_response :success\n\n    response_data = JSON.parse(response.body)\n    assert_equal @transaction.id, response_data[\"id\"]\n    assert response_data.key?(\"name\")\n    assert response_data.key?(\"amount\")\n    assert response_data.key?(\"date\")\n    assert response_data.key?(\"account\")\n  end\n\n  test \"should show transaction with read-only API key\" do\n    get api_v1_transaction_url(@transaction), headers: api_headers(@read_only_api_key)\n    assert_response :success\n  end\n\n  test \"should return 404 for non-existent transaction\" do\n    get api_v1_transaction_url(999999), headers: api_headers(@api_key)\n    assert_response :not_found\n  end\n\n  test \"should reject show request without API key\" do\n    get api_v1_transaction_url(@transaction)\n    assert_response :unauthorized\n  end\n\n  # CREATE action tests\n  test \"should create transaction with valid parameters\" do\n    transaction_params = {\n      transaction: {\n        account_id: @account.id,\n        name: \"Test Transaction\",\n        amount: 25.00,\n        date: Date.current,\n        currency: \"USD\",\n        nature: \"expense\"\n      }\n    }\n\n    assert_difference(\"@account.entries.count\", 1) do\n      post api_v1_transactions_url,\n           params: transaction_params,\n           headers: api_headers(@api_key)\n    end\n\n    assert_response :created\n    response_data = JSON.parse(response.body)\n    assert_equal \"Test Transaction\", response_data[\"name\"]\n    assert_equal @account.id, response_data[\"account\"][\"id\"]\n  end\n\n  test \"should reject create with read-only API key\" do\n    transaction_params = {\n      transaction: {\n        account_id: @account.id,\n        name: \"Test Transaction\",\n        amount: 25.00,\n        date: Date.current\n      }\n    }\n\n    post api_v1_transactions_url,\n         params: transaction_params,\n         headers: api_headers(@read_only_api_key)\n    assert_response :forbidden\n  end\n\n  test \"should reject create with invalid parameters\" do\n    transaction_params = {\n      transaction: {\n        # Missing required fields\n        name: \"Test Transaction\"\n      }\n    }\n\n    post api_v1_transactions_url,\n         params: transaction_params,\n         headers: api_headers(@api_key)\n    assert_response :unprocessable_entity\n  end\n\n  test \"should reject create without API key\" do\n    post api_v1_transactions_url, params: { transaction: { name: \"Test\" } }\n    assert_response :unauthorized\n  end\n\n  # UPDATE action tests\n  test \"should update transaction with valid parameters\" do\n    update_params = {\n      transaction: {\n        name: \"Updated Transaction Name\",\n        amount: 30.00\n      }\n    }\n\n    put api_v1_transaction_url(@transaction),\n        params: update_params,\n        headers: api_headers(@api_key)\n    assert_response :success\n\n    response_data = JSON.parse(response.body)\n    assert_equal \"Updated Transaction Name\", response_data[\"name\"]\n  end\n\n  test \"should reject update with read-only API key\" do\n    update_params = {\n      transaction: {\n        name: \"Updated Transaction Name\"\n      }\n    }\n\n    put api_v1_transaction_url(@transaction),\n        params: update_params,\n        headers: api_headers(@read_only_api_key)\n    assert_response :forbidden\n  end\n\n  test \"should reject update for non-existent transaction\" do\n    put api_v1_transaction_url(999999),\n        params: { transaction: { name: \"Test\" } },\n        headers: api_headers(@api_key)\n    assert_response :not_found\n  end\n\n  test \"should reject update without API key\" do\n    put api_v1_transaction_url(@transaction), params: { transaction: { name: \"Test\" } }\n    assert_response :unauthorized\n  end\n\n  # DESTROY action tests\n  test \"should destroy transaction\" do\n  entry_to_delete = @account.entries.create!(\n    name: \"Transaction to Delete\",\n    amount: 10.00,\n    currency: \"USD\",\n    date: Date.current,\n    entryable: Transaction.new\n  )\n  transaction_to_delete = entry_to_delete.transaction\n\n  assert_difference(\"@account.entries.count\", -1) do\n    delete api_v1_transaction_url(transaction_to_delete), headers: api_headers(@api_key)\n  end\n\n  assert_response :success\n  response_data = JSON.parse(response.body)\n  assert response_data.key?(\"message\")\nend\n\n  test \"should reject destroy with read-only API key\" do\n    delete api_v1_transaction_url(@transaction), headers: api_headers(@read_only_api_key)\n    assert_response :forbidden\n  end\n\n  test \"should reject destroy for non-existent transaction\" do\n    delete api_v1_transaction_url(999999), headers: api_headers(@api_key)\n    assert_response :not_found\n  end\n\n  test \"should reject destroy without API key\" do\n    delete api_v1_transaction_url(@transaction)\n    assert_response :unauthorized\n  end\n\n  # JSON structure tests\n  test \"transaction JSON should have expected structure\" do\n    get api_v1_transaction_url(@transaction), headers: api_headers(@api_key)\n    assert_response :success\n\n    transaction_data = JSON.parse(response.body)\n\n    # Basic fields\n    assert transaction_data.key?(\"id\")\n    assert transaction_data.key?(\"date\")\n    assert transaction_data.key?(\"amount\")\n    assert transaction_data.key?(\"currency\")\n    assert transaction_data.key?(\"name\")\n    assert transaction_data.key?(\"classification\")\n    assert transaction_data.key?(\"created_at\")\n    assert transaction_data.key?(\"updated_at\")\n\n    # Account information\n    assert transaction_data.key?(\"account\")\n    assert transaction_data[\"account\"].key?(\"id\")\n    assert transaction_data[\"account\"].key?(\"name\")\n    assert transaction_data[\"account\"].key?(\"account_type\")\n\n    # Optional fields should be present (even if nil)\n    assert transaction_data.key?(\"category\")\n    assert transaction_data.key?(\"merchant\")\n    assert transaction_data.key?(\"tags\")\n    assert transaction_data.key?(\"transfer\")\n    assert transaction_data.key?(\"notes\")\n  end\n\n  test \"transactions with transfers should include transfer information\" do\n    # Create a transfer between two accounts to test transfer rendering\n    from_account = @family.accounts.create!(\n      name: \"Transfer From Account\",\n      balance: 1000,\n      currency: \"USD\",\n      accountable: Depository.new\n    )\n\n    to_account = @family.accounts.create!(\n      name: \"Transfer To Account\",\n      balance: 0,\n      currency: \"USD\",\n      accountable: Depository.new\n    )\n\n    transfer = Transfer::Creator.new(\n      family: @family,\n      source_account_id: from_account.id,\n      destination_account_id: to_account.id,\n      date: Date.current,\n      amount: 100\n    ).create\n\n    get api_v1_transaction_url(transfer.inflow_transaction), headers: api_headers(@api_key)\n    assert_response :success\n\n    transaction_data = JSON.parse(response.body)\n    assert_not_nil transaction_data[\"transfer\"]\n    assert transaction_data[\"transfer\"].key?(\"id\")\n    assert transaction_data[\"transfer\"].key?(\"amount\")\n    assert transaction_data[\"transfer\"].key?(\"currency\")\n    assert transaction_data[\"transfer\"].key?(\"other_account\")\n  end\n\n  private\n\n    def api_headers(api_key)\n      { \"X-Api-Key\" => api_key.display_key }\n    end\nend\n"
  },
  {
    "path": "test/controllers/api/v1/usage_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Api::V1::UsageControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_admin)\n    # Destroy any existing active API keys for this user\n    @user.api_keys.active.destroy_all\n\n    @api_key = ApiKey.create!(\n      user: @user,\n      name: \"Test API Key\",\n      scopes: [ \"read\" ],\n      display_key: \"usage_test_#{SecureRandom.hex(8)}\"\n    )\n\n    # Clear any existing rate limit data\n    Redis.new.del(\"api_rate_limit:#{@api_key.id}\")\n  end\n\n  teardown do\n    # Clean up Redis data after each test\n    Redis.new.del(\"api_rate_limit:#{@api_key.id}\")\n  end\n\n  test \"should return usage information for API key authentication\" do\n    # Make a few requests to generate some usage\n    3.times do\n      get \"/api/v1/test\", headers: { \"X-Api-Key\" => @api_key.display_key }\n      assert_response :success\n    end\n\n    # Now check usage\n    get \"/api/v1/usage\", headers: { \"X-Api-Key\" => @api_key.display_key }\n    assert_response :success\n\n    response_body = JSON.parse(response.body)\n\n    # Check API key information\n    assert_equal \"Test API Key\", response_body[\"api_key\"][\"name\"]\n    assert_equal [ \"read\" ], response_body[\"api_key\"][\"scopes\"]\n    assert_not_nil response_body[\"api_key\"][\"last_used_at\"]\n    assert_not_nil response_body[\"api_key\"][\"created_at\"]\n\n    # Check rate limit information\n    assert_equal \"standard\", response_body[\"rate_limit\"][\"tier\"]\n    assert_equal 100, response_body[\"rate_limit\"][\"limit\"]\n    assert_equal 4, response_body[\"rate_limit\"][\"current_count\"] # 3 test requests + 1 usage request\n    assert_equal 96, response_body[\"rate_limit\"][\"remaining\"]\n    assert response_body[\"rate_limit\"][\"reset_in_seconds\"] > 0\n    assert_not_nil response_body[\"rate_limit\"][\"reset_at\"]\n  end\n\n  test \"should require read scope for usage endpoint\" do\n    # Create an API key without read scope (this shouldn't be possible with current validations, but let's test)\n    api_key_no_read = ApiKey.new(\n      user: @user,\n      name: \"No Read Key\",\n      scopes: [],\n      display_key: \"no_read_key_#{SecureRandom.hex(8)}\"\n    )\n    # Skip validations to create invalid key for testing\n    api_key_no_read.save(validate: false)\n\n    begin\n      get \"/api/v1/usage\", headers: { \"X-Api-Key\" => api_key_no_read.display_key }\n      assert_response :forbidden\n\n      response_body = JSON.parse(response.body)\n      assert_equal \"insufficient_scope\", response_body[\"error\"]\n    ensure\n      Redis.new.del(\"api_rate_limit:#{api_key_no_read.id}\")\n      api_key_no_read.destroy\n    end\n  end\n\n  test \"should return correct message for OAuth authentication\" do\n    # This test would need OAuth setup, but for now we can mock it\n    # For the current implementation, we'll test what happens with no authentication\n    get \"/api/v1/usage\"\n    assert_response :unauthorized\n  end\n\n  test \"should update usage count when accessing usage endpoint\" do\n    # Check initial state\n    get \"/api/v1/usage\", headers: { \"X-Api-Key\" => @api_key.display_key }\n    assert_response :success\n\n    response_body = JSON.parse(response.body)\n    first_count = response_body[\"rate_limit\"][\"current_count\"]\n\n    # Make another usage request\n    get \"/api/v1/usage\", headers: { \"X-Api-Key\" => @api_key.display_key }\n    assert_response :success\n\n    response_body = JSON.parse(response.body)\n    second_count = response_body[\"rate_limit\"][\"current_count\"]\n\n    assert_equal first_count + 1, second_count\n  end\n\n  test \"should include rate limit headers in usage response\" do\n    get \"/api/v1/usage\", headers: { \"X-Api-Key\" => @api_key.display_key }\n    assert_response :success\n\n    assert_not_nil response.headers[\"X-RateLimit-Limit\"]\n    assert_not_nil response.headers[\"X-RateLimit-Remaining\"]\n    assert_not_nil response.headers[\"X-RateLimit-Reset\"]\n\n    assert_equal \"100\", response.headers[\"X-RateLimit-Limit\"]\n    assert_equal \"99\", response.headers[\"X-RateLimit-Remaining\"]\n  end\n\n  test \"should work correctly when approaching rate limit\" do\n    # Make 98 requests to get close to the limit\n    98.times do\n      get \"/api/v1/test\", headers: { \"X-Api-Key\" => @api_key.display_key }\n      assert_response :success\n    end\n\n    # Check usage - this should be request 99\n    get \"/api/v1/usage\", headers: { \"X-Api-Key\" => @api_key.display_key }\n    assert_response :success\n\n    response_body = JSON.parse(response.body)\n    assert_equal 99, response_body[\"rate_limit\"][\"current_count\"]\n    assert_equal 1, response_body[\"rate_limit\"][\"remaining\"]\n\n    # One more request should hit the limit\n    get \"/api/v1/test\", headers: { \"X-Api-Key\" => @api_key.display_key }\n    assert_response :success\n\n    # Now we should be rate limited\n    get \"/api/v1/usage\", headers: { \"X-Api-Key\" => @api_key.display_key }\n    assert_response :too_many_requests\n  end\nend\n"
  },
  {
    "path": "test/controllers/categories_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass CategoriesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in users(:family_admin)\n    @transaction = transactions :one\n  end\n\n  test \"index\" do\n    get categories_url\n    assert_response :success\n  end\n\n  test \"new\" do\n    get new_category_url\n    assert_response :success\n  end\n\n  test \"create\" do\n    color = Category::COLORS.sample\n\n    assert_difference \"Category.count\", +1 do\n      post categories_url, params: {\n        category: {\n          name: \"New Category\",\n          color: color } }\n    end\n\n    new_category = Category.order(:created_at).last\n\n    assert_redirected_to categories_url\n    assert_equal \"New Category\", new_category.name\n    assert_equal color, new_category.color\n  end\n\n  test \"create fails if name is not unique\" do\n    assert_no_difference \"Category.count\" do\n      post categories_url, params: {\n        category: {\n          name: categories(:food_and_drink).name,\n          color: Category::COLORS.sample } }\n    end\n\n    assert_response :unprocessable_entity\n  end\n\n  test \"create and assign to transaction\" do\n    color = Category::COLORS.sample\n\n    assert_difference \"Category.count\", +1 do\n      post categories_url, params: {\n        transaction_id: @transaction.id,\n        category: {\n          name: \"New Category\",\n          color: color } }\n    end\n\n    new_category = Category.order(:created_at).last\n\n    assert_redirected_to categories_url\n    assert_equal \"New Category\", new_category.name\n    assert_equal color, new_category.color\n    assert_equal @transaction.reload.category, new_category\n  end\n\n  test \"edit\" do\n    get edit_category_url(categories(:food_and_drink))\n    assert_response :success\n  end\n\n  test \"update\" do\n    new_color = Category::COLORS.without(categories(:income).color).sample\n\n    assert_changes -> { categories(:income).name }, to: \"New Name\" do\n      assert_changes -> { categories(:income).reload.color }, to: new_color do\n        patch category_url(categories(:income)), params: {\n          category: {\n            name: \"New Name\",\n            color: new_color } }\n      end\n    end\n\n    assert_redirected_to categories_url\n  end\n\n  test \"bootstrap\" do\n    assert_difference \"Category.count\", 12 do\n      post bootstrap_categories_url\n    end\n\n    assert_redirected_to categories_url\n  end\nend\n"
  },
  {
    "path": "test/controllers/category/deletions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Category::DeletionsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in users(:family_admin)\n    @category = categories(:food_and_drink)\n  end\n\n  test \"new\" do\n    get new_category_deletion_url(@category)\n    assert_response :success\n  end\n\n  test \"create with replacement\" do\n    replacement_category = categories(:income)\n\n    assert_not_empty @category.transactions\n\n    assert_difference \"Category.count\", -1 do\n      assert_difference \"replacement_category.transactions.count\", @category.transactions.count do\n        post category_deletions_url(@category),\n          params: { replacement_category_id: replacement_category.id }\n      end\n    end\n\n    assert_redirected_to transactions_url\n  end\n\n  test \"create without replacement\" do\n    assert_not_empty @category.transactions\n\n    assert_difference \"Category.count\", -1 do\n      assert_difference \"Transaction.where(category: nil).count\", @category.transactions.count do\n        post category_deletions_url(@category)\n      end\n    end\n\n    assert_redirected_to transactions_url\n  end\nend\n"
  },
  {
    "path": "test/controllers/chats_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass ChatsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_admin)\n    @family = families(:dylan_family)\n    sign_in @user\n  end\n\n  test \"gets index\" do\n    get chats_url\n    assert_response :success\n  end\n\n  test \"creates chat\" do\n    assert_difference(\"Chat.count\") do\n      post chats_url, params: { chat: { content: \"Hello\", ai_model: \"gpt-4.1\" } }\n    end\n\n    assert_redirected_to chat_path(Chat.order(created_at: :desc).first, thinking: true)\n  end\n\n  test \"shows chat\" do\n    get chat_url(chats(:one))\n    assert_response :success\n  end\n\n  test \"destroys chat\" do\n    assert_difference(\"Chat.count\", -1) do\n      delete chat_url(chats(:one))\n    end\n\n    assert_redirected_to chats_url\n  end\n\n  test \"should not allow access to other user's chats\" do\n    other_user = users(:family_member)\n    other_chat = Chat.create!(user: other_user, title: \"Other User's Chat\")\n\n    get chat_url(other_chat)\n    assert_response :not_found\n\n    delete chat_url(other_chat)\n    assert_response :not_found\n  end\nend\n"
  },
  {
    "path": "test/controllers/concerns/auto_sync_test.rb",
    "content": "require \"test_helper\"\n\nclass AutoSyncTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n    @family = @user.family\n\n    # Start fresh\n    Sync.destroy_all\n  end\n\n  test \"auto-syncs family if hasn't synced\" do\n    skip \"AutoSync functionality temporarily disabled\"\n    assert_difference \"Sync.count\", 1 do\n      get root_path\n    end\n  end\n\n  test \"auto-syncs family if hasn't synced in last 24 hours\" do\n    skip \"AutoSync functionality temporarily disabled\"\n    # If request comes in at beginning of day, but last sync was 1 hour ago (\"yesterday\"), we still sync\n    travel_to Time.current.beginning_of_day\n    last_sync_datetime = 1.hour.ago\n\n    Sync.create!(syncable: @family, created_at: last_sync_datetime, status: \"completed\")\n\n    assert_difference \"Sync.count\", 1 do\n      get root_path\n    end\n  end\n\n  test \"does not auto-sync if family has synced today already\" do\n    travel_to Time.current.end_of_day\n\n    last_created_sync_at = 23.hours.ago\n\n    Sync.create!(syncable: @family, created_at: last_created_sync_at, status: \"completed\")\n\n    assert_no_difference \"Sync.count\" do\n      get root_path\n    end\n  end\n\n  test \"does not auto-sync if preference is disabled\" do\n    @family.update!(auto_sync_on_login: false)\n\n    assert_no_difference \"Sync.count\" do\n      get root_path\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/concerns/onboardable_test.rb",
    "content": "require \"test_helper\"\n\nclass OnboardableTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:empty)\n    @user.family.subscription.destroy\n  end\n\n  test \"must complete onboarding before any other action\" do\n    @user.update!(onboarded_at: nil)\n\n    get root_path\n    assert_redirected_to onboarding_path\n  end\n\n  test \"must have subscription to visit dashboard\" do\n    @user.update!(onboarded_at: 1.day.ago)\n\n    get root_path\n    assert_redirected_to trial_onboarding_path\n  end\n\n  test \"onboarded subscribed user can visit dashboard\" do\n    @user.update!(onboarded_at: 1.day.ago)\n    @user.family.start_trial_subscription!\n\n    get root_path\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/credit_cards_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass CreditCardsControllerTest < ActionDispatch::IntegrationTest\n  include AccountableResourceInterfaceTest\n\n  setup do\n    sign_in @user = users(:family_admin)\n    @account = accounts(:credit_card)\n  end\n\n  test \"creates with credit card details\" do\n    assert_difference -> { Account.count } => 1,\n      -> { CreditCard.count } => 1,\n      -> { Valuation.count } => 1,\n      -> { Entry.count } => 1 do\n      post credit_cards_path, params: {\n        account: {\n          name: \"New Credit Card\",\n          balance: 1000,\n          currency: \"USD\",\n          accountable_type: \"CreditCard\",\n          accountable_attributes: {\n            available_credit: 5000,\n            minimum_payment: 25.51,\n            apr: 15.99,\n            expiration_date: 2.years.from_now.to_date,\n            annual_fee: 99\n          }\n        }\n      }\n    end\n\n    created_account = Account.order(:created_at).last\n\n    assert_equal \"New Credit Card\", created_account.name\n    assert_equal 1000, created_account.balance\n    assert_equal \"USD\", created_account.currency\n    assert_equal 5000, created_account.accountable.available_credit\n    assert_equal 25.51, created_account.accountable.minimum_payment\n    assert_equal 15.99, created_account.accountable.apr\n    assert_equal 2.years.from_now.to_date, created_account.accountable.expiration_date\n    assert_equal 99, created_account.accountable.annual_fee\n\n    assert_redirected_to created_account\n    assert_equal \"Credit card account created\", flash[:notice]\n    assert_enqueued_with(job: SyncJob)\n  end\n\n  test \"updates with credit card details\" do\n    assert_no_difference [ \"Account.count\", \"CreditCard.count\" ] do\n      patch credit_card_path(@account), params: {\n        account: {\n          name: \"Updated Credit Card\",\n          balance: 2000,\n          currency: \"USD\",\n          accountable_type: \"CreditCard\",\n          accountable_attributes: {\n            id: @account.accountable_id,\n            available_credit: 6000,\n            minimum_payment: 50,\n            apr: 14.99,\n            expiration_date: 3.years.from_now.to_date,\n            annual_fee: 0\n          }\n        }\n      }\n    end\n\n    @account.reload\n\n    assert_equal \"Updated Credit Card\", @account.name\n    assert_equal 2000, @account.balance\n    assert_equal 6000, @account.accountable.available_credit\n    assert_equal 50, @account.accountable.minimum_payment\n    assert_equal 14.99, @account.accountable.apr\n    assert_equal 3.years.from_now.to_date, @account.accountable.expiration_date\n    assert_equal 0, @account.accountable.annual_fee\n\n    assert_redirected_to @account\n    assert_equal \"Credit card account updated\", flash[:notice]\n    assert_enqueued_with(job: SyncJob)\n  end\nend\n"
  },
  {
    "path": "test/controllers/cryptos_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass CryptosControllerTest < ActionDispatch::IntegrationTest\n  include AccountableResourceInterfaceTest\n\n  setup do\n    sign_in @user = users(:family_admin)\n    @account = accounts(:crypto)\n  end\nend\n"
  },
  {
    "path": "test/controllers/currencies_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass CurrenciesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"should show currency\" do\n    get currency_url(id: \"EUR\", format: :json)\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/current_sessions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass CurrentSessionsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_admin)\n    sign_in @user\n  end\n\n  test \"can update the preferred tab for any namespace\" do\n    put current_session_url, params: { current_session: { tab_key: \"accounts_sidebar_tab\", tab_value: \"asset\" } }\n    assert_response :success\n    session = Session.order(updated_at: :desc).first\n    assert_equal \"asset\", session.get_preferred_tab(\"accounts_sidebar_tab\")\n  end\nend\n"
  },
  {
    "path": "test/controllers/depositories_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass DepositoriesControllerTest < ActionDispatch::IntegrationTest\n  include AccountableResourceInterfaceTest\n\n  setup do\n    sign_in @user = users(:family_admin)\n    @account = accounts(:depository)\n  end\nend\n"
  },
  {
    "path": "test/controllers/email_confirmations_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass EmailConfirmationsControllerTest < ActionDispatch::IntegrationTest\n  test \"should get confirm\" do\n    user = users(:new_email)\n    user.update!(unconfirmed_email: \"new@example.com\")\n    token = user.generate_token_for(:email_confirmation)\n\n    get new_email_confirmation_path(token: token)\n    assert_redirected_to new_session_path\n  end\nend\n"
  },
  {
    "path": "test/controllers/family_exports_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass FamilyExportsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @admin = users(:family_admin)\n    @non_admin = users(:family_member)\n    @family = @admin.family\n\n    sign_in @admin\n  end\n\n  test \"non-admin cannot access exports\" do\n    sign_in @non_admin\n\n    get new_family_export_path\n    assert_redirected_to root_path\n\n    post family_exports_path\n    assert_redirected_to root_path\n\n    get family_exports_path\n    assert_redirected_to root_path\n  end\n\n  test \"admin can view export modal\" do\n    get new_family_export_path\n    assert_response :success\n    assert_select \"h2\", text: \"Export your data\"\n  end\n\n  test \"admin can create export\" do\n    assert_enqueued_with(job: FamilyDataExportJob) do\n      post family_exports_path\n    end\n\n    assert_redirected_to settings_profile_path\n    assert_equal \"Export started. You'll be able to download it shortly.\", flash[:notice]\n\n    export = @family.family_exports.last\n    assert_equal \"pending\", export.status\n  end\n\n  test \"admin can view export list\" do\n    export1 = @family.family_exports.create!(status: \"completed\")\n    export2 = @family.family_exports.create!(status: \"processing\")\n\n    get family_exports_path\n    assert_response :success\n\n    assert_match export1.filename, response.body\n    assert_match \"Exporting...\", response.body\n  end\n\n  test \"admin can download completed export\" do\n    export = @family.family_exports.create!(status: \"completed\")\n    export.export_file.attach(\n      io: StringIO.new(\"test zip content\"),\n      filename: \"test.zip\",\n      content_type: \"application/zip\"\n    )\n\n    get download_family_export_path(export)\n    assert_redirected_to(/rails\\/active_storage/)\n  end\n\n  test \"cannot download incomplete export\" do\n    export = @family.family_exports.create!(status: \"processing\")\n\n    get download_family_export_path(export)\n    assert_redirected_to settings_profile_path\n    assert_equal \"Export not ready for download\", flash[:alert]\n  end\nend\n"
  },
  {
    "path": "test/controllers/family_merchants_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass FamilyMerchantsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n    @merchant = merchants(:netflix)\n  end\n\n  test \"index\" do\n    get family_merchants_path\n    assert_response :success\n  end\n\n  test \"new\" do\n    get new_family_merchant_path\n    assert_response :success\n  end\n\n  test \"should create merchant\" do\n    assert_difference(\"FamilyMerchant.count\") do\n      post family_merchants_url, params: { family_merchant: { name: \"new merchant\", color: \"#000000\" } }\n    end\n\n    assert_redirected_to family_merchants_path\n  end\n\n  test \"should update merchant\" do\n    patch family_merchant_url(@merchant), params: { family_merchant: { name: \"new name\", color: \"#000000\" } }\n    assert_redirected_to family_merchants_path\n  end\n\n  test \"should destroy merchant\" do\n    assert_difference(\"FamilyMerchant.count\", -1) do\n      delete family_merchant_url(@merchant)\n    end\n\n    assert_redirected_to family_merchants_path\n  end\nend\n"
  },
  {
    "path": "test/controllers/holdings_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass HoldingsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in users(:family_admin)\n    @account = accounts(:investment)\n    @holding = @account.holdings.first\n  end\n\n  test \"gets holdings\" do\n    get holdings_url(account_id: @account.id)\n    assert_response :success\n  end\n\n  test \"gets holding\" do\n    get holding_path(@holding)\n\n    assert_response :success\n  end\n\n  test \"destroys holding and associated entries\" do\n    assert_difference -> { Holding.count } => -1,\n                      -> { Entry.count } => -1 do\n      delete holding_path(@holding)\n    end\n\n    assert_redirected_to account_path(@holding.account)\n    assert_empty @holding.account.entries.where(entryable: @holding.account.trades.where(security: @holding.security))\n  end\nend\n"
  },
  {
    "path": "test/controllers/impersonation_sessions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass ImpersonationSessionsControllerTest < ActionDispatch::IntegrationTest\n  test \"impersonation session logs all activity for auditing\" do\n    sign_in impersonator = users(:maybe_support_staff)\n    impersonated = users(:family_member)\n\n    impersonator_session = impersonation_sessions(:in_progress)\n\n    post join_impersonation_sessions_path, params: { impersonation_session_id: impersonator_session.id }\n\n    assert_difference \"impersonator_session.logs.count\", 2 do\n      get root_path\n      get account_path(impersonated.family.accounts.first)\n    end\n  end\n\n  test \"super admin can request an impersonation session\" do\n    sign_in users(:maybe_support_staff)\n\n    post impersonation_sessions_path, params: { impersonation_session: { impersonated_id: users(:family_member).id } }\n\n    assert_equal \"Request sent to user. Waiting for approval.\", flash[:notice]\n    assert_redirected_to root_path\n  end\n\n  test \"super admin can join and leave an in progress impersonation session\" do\n    sign_in super_admin = users(:maybe_support_staff)\n\n    impersonator_session = impersonation_sessions(:in_progress)\n\n    super_admin_session = super_admin.sessions.order(created_at: :desc).first\n\n    assert_nil super_admin_session.active_impersonator_session\n\n    # Joining the session\n    post join_impersonation_sessions_path, params: { impersonation_session_id: impersonator_session.id }\n    assert_equal impersonator_session, super_admin_session.reload.active_impersonator_session\n    assert_equal \"Joined session\", flash[:notice]\n    assert_redirected_to root_path\n\n    follow_redirect!\n\n    # Leaving the session\n    delete leave_impersonation_sessions_path\n    assert_nil super_admin_session.reload.active_impersonator_session\n    assert_equal \"Left session\", flash[:notice]\n    assert_redirected_to root_path\n\n    # Impersonation session still in progress because nobody has ended it yet\n    assert_equal \"in_progress\", impersonator_session.reload.status\n  end\n\n  test \"super admin can complete an impersonation session\" do\n    sign_in super_admin = users(:maybe_support_staff)\n\n    impersonator_session = impersonation_sessions(:in_progress)\n\n    put complete_impersonation_session_path(impersonator_session)\n\n    assert_equal \"Session completed\", flash[:notice]\n    assert_nil super_admin.sessions.order(created_at: :desc).first.active_impersonator_session\n    assert_equal \"complete\", impersonator_session.reload.status\n    assert_redirected_to root_path\n  end\n\n  test \"regular user can complete an impersonation session\" do\n    sign_in regular_user = users(:family_member)\n\n    impersonator_session = impersonation_sessions(:in_progress)\n\n    put complete_impersonation_session_path(impersonator_session)\n\n    assert_equal \"Session completed\", flash[:notice]\n    assert_equal \"complete\", impersonator_session.reload.status\n    assert_redirected_to root_path\n  end\n\n  test \"super admin cannot accept an impersonation session\" do\n    sign_in super_admin = users(:maybe_support_staff)\n\n    impersonator_session = impersonation_sessions(:in_progress)\n\n    put approve_impersonation_session_path(impersonator_session)\n\n    assert_response :not_found\n  end\n\n  test \"regular user can accept an impersonation session\" do\n    sign_in regular_user = users(:family_member)\n\n    impersonator_session = impersonation_sessions(:in_progress)\n\n    put approve_impersonation_session_path(impersonator_session)\n\n    assert_equal \"Request approved\", flash[:notice]\n    assert_equal \"in_progress\", impersonator_session.reload.status\n    assert_redirected_to root_path\n  end\n\n  test \"regular user can reject an impersonation session\" do\n    sign_in regular_user = users(:family_member)\n\n    impersonator_session = impersonation_sessions(:in_progress)\n\n    put reject_impersonation_session_path(impersonator_session)\n\n    assert_equal \"Request rejected\", flash[:notice]\n    assert_equal \"rejected\", impersonator_session.reload.status\n    assert_redirected_to root_path\n  end\nend\n"
  },
  {
    "path": "test/controllers/import/cleans_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Import::CleansControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"shows if configured\" do\n    import = imports(:transaction)\n\n    TransactionImport.any_instance.stubs(:configured?).returns(true)\n\n    get import_clean_path(import)\n    assert_response :success\n  end\n\n  test \"redirects if not configured\" do\n    import = imports(:transaction)\n\n    TransactionImport.any_instance.stubs(:configured?).returns(false)\n\n    get import_clean_path(import)\n    assert_redirected_to import_configuration_path(import)\n  end\nend\n"
  },
  {
    "path": "test/controllers/import/configurations_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Import::ConfigurationsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n    @import = imports(:transaction)\n  end\n\n  test \"show\" do\n    get import_configuration_url(@import)\n    assert_response :success\n  end\n\n  test \"updating a valid configuration regenerates rows\" do\n    TransactionImport.any_instance.expects(:generate_rows_from_csv).once\n\n    patch import_configuration_url(@import), params: {\n      import: {\n        date_col_label: \"Date\",\n        date_format: \"%Y-%m-%d\",\n        name_col_label: \"Name\",\n        category_col_label: \"Category\",\n        tags_col_label: \"Tags\",\n        amount_col_label: \"Amount\",\n        signage_convention: \"inflows_positive\",\n        account_col_label: \"Account\",\n        number_format: \"1.234,56\"\n      }\n    }\n\n    assert_redirected_to import_clean_url(@import)\n    assert_equal \"Import configured successfully.\", flash[:notice]\n\n    # Verify configurations were saved\n    @import.reload\n    assert_equal \"Date\", @import.date_col_label\n    assert_equal \"%Y-%m-%d\", @import.date_format\n    assert_equal \"Name\", @import.name_col_label\n    assert_equal \"Category\", @import.category_col_label\n    assert_equal \"Tags\", @import.tags_col_label\n    assert_equal \"Amount\", @import.amount_col_label\n    assert_equal \"inflows_positive\", @import.signage_convention\n    assert_equal \"Account\", @import.account_col_label\n    assert_equal \"1.234,56\", @import.number_format\n  end\nend\n"
  },
  {
    "path": "test/controllers/import/confirms_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Import::ConfirmsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"shows if cleaned\" do\n    import = imports(:transaction)\n\n    TransactionImport.any_instance.stubs(:cleaned?).returns(true)\n\n    get import_confirm_path(import)\n    assert_response :success\n  end\n\n  test \"redirects if not cleaned\" do\n    import = imports(:transaction)\n\n    TransactionImport.any_instance.stubs(:cleaned?).returns(false)\n\n    get import_confirm_path(import)\n    assert_redirected_to import_clean_path(import)\n    assert_equal \"You have invalid data, please edit until all errors are resolved\", flash[:alert]\n  end\nend\n"
  },
  {
    "path": "test/controllers/import/mappings_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Import::MappingsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n\n    @import = imports(:transaction)\n  end\n\n  test \"updates mapping\" do\n    mapping = import_mappings(:one)\n    new_category = categories(:income)\n\n    patch import_mapping_path(@import, mapping), params: {\n      import_mapping: {\n        mappable_type: \"Category\",\n        mappable_id: new_category.id,\n        key: \"Food\"\n      }\n    }\n\n    mapping.reload\n\n    assert_equal new_category, mapping.mappable\n    assert_equal \"Food\", mapping.key\n\n    assert_redirected_to import_confirm_path(@import)\n  end\nend\n"
  },
  {
    "path": "test/controllers/import/rows_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Import::RowsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n\n    @import = imports(:transaction)\n    @row = import_rows(:one)\n  end\n\n  test \"show transaction row\" do\n    get import_row_path(@import, @row)\n\n    assert_row_fields(@row, [ :date, :name, :amount, :currency, :category, :tags, :account, :notes ])\n\n    assert_response :success\n  end\n\n  test \"show trade row\" do\n    import = @user.family.imports.create!(type: \"TradeImport\")\n    row = import.rows.create!(date: \"01/01/2024\", currency: \"USD\", qty: 10, price: 100, ticker: \"AAPL\")\n\n    get import_row_path(import, row)\n\n    assert_row_fields(row, [ :date, :ticker, :qty, :price, :currency, :account, :name, :account ])\n\n    assert_response :success\n  end\n\n  test \"show account row\" do\n    import = @user.family.imports.create!(type: \"AccountImport\")\n    row = import.rows.create!(name: \"Test Account\", amount: 10000, currency: \"USD\")\n\n    get import_row_path(import, row)\n\n    assert_row_fields(row, [ :entity_type, :name, :amount, :currency ])\n\n    assert_response :success\n  end\n\n  test \"show mint row\" do\n    import = @user.family.imports.create!(type: \"MintImport\")\n    row = import.rows.create!(date: \"01/01/2024\", amount: 100, currency: \"USD\")\n\n    get import_row_path(import, row)\n\n    assert_row_fields(row, [ :date, :name, :amount, :currency, :category, :tags, :account, :notes ])\n\n    assert_response :success\n  end\n\n  test \"update\" do\n    patch import_row_path(@import, @row), params: {\n      import_row: {\n        account: \"Checking Account\",\n        date: \"2024-01-01\",\n        qty: nil,\n        ticker: nil,\n        price: nil,\n        amount: 100,\n        currency: \"USD\",\n        name: \"Test\",\n        category: \"Food\",\n        tags: \"grocery, dinner\",\n        entity_type: nil,\n        notes: \"Weekly shopping\"\n      }\n    }\n\n    assert_redirected_to import_row_path(@import, @row)\n  end\n\n  private\n    def assert_row_fields(row, fields)\n      fields.each do |field|\n        assert_select \"turbo-frame##{dom_id(row, field)}\"\n      end\n    end\nend\n"
  },
  {
    "path": "test/controllers/import/uploads_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Import::UploadsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n    @import = imports(:transaction)\n  end\n\n  test \"show\" do\n    get import_upload_url(@import)\n    assert_response :success\n  end\n\n  test \"uploads valid csv by copy and pasting\" do\n    patch import_upload_url(@import), params: {\n      import: {\n        raw_file_str: file_fixture(\"imports/valid.csv\").read,\n        col_sep: \",\"\n      }\n    }\n\n    assert_redirected_to import_configuration_url(@import, template_hint: true)\n    assert_equal \"CSV uploaded successfully.\", flash[:notice]\n  end\n\n  test \"uploads valid csv by file\" do\n    patch import_upload_url(@import), params: {\n      import: {\n        csv_file: file_fixture_upload(\"imports/valid.csv\"),\n        col_sep: \",\"\n      }\n    }\n\n    assert_redirected_to import_configuration_url(@import, template_hint: true)\n    assert_equal \"CSV uploaded successfully.\", flash[:notice]\n  end\n\n  test \"invalid csv cannot be uploaded\" do\n    patch import_upload_url(@import), params: {\n      import: {\n        csv_file: file_fixture_upload(\"imports/invalid.csv\"),\n        col_sep: \",\"\n      }\n    }\n\n    assert_response :unprocessable_entity\n    assert_equal \"Must be valid CSV with headers and at least one row of data\", flash[:alert]\n  end\nend\n"
  },
  {
    "path": "test/controllers/imports_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass ImportsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"gets index\" do\n    get imports_url\n\n    assert_response :success\n\n    @user.family.imports.ordered.each do |import|\n      assert_select \"#\" + dom_id(import), count: 1\n    end\n  end\n\n  test \"gets new\" do\n    get new_import_url\n\n    assert_response :success\n\n    assert_select \"turbo-frame#modal\"\n  end\n\n  test \"creates import\" do\n    assert_difference \"Import.count\", 1 do\n      post imports_url, params: {\n        import: {\n          type: \"TransactionImport\"\n        }\n      }\n    end\n\n    assert_redirected_to import_upload_url(Import.all.ordered.first)\n  end\n\n  test \"publishes import\" do\n    import = imports(:transaction)\n\n    TransactionImport.any_instance.expects(:publish_later).once\n\n    post publish_import_url(import)\n\n    assert_equal \"Your import has started in the background.\", flash[:notice]\n    assert_redirected_to import_path(import)\n  end\n\n  test \"destroys import\" do\n    import = imports(:transaction)\n\n    assert_difference \"Import.count\", -1 do\n      delete import_url(import)\n    end\n\n    assert_redirected_to imports_path\n  end\nend\n"
  },
  {
    "path": "test/controllers/investments_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass InvestmentsControllerTest < ActionDispatch::IntegrationTest\n  include AccountableResourceInterfaceTest\n\n  setup do\n    sign_in @user = users(:family_admin)\n    @account = accounts(:investment)\n  end\nend\n"
  },
  {
    "path": "test/controllers/invitations_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass InvitationsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @admin = users(:family_admin)\n    @invitation = invitations(:one)\n  end\n\n  test \"should get new\" do\n    get new_invitation_url\n    assert_response :success\n  end\n\n  test \"should create invitation for member\" do\n    assert_difference(\"Invitation.count\") do\n      assert_enqueued_with(job: ActionMailer::MailDeliveryJob) do\n        post invitations_url, params: {\n          invitation: {\n            email: \"new@example.com\",\n            role: \"member\"\n          }\n        }\n      end\n    end\n\n    invitation = Invitation.order(created_at: :desc).first\n    assert_equal \"member\", invitation.role\n    assert_equal @admin, invitation.inviter\n    assert_equal \"new@example.com\", invitation.email\n    assert_redirected_to settings_profile_path\n    assert_equal I18n.t(\"invitations.create.success\"), flash[:notice]\n  end\n\n  test \"non-admin cannot create invitations\" do\n    sign_in users(:family_member)\n\n    assert_no_difference(\"Invitation.count\") do\n      post invitations_url, params: {\n        invitation: {\n          email: \"new@example.com\",\n          role: \"admin\"\n        }\n      }\n    end\n\n    assert_redirected_to settings_profile_path\n    assert_equal I18n.t(\"invitations.create.failure\"), flash[:alert]\n  end\n\n  test \"admin can create admin invitation\" do\n    assert_difference(\"Invitation.count\") do\n      post invitations_url, params: {\n        invitation: {\n          email: \"new@example.com\",\n          role: \"admin\"\n        }\n      }\n    end\n\n    invitation = Invitation.order(created_at: :desc).first\n    assert_equal \"admin\", invitation.role\n    assert_equal @admin.family, invitation.family\n    assert_equal @admin, invitation.inviter\n  end\n\n  test \"should handle invalid invitation creation\" do\n    assert_no_difference(\"Invitation.count\") do\n      post invitations_url, params: {\n        invitation: {\n          email: \"\",\n          role: \"member\"\n        }\n      }\n    end\n\n    assert_redirected_to settings_profile_path\n    assert_equal I18n.t(\"invitations.create.failure\"), flash[:alert]\n  end\n\n  test \"should accept invitation and redirect to registration\" do\n    get accept_invitation_url(@invitation.token)\n    assert_redirected_to new_registration_path(invitation: @invitation.token)\n  end\n\n  test \"should not accept invalid invitation token\" do\n    get accept_invitation_url(\"invalid-token\")\n    assert_response :not_found\n  end\n\n  test \"admin can remove pending invitation\" do\n    assert_difference(\"Invitation.count\", -1) do\n      delete invitation_url(@invitation)\n    end\n\n    assert_redirected_to settings_profile_path\n    assert_equal I18n.t(\"invitations.destroy.success\"), flash[:notice]\n  end\n\n  test \"non-admin cannot remove invitations\" do\n    sign_in users(:family_member)\n\n    assert_no_difference(\"Invitation.count\") do\n      delete invitation_url(@invitation)\n    end\n\n    assert_redirected_to settings_profile_path\n    assert_equal I18n.t(\"invitations.destroy.not_authorized\"), flash[:alert]\n  end\n\n  test \"should handle invalid invitation removal\" do\n    delete invitation_url(id: \"invalid-id\")\n    assert_response :not_found\n  end\nend\n"
  },
  {
    "path": "test/controllers/invite_codes_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass InviteCodesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)\n  end\n  test \"admin can generate invite codes\" do\n    sign_in users(:family_admin)\n\n    assert_difference(\"InviteCode.count\") do\n      post invite_codes_url, params: {}\n    end\n  end\n\n  test \"non-admin cannot generate invite codes\" do\n    sign_in users(:family_member)\n\n    assert_raises(StandardError) { post invite_codes_url, params: {} }\n  end\nend\n"
  },
  {
    "path": "test/controllers/loans_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass LoansControllerTest < ActionDispatch::IntegrationTest\n  include AccountableResourceInterfaceTest\n\n  setup do\n    sign_in @user = users(:family_admin)\n    @account = accounts(:loan)\n  end\n\n  test \"creates with loan details\" do\n    assert_difference -> { Account.count } => 1,\n      -> { Loan.count } => 1,\n      -> { Valuation.count } => 1,\n      -> { Entry.count } => 1 do\n      post loans_path, params: {\n        account: {\n          name: \"New Loan\",\n          balance: 50000,\n          currency: \"USD\",\n          accountable_type: \"Loan\",\n          accountable_attributes: {\n            interest_rate: 5.5,\n            term_months: 60,\n            rate_type: \"fixed\",\n            initial_balance: 50000\n          }\n        }\n      }\n    end\n\n    created_account = Account.order(:created_at).last\n\n    assert_equal \"New Loan\", created_account.name\n    assert_equal 50000, created_account.balance\n    assert_equal \"USD\", created_account.currency\n    assert_equal 5.5, created_account.accountable.interest_rate\n    assert_equal 60, created_account.accountable.term_months\n    assert_equal \"fixed\", created_account.accountable.rate_type\n    assert_equal 50000, created_account.accountable.initial_balance\n\n    assert_redirected_to created_account\n    assert_equal \"Loan account created\", flash[:notice]\n    assert_enqueued_with(job: SyncJob)\n  end\n\n  test \"updates with loan details\" do\n    assert_no_difference [ \"Account.count\", \"Loan.count\" ] do\n      patch loan_path(@account), params: {\n        account: {\n          name: \"Updated Loan\",\n          balance: 45000,\n          currency: \"USD\",\n          accountable_type: \"Loan\",\n          accountable_attributes: {\n            id: @account.accountable_id,\n            interest_rate: 4.5,\n            term_months: 48,\n            rate_type: \"fixed\",\n            initial_balance: 48000\n          }\n        }\n      }\n    end\n\n    @account.reload\n\n    assert_equal \"Updated Loan\", @account.name\n    assert_equal 45000, @account.balance\n    assert_equal 4.5, @account.accountable.interest_rate\n    assert_equal 48, @account.accountable.term_months\n    assert_equal \"fixed\", @account.accountable.rate_type\n    assert_equal 48000, @account.accountable.initial_balance\n\n    assert_redirected_to @account\n    assert_equal \"Loan account updated\", flash[:notice]\n    assert_enqueued_with(job: SyncJob)\n  end\nend\n"
  },
  {
    "path": "test/controllers/messages_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass MessagesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n    @chat = @user.chats.first\n  end\n\n  test \"can create a message\" do\n    post chat_messages_url(@chat), params: { message: { content: \"Hello\", ai_model: \"gpt-4.1\" } }\n\n    assert_redirected_to chat_path(@chat, thinking: true)\n  end\n\n  test \"cannot create a message if AI is disabled\" do\n    @user.update!(ai_enabled: false)\n\n    post chat_messages_url(@chat), params: { message: { content: \"Hello\", ai_model: \"gpt-4.1\" } }\n\n    assert_response :forbidden\n  end\nend\n"
  },
  {
    "path": "test/controllers/mfa_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass MfaControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_member)\n    sign_in @user\n  end\n\n  def sign_out\n    @user.sessions.each do |session|\n      delete session_path(session)\n    end\n  end\n\n  test \"redirects to root if MFA already enabled\" do\n    @user.setup_mfa!\n    @user.enable_mfa!\n\n    get new_mfa_path\n    assert_redirected_to root_path\n  end\n\n  test \"sets up MFA when visiting new\" do\n    get new_mfa_path\n\n    assert_response :success\n    assert @user.reload.otp_secret.present?\n    assert_not @user.otp_required?\n    assert_select \"svg\" # QR code should be present\n  end\n\n  test \"enables MFA with valid code\" do\n    @user.setup_mfa!\n    totp = ROTP::TOTP.new(@user.otp_secret, issuer: \"Maybe\")\n\n    post mfa_path, params: { code: totp.now }\n\n    assert_response :success\n    assert @user.reload.otp_required?\n    assert_equal 8, @user.otp_backup_codes.length\n    assert_select \"div.grid-cols-2\" # Check for backup codes grid\n  end\n\n  test \"does not enable MFA with invalid code\" do\n    @user.setup_mfa!\n\n    post mfa_path, params: { code: \"invalid\" }\n\n    assert_redirected_to new_mfa_path\n    assert_not @user.reload.otp_required?\n    assert_empty @user.otp_backup_codes\n  end\n\n  test \"verify shows MFA verification page\" do\n    @user.setup_mfa!\n    @user.enable_mfa!\n    sign_out\n\n    post sessions_path, params: { email: @user.email, password: user_password_test }\n    assert_redirected_to verify_mfa_path\n\n    get verify_mfa_path\n    assert_response :success\n    assert_select \"form[action=?]\", verify_mfa_path\n  end\n\n  test \"verify_code authenticates with valid TOTP\" do\n    @user.setup_mfa!\n    @user.enable_mfa!\n    sign_out\n\n    post sessions_path, params: { email: @user.email, password: user_password_test }\n    totp = ROTP::TOTP.new(@user.otp_secret, issuer: \"Maybe\")\n\n    post verify_mfa_path, params: { code: totp.now }\n\n    assert_redirected_to root_path\n    assert Session.exists?(user_id: @user.id)\n  end\n\n  test \"verify_code authenticates with valid backup code\" do\n    @user.setup_mfa!\n    @user.enable_mfa!\n    sign_out\n\n    post sessions_path, params: { email: @user.email, password: user_password_test }\n    backup_code = @user.otp_backup_codes.first\n\n    post verify_mfa_path, params: { code: backup_code }\n\n    assert_redirected_to root_path\n    assert Session.exists?(user_id: @user.id)\n    assert_not @user.reload.otp_backup_codes.include?(backup_code)\n  end\n\n  test \"verify_code rejects invalid codes\" do\n    @user.setup_mfa!\n    @user.enable_mfa!\n    sign_out\n\n    post sessions_path, params: { email: @user.email, password: user_password_test }\n    post verify_mfa_path, params: { code: \"invalid\" }\n\n    assert_response :unprocessable_entity\n    assert_not Session.exists?(user_id: @user.id)\n  end\n\n  test \"disable removes MFA\" do\n    @user.setup_mfa!\n    @user.enable_mfa!\n\n    delete disable_mfa_path\n\n    assert_redirected_to settings_security_path\n    assert_not @user.reload.otp_required?\n    assert_nil @user.otp_secret\n    assert_empty @user.otp_backup_codes\n  end\nend\n"
  },
  {
    "path": "test/controllers/onboardings_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass OnboardingsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_admin)\n    @family = @user.family\n\n    # Reset onboarding state\n    @user.update!(set_onboarding_preferences_at: nil)\n\n    sign_in @user\n  end\n\n  test \"should get show\" do\n    get onboarding_url\n    assert_response :success\n    assert_select \"h1\", text: /set up your account/i\n  end\n\n  test \"should get preferences\" do\n    get preferences_onboarding_url\n    assert_response :success\n    assert_select \"h1\", text: /preferences/i\n  end\n\n  test \"preferences page renders Series chart data without errors\" do\n    get preferences_onboarding_url\n    assert_response :success\n\n    # This test specifically targets the Series model bug\n    # The page should render without throwing the \"unknown keyword: :trend\" error\n    assert_select \"[data-controller='time-series-chart']\"\n    assert_select \"#previewChart\"\n\n    # Verify that the Series.from_raw_values call in the view works\n    # If the Series bug existed, this would raise an ActionView::Template::Error\n    assert_no_match /unknown keyword: :trend/, response.body\n  end\n\n  test \"preferences page includes chart with valid JSON data\" do\n    get preferences_onboarding_url\n    assert_response :success\n\n    # Extract the chart data from the response\n    chart_data_match = response.body.match(/data-time-series-chart-data-value=\"([^\"]*)\"/)\n    assert chart_data_match, \"Chart data attribute should be present\"\n\n    # Decode HTML entities and parse JSON\n    chart_data_json = CGI.unescapeHTML(chart_data_match[1])\n\n    # Should be valid JSON\n    assert_nothing_raised do\n      chart_data = JSON.parse(chart_data_json)\n\n      # Verify expected structure\n      assert chart_data.key?(\"start_date\")\n      assert chart_data.key?(\"end_date\")\n      assert chart_data.key?(\"interval\")\n      assert chart_data.key?(\"trend\")\n      assert chart_data.key?(\"values\")\n\n      # Verify trend has expected structure\n      trend = chart_data[\"trend\"]\n      assert trend.key?(\"value\")\n      assert trend.key?(\"percent\")\n      assert trend.key?(\"current\")\n      assert trend.key?(\"previous\")\n\n      # Verify values array has expected structure\n      values = chart_data[\"values\"]\n      assert values.is_a?(Array)\n      assert values.length > 0\n\n      values.each do |value|\n        assert value.key?(\"date\")\n        assert value.key?(\"value\")\n        assert value.key?(\"trend\")\n      end\n    end\n  end\n\n  test \"should get goals\" do\n    get goals_onboarding_url\n    assert_response :success\n    assert_select \"h1\", text: /What brings you to Maybe/i\n  end\n\n  test \"should get trial\" do\n    get trial_onboarding_url\n    assert_response :success\n  end\n\n  test \"preferences page shows currency formatting example\" do\n    get preferences_onboarding_url\n    assert_response :success\n\n    # Should show formatted currency example\n    assert_select \"p\", text: /\\$2,325\\.25/\n    assert_select \"span\", text: /\\+\\$78\\.90/\n  end\n\n  test \"preferences page shows date formatting example\" do\n  get preferences_onboarding_url\n  assert_response :success\n\n  # Should show formatted date example (checking for the specific format shown)\n  assert_match /10-23-2024/, response.body\nend\n\n  test \"preferences page includes all required form fields\" do\n  get preferences_onboarding_url\n  assert_response :success\n\n  # Verify all form fields are present\n  assert_select \"select[name='user[family_attributes][locale]']\"\n  assert_select \"select[name='user[family_attributes][currency]']\"\n  assert_select \"select[name='user[family_attributes][date_format]']\"\n  assert_select \"select[name='user[theme]']\"\n  assert_select \"button[type='submit']\"\nend\n\n  test \"preferences page includes JavaScript controllers\" do\n    get preferences_onboarding_url\n    assert_response :success\n\n    # Should include onboarding controller for dynamic updates\n    assert_select \"[data-controller*='onboarding']\"\n    assert_select \"[data-controller*='time-series-chart']\"\n  end\n\n  test \"all onboarding pages set correct layout\" do\n    # Test that all onboarding pages use the wizard layout\n    get onboarding_url\n    assert_response :success\n\n    get preferences_onboarding_url\n    assert_response :success\n\n    get goals_onboarding_url\n    assert_response :success\n\n    get trial_onboarding_url\n    assert_response :success\n  end\n\n  test \"onboarding pages require authentication\" do\n  sign_out\n\n  get onboarding_url\n  assert_redirected_to new_session_url\n\n  get preferences_onboarding_url\n  assert_redirected_to new_session_url\n\n  get goals_onboarding_url\n  assert_redirected_to new_session_url\n\n  get trial_onboarding_url\n  assert_redirected_to new_session_url\nend\n\n    private\n\n      def sign_out\n        @user.sessions.each do |session|\n          delete session_path(session)\n        end\n      end\nend\n"
  },
  {
    "path": "test/controllers/other_assets_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass OtherAssetsControllerTest < ActionDispatch::IntegrationTest\n  include AccountableResourceInterfaceTest\n\n  setup do\n    sign_in @user = users(:family_admin)\n    @account = accounts(:other_asset)\n  end\nend\n"
  },
  {
    "path": "test/controllers/other_liabilities_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass OtherLiabilitiesControllerTest < ActionDispatch::IntegrationTest\n  include AccountableResourceInterfaceTest\n\n  setup do\n    sign_in @user = users(:family_admin)\n    @account = accounts(:other_liability)\n  end\nend\n"
  },
  {
    "path": "test/controllers/pages_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass PagesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"dashboard\" do\n    get root_path\n    assert_response :ok\n  end\n\n  test \"changelog\" do\n    VCR.use_cassette(\"git_repository_provider/fetch_latest_release_notes\") do\n      get changelog_path\n      assert_response :ok\n    end\n  end\n\n  test \"changelog with nil release notes\" do\n    # Mock the GitHub provider to return nil (simulating API failure or no releases)\n    github_provider = mock\n    github_provider.expects(:fetch_latest_release_notes).returns(nil)\n    Provider::Registry.stubs(:get_provider).with(:github).returns(github_provider)\n\n    get changelog_path\n    assert_response :ok\n    assert_select \"h2\", text: \"Release notes unavailable\"\n    assert_select \"a[href='https://github.com/maybe-finance/maybe/releases']\"\n  end\n\n  test \"changelog with incomplete release notes\" do\n    # Mock the GitHub provider to return incomplete data (missing some fields)\n    github_provider = mock\n    incomplete_data = {\n      avatar: nil,\n      username: \"maybe-finance\",\n      name: \"Test Release\",\n      published_at: nil,\n      body: nil\n    }\n    github_provider.expects(:fetch_latest_release_notes).returns(incomplete_data)\n    Provider::Registry.stubs(:get_provider).with(:github).returns(github_provider)\n\n    get changelog_path\n    assert_response :ok\n    assert_select \"h2\", text: \"Test Release\"\n    # Should not crash even with nil values\n  end\nend\n"
  },
  {
    "path": "test/controllers/password_resets_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass PasswordResetsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_admin)\n  end\n\n  test \"new\" do\n    get new_password_reset_path\n    assert_response :ok\n  end\n\n  test \"create\" do\n    assert_enqueued_emails 1 do\n      post password_reset_path, params: { email: @user.email }\n      assert_redirected_to new_password_reset_url(step: \"pending\")\n    end\n  end\n\n  test \"edit\" do\n    get edit_password_reset_path(token: @user.generate_token_for(:password_reset))\n    assert_response :ok\n  end\n\n  test \"update\" do\n    patch password_reset_path(token: @user.generate_token_for(:password_reset)),\n      params: { user: { password: \"password\", password_confirmation: \"password\" } }\n    assert_redirected_to new_session_url\n  end\nend\n"
  },
  {
    "path": "test/controllers/plaid_items_controller_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass PlaidItemsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"create\" do\n    @plaid_provider = mock\n    Provider::Registry.expects(:plaid_provider_for_region).with(\"us\").returns(@plaid_provider)\n\n    public_token = \"public-sandbox-1234\"\n\n    @plaid_provider.expects(:exchange_public_token).with(public_token).returns(\n      OpenStruct.new(access_token: \"access-sandbox-1234\", item_id: \"item-sandbox-1234\")\n    )\n\n    assert_difference \"PlaidItem.count\", 1 do\n      post plaid_items_url, params: {\n        plaid_item: {\n          public_token: public_token,\n          region: \"us\",\n          metadata: { institution: { name: \"Plaid Item Name\" } }\n        }\n      }\n    end\n\n    assert_equal \"Account linked successfully.  Please wait for accounts to sync.\", flash[:notice]\n    assert_redirected_to accounts_path\n  end\n\n  test \"destroy\" do\n    delete plaid_item_url(plaid_items(:one))\n\n    assert_equal \"Accounts scheduled for deletion.\", flash[:notice]\n    assert_enqueued_with job: DestroyJob\n    assert_redirected_to accounts_path\n  end\n\n  test \"sync\" do\n    plaid_item = plaid_items(:one)\n    PlaidItem.any_instance.expects(:sync_later).once\n\n    post sync_plaid_item_url(plaid_item)\n\n    assert_redirected_to accounts_path\n  end\nend\n"
  },
  {
    "path": "test/controllers/properties_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass PropertiesControllerTest < ActionDispatch::IntegrationTest\n  include AccountableResourceInterfaceTest\n\n  setup do\n    sign_in @user = users(:family_admin)\n    @account = accounts(:property)\n  end\n\n  test \"creates property in draft status and redirects to balances step\" do\n    assert_difference -> { Account.count } => 1 do\n      post properties_path, params: {\n        account: {\n          name: \"New Property\",\n          subtype: \"house\",\n          accountable_type: \"Property\",\n          accountable_attributes: {\n            year_built: 1990,\n            area_value: 1200,\n            area_unit: \"sqft\"\n          }\n        }\n      }\n    end\n\n    created_account = Account.order(:created_at).last\n    assert created_account.accountable.is_a?(Property)\n    assert_equal \"draft\", created_account.status\n    assert_equal 0, created_account.balance\n    assert_equal 1990, created_account.accountable.year_built\n    assert_equal 1200, created_account.accountable.area_value\n    assert_equal \"sqft\", created_account.accountable.area_unit\n    assert_redirected_to balances_property_path(created_account)\n  end\n\n  test \"updates property overview\" do\n    assert_no_difference [ \"Account.count\", \"Property.count\" ] do\n      patch property_path(@account), params: {\n        account: {\n          name: \"Updated Property\",\n          subtype: \"condo\"\n        }\n      }\n    end\n\n    @account.reload\n    assert_equal \"Updated Property\", @account.name\n    assert_equal \"condo\", @account.subtype\n\n    # If account is active, it renders edit view; otherwise redirects to balances\n    if @account.active?\n      assert_response :success\n    else\n      assert_redirected_to balances_property_path(@account)\n    end\n  end\n\n  # Tab view tests\n  test \"shows balances tab\" do\n    get balances_property_path(@account)\n    assert_response :success\n  end\n\n  test \"shows address tab\" do\n    get address_property_path(@account)\n    assert_response :success\n  end\n\n  # Tab update tests\n  test \"updates balances tab\" do\n    original_balance = @account.balance\n\n    patch update_balances_property_path(@account), params: {\n      account: {\n        balance: 600000,\n        currency: \"EUR\"\n      }\n    }\n\n    # If account is active, it renders balances view; otherwise redirects to address\n    if @account.reload.active?\n      assert_response :success\n    else\n      assert_redirected_to address_property_path(@account)\n    end\n  end\n\n  test \"updates address tab\" do\n    patch update_address_property_path(@account), params: {\n      property: {\n        address_attributes: {\n          line1: \"456 New Street\",\n          locality: \"San Francisco\",\n          region: \"CA\",\n          country: \"US\",\n          postal_code: \"94102\"\n        }\n      }\n    }\n\n    @account.reload\n    assert_equal \"456 New Street\", @account.accountable.address.line1\n    assert_equal \"San Francisco\", @account.accountable.address.locality\n\n    # If account is draft, it activates and redirects; otherwise renders address\n    if @account.draft?\n      assert_redirected_to account_path(@account)\n    else\n      assert_response :success\n    end\n  end\n\n  test \"balances update handles validation errors\" do\n    Account.any_instance.stubs(:set_current_balance).returns(OpenStruct.new(success?: false, error_message: \"Invalid balance\"))\n\n    patch update_balances_property_path(@account), params: {\n      account: {\n        balance: 600000,\n        currency: \"EUR\"\n      }\n    }\n\n    assert_response :unprocessable_entity\n  end\n\n  test \"address update handles validation errors\" do\n    Property.any_instance.stubs(:update).returns(false)\n\n    patch update_address_property_path(@account), params: {\n      property: {\n        address_attributes: {\n          line1: \"123 Test St\"\n        }\n      }\n    }\n\n    assert_response :unprocessable_entity\n  end\n\n  test \"address update activates draft account\" do\n    # Create a draft property account\n    draft_account = Account.create!(\n      family: @user.family,\n      name: \"Draft Property\",\n      accountable: Property.new,\n      status: \"draft\",\n      balance: 500000,\n      currency: \"USD\"\n    )\n\n    assert draft_account.draft?\n\n    patch update_address_property_path(draft_account), params: {\n      property: {\n        address_attributes: {\n          line1: \"789 Activate St\",\n          locality: \"New York\",\n          region: \"NY\",\n          country: \"US\",\n          postal_code: \"10001\"\n        }\n      }\n    }\n\n    draft_account.reload\n    assert draft_account.active?\n    assert_redirected_to account_path(draft_account)\n  end\nend\n"
  },
  {
    "path": "test/controllers/registrations_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass RegistrationsControllerTest < ActionDispatch::IntegrationTest\n  test \"new\" do\n    get new_registration_url\n    assert_response :success\n  end\n\n  test \"create redirects to correct URL\" do\n    post registration_url, params: { user: {\n      email: \"john@example.com\",\n      password: \"Password1!\" } }\n\n    assert_redirected_to root_url\n  end\n\n  test \"create when hosted requires an invite code\" do\n    with_env_overrides REQUIRE_INVITE_CODE: \"true\" do\n      assert_no_difference \"User.count\" do\n        post registration_url, params: { user: {\n          email: \"john@example.com\",\n          password: \"Password1!\" } }\n        assert_redirected_to new_registration_url\n\n        post registration_url, params: { user: {\n          email: \"john@example.com\",\n          password: \"Password1!\",\n          invite_code: \"foo\" } }\n        assert_redirected_to new_registration_url\n      end\n\n      assert_difference \"User.count\", +1 do\n        post registration_url, params: { user: {\n          email: \"john@example.com\",\n          password: \"Password1!\",\n          invite_code: InviteCode.generate! } }\n        assert_redirected_to root_url\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/rules_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass RulesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"should get new\" do\n    get new_rule_url(resource_type: \"transaction\")\n    assert_response :success\n  end\n\n  test \"should get edit\" do\n    get edit_rule_url(rules(:one))\n    assert_response :success\n  end\n\n  # \"Set all transactions with a name like 'starbucks' and an amount between 20 and 40 to the 'food and drink' category\"\n  test \"creates rule with nested conditions\" do\n    post rules_url, params: {\n      rule: {\n        effective_date: 30.days.ago.to_date,\n        resource_type: \"transaction\",\n        conditions_attributes: {\n          \"0\" => {\n            condition_type: \"transaction_name\",\n            operator: \"like\",\n            value: \"starbucks\"\n          },\n          \"1\" => {\n            condition_type: \"compound\",\n            operator: \"and\",\n            sub_conditions_attributes: {\n              \"0\" => {\n                condition_type: \"transaction_amount\",\n                operator: \">\",\n                value: 20\n              },\n              \"1\" => {\n                condition_type: \"transaction_amount\",\n                operator: \"<\",\n                value: 40\n              }\n            }\n          }\n        },\n        actions_attributes: {\n          \"0\" => {\n            action_type: \"set_transaction_category\",\n            value: categories(:food_and_drink).id\n          }\n        }\n      }\n    }\n\n    rule = @user.family.rules.order(\"created_at DESC\").first\n\n    # Rule\n    assert_equal \"transaction\", rule.resource_type\n    assert_not rule.active # Not active by default\n    assert_equal 30.days.ago.to_date, rule.effective_date\n\n    # Conditions assertions\n    assert_equal 2, rule.conditions.count\n    compound_condition = rule.conditions.find { |condition| condition.condition_type == \"compound\" }\n    assert_equal \"compound\", compound_condition.condition_type\n    assert_equal 2, compound_condition.sub_conditions.count\n\n    # Actions assertions\n    assert_equal 1, rule.actions.count\n    assert_equal \"set_transaction_category\", rule.actions.first.action_type\n    assert_equal categories(:food_and_drink).id, rule.actions.first.value\n\n    assert_redirected_to confirm_rule_url(rule)\n  end\n\n  test \"can update rule\" do\n    rule = rules(:one)\n\n    assert_difference -> { Rule.count } => 0,\n      -> { Rule::Condition.count } => 1,\n      -> { Rule::Action.count } => 1 do\n      patch rule_url(rule), params: {\n        rule: {\n          active: false,\n          conditions_attributes: {\n            \"0\" => {\n              id: rule.conditions.first.id,\n              value: \"new_value\"\n            },\n            \"1\" => {\n              condition_type: \"transaction_amount\",\n              operator: \">\",\n              value: 100\n            }\n          },\n          actions_attributes: {\n            \"0\" => {\n              id: rule.actions.first.id,\n              value: \"new_value\"\n            },\n            \"1\" => {\n              action_type: \"set_transaction_tags\",\n              value: tags(:one).id\n            }\n          }\n        }\n      }\n    end\n\n    rule.reload\n\n    assert_not rule.active\n    assert_equal \"new_value\", rule.conditions.order(\"created_at ASC\").first.value\n    assert_equal \"new_value\", rule.actions.order(\"created_at ASC\").first.value\n    assert_equal tags(:one).id, rule.actions.order(\"created_at ASC\").last.value\n    assert_equal \"100\", rule.conditions.order(\"created_at ASC\").last.value\n\n    assert_redirected_to rules_url\n  end\n\n  test \"can destroy conditions and actions while editing\" do\n    rule = rules(:one)\n\n    assert_equal 1, rule.conditions.count\n    assert_equal 1, rule.actions.count\n\n    patch rule_url(rule), params: {\n      rule: {\n        conditions_attributes: {\n          \"0\" => { id: rule.conditions.first.id, _destroy: true },\n          \"1\" => {\n            condition_type: \"transaction_name\",\n            operator: \"like\",\n            value: \"new_condition\"\n          }\n        },\n        actions_attributes: {\n          \"0\" => { id: rule.actions.first.id, _destroy: true },\n          \"1\" => {\n            action_type: \"set_transaction_tags\",\n            value: tags(:one).id\n          }\n        }\n      }\n    }\n\n    assert_redirected_to rules_url\n\n    rule.reload\n\n    assert_equal 1, rule.conditions.count\n    assert_equal 1, rule.actions.count\n  end\n\n  test \"can destroy rule\" do\n    rule = rules(:one)\n\n    assert_difference [ \"Rule.count\", \"Rule::Condition.count\", \"Rule::Action.count\" ], -1 do\n      delete rule_url(rule)\n    end\n\n    assert_redirected_to rules_url\n  end\nend\n"
  },
  {
    "path": "test/controllers/sessions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass SessionsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_admin)\n  end\n\n  test \"login page\" do\n    get new_session_url\n    assert_response :success\n  end\n\n  test \"can sign in\" do\n    sign_in @user\n    assert_redirected_to root_url\n    assert Session.exists?(user_id: @user.id)\n\n    get root_url\n    assert_response :success\n  end\n\n  test \"fails to sign in with bad password\" do\n    post sessions_url, params: { email: @user.email, password: \"bad\" }\n    assert_response :unprocessable_entity\n    assert_equal \"Invalid email or password.\", flash[:alert]\n  end\n\n  test \"can sign out\" do\n    sign_in @user\n    session_record = @user.sessions.last\n\n    delete session_url(session_record)\n    assert_redirected_to new_session_path\n    assert_equal \"You have signed out successfully.\", flash[:notice]\n\n    # Verify session is destroyed\n    assert_nil Session.find_by(id: session_record.id)\n  end\n\n  test \"redirects to MFA verification when MFA enabled\" do\n    @user.setup_mfa!\n    @user.enable_mfa!\n    @user.sessions.destroy_all # Clean up any existing sessions\n\n    post sessions_path, params: { email: @user.email, password: user_password_test }\n\n    assert_redirected_to verify_mfa_path\n    assert_equal @user.id, session[:mfa_user_id]\n    assert_not Session.exists?(user_id: @user.id)\n  end\nend\n"
  },
  {
    "path": "test/controllers/settings/api_keys_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Settings::ApiKeysControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:family_admin)\n    @user.api_keys.destroy_all # Ensure clean state\n    sign_in @user\n  end\n\n  test \"should show no API key page when user has no active keys\" do\n    get settings_api_key_path\n    assert_response :success\n  end\n\n  test \"should show current API key when user has active key\" do\n    @api_key = ApiKey.create!(\n      user: @user,\n      name: \"Test API Key\",\n      display_key: \"test_key_123\",\n      scopes: [ \"read\" ]\n    )\n\n    get settings_api_key_path\n    assert_response :success\n  end\n\n  test \"should show new API key form\" do\n    get new_settings_api_key_path\n    assert_response :success\n  end\n\n  test \"should redirect to show when user already has active key and tries to visit new\" do\n    ApiKey.create!(\n      user: @user,\n      name: \"Existing API Key\",\n      display_key: \"existing_key_123\",\n      scopes: [ \"read\" ]\n    )\n\n    get new_settings_api_key_path\n    assert_redirected_to settings_api_key_path\n  end\n\n  test \"should create new API key with valid parameters\" do\n    assert_difference \"ApiKey.count\", 1 do\n      post settings_api_key_path, params: {\n        api_key: {\n          name: \"Test Integration Key\",\n          scopes: \"read_write\"\n        }\n      }\n    end\n\n    assert_redirected_to settings_api_key_path\n    follow_redirect!\n    assert_response :success\n\n    api_key = @user.api_keys.active.first\n    assert_equal \"Test Integration Key\", api_key.name\n    assert_includes api_key.scopes, \"read_write\"\n  end\n\n  test \"should revoke existing key when creating new one\" do\n    old_key = ApiKey.create!(\n      user: @user,\n      name: \"Old API Key\",\n      display_key: \"old_key_123\",\n      scopes: [ \"read\" ]\n    )\n\n    post settings_api_key_path, params: {\n      api_key: {\n        name: \"New API Key\",\n        scopes: \"read_write\"\n      }\n    }\n\n    assert_redirected_to settings_api_key_path\n    follow_redirect!\n    assert_response :success\n\n    old_key.reload\n    assert old_key.revoked?\n\n    new_key = @user.api_keys.active.first\n    assert_equal \"New API Key\", new_key.name\n  end\n\n  test \"should not create API key without name\" do\n    assert_no_difference \"ApiKey.count\" do\n      post settings_api_key_path, params: {\n        api_key: {\n          name: \"\",\n          scopes: \"read\"\n        }\n      }\n    end\n\n    assert_response :unprocessable_entity\n  end\n\n  test \"should not create API key without scopes\" do\n  # Ensure clean state for this specific test\n  @user.api_keys.destroy_all\n  initial_user_count = @user.api_keys.count\n\n  assert_no_difference \"@user.api_keys.count\" do\n    post settings_api_key_path, params: {\n      api_key: {\n        name: \"Test Key\",\n        scopes: []\n      }\n    }\n  end\n\n  assert_response :unprocessable_entity\n  assert_equal initial_user_count, @user.api_keys.reload.count\nend\n\n  test \"should revoke API key\" do\n    @api_key = ApiKey.create!(\n      user: @user,\n      name: \"Test API Key\",\n      display_key: \"test_key_123\",\n      scopes: [ \"read\" ]\n    )\n\n    delete settings_api_key_path\n\n    assert_redirected_to settings_api_key_path\n    follow_redirect!\n    assert_response :success\n\n    @api_key.reload\n    assert @api_key.revoked?\n  end\n\n  test \"should handle revoke when no API key exists\" do\n    delete settings_api_key_path\n\n    assert_redirected_to settings_api_key_path\n    # Should not error even when no API key exists\n  end\n\n  test \"should only allow one active API key per user\" do\n    # Create first API key\n    post settings_api_key_path, params: {\n      api_key: {\n        name: \"First Key\",\n        scopes: \"read\"\n      }\n    }\n\n    first_key = @user.api_keys.active.first\n\n    # Create second API key\n    post settings_api_key_path, params: {\n      api_key: {\n        name: \"Second Key\",\n        scopes: \"read_write\"\n      }\n    }\n\n    # First key should be revoked\n    first_key.reload\n    assert first_key.revoked?\n\n    # Only one active key should exist\n    assert_equal 1, @user.api_keys.active.count\n    assert_equal \"Second Key\", @user.api_keys.active.first.name\n  end\n\n  test \"should generate secure random API key\" do\n    post settings_api_key_path, params: {\n      api_key: {\n        name: \"Random Key Test\",\n        scopes: \"read\"\n      }\n    }\n\n    assert_redirected_to settings_api_key_path\n    follow_redirect!\n    assert_response :success\n\n    # Verify the API key was created with expected properties\n    api_key = @user.api_keys.active.first\n    assert api_key.present?\n    assert_equal \"Random Key Test\", api_key.name\n    assert_includes api_key.scopes, \"read\"\n  end\nend\n"
  },
  {
    "path": "test/controllers/settings/billings_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Settings::BillingsControllerTest < ActionDispatch::IntegrationTest\n  # test \"the truth\" do\n  #   assert true\n  # end\nend\n"
  },
  {
    "path": "test/controllers/settings/hostings_controller_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass Settings::HostingsControllerTest < ActionDispatch::IntegrationTest\n  include ProviderTestHelper\n\n  setup do\n    sign_in users(:family_admin)\n\n    @provider = mock\n    Provider::Registry.stubs(:get_provider).with(:synth).returns(@provider)\n    @usage_response = provider_success_response(\n      OpenStruct.new(\n        used: 10,\n        limit: 100,\n        utilization: 10,\n        plan: \"free\",\n      )\n    )\n  end\n\n  test \"cannot edit when self hosting is disabled\" do\n    with_env_overrides SELF_HOSTED: \"false\" do\n      get settings_hosting_url\n      assert_response :forbidden\n\n      patch settings_hosting_url, params: { setting: { require_invite_for_signup: true } }\n      assert_response :forbidden\n    end\n  end\n\n  test \"should get edit when self hosting is enabled\" do\n    @provider.expects(:usage).returns(@usage_response)\n\n    with_self_hosting do\n      get settings_hosting_url\n      assert_response :success\n    end\n  end\n\n  test \"can update settings when self hosting is enabled\" do\n    with_self_hosting do\n      patch settings_hosting_url, params: { setting: { synth_api_key: \"1234567890\" } }\n\n      assert_equal \"1234567890\", Setting.synth_api_key\n    end\n  end\n\n  test \"can clear data cache when self hosting is enabled\" do\n    account = accounts(:investment)\n    holding = account.holdings.first\n    exchange_rate = exchange_rates(:one)\n    security_price = holding.security.prices.first\n    account_balance = account.balances.create!(date: Date.current, balance: 1000, currency: \"USD\")\n\n    with_self_hosting do\n      perform_enqueued_jobs(only: DataCacheClearJob) do\n        delete clear_cache_settings_hosting_url\n      end\n    end\n\n    assert_redirected_to settings_hosting_url\n    assert_equal I18n.t(\"settings.hostings.clear_cache.cache_cleared\"), flash[:notice]\n\n    assert_not ExchangeRate.exists?(exchange_rate.id)\n    assert_not Security::Price.exists?(security_price.id)\n    assert_not Holding.exists?(holding.id)\n    assert_not Balance.exists?(account_balance.id)\n  end\n\n  test \"can clear data only when admin\" do\n    with_self_hosting do\n      sign_in users(:family_member)\n\n      assert_no_enqueued_jobs do\n        delete clear_cache_settings_hosting_url\n      end\n\n      assert_redirected_to settings_hosting_url\n      assert_equal I18n.t(\"settings.hostings.not_authorized\"), flash[:alert]\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/settings/preferences_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Settings::PreferencesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in users(:family_admin)\n  end\n  test \"get\" do\n    get settings_preferences_url\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/settings/profiles_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    @admin = users(:family_admin)\n    @member = users(:family_member)\n  end\n\n  test \"should get show\" do\n    sign_in @admin\n    get settings_profile_path\n    assert_response :success\n  end\n\n  test \"admin can remove a family member\" do\n    sign_in @admin\n    assert_difference(\"User.count\", -1) do\n      delete settings_profile_path(user_id: @member)\n    end\n\n    assert_redirected_to settings_profile_path\n    assert_equal \"Member removed successfully.\", flash[:notice]\n    assert_raises(ActiveRecord::RecordNotFound) { User.find(@member.id) }\n  end\n\n  test \"admin cannot remove themselves\" do\n    sign_in @admin\n    assert_no_difference(\"User.count\") do\n      delete settings_profile_path(user_id: @admin)\n    end\n\n    assert_redirected_to settings_profile_path\n    assert_equal I18n.t(\"settings.profiles.destroy.cannot_remove_self\"), flash[:alert]\n    assert User.find(@admin.id)\n  end\n\n  test \"non-admin cannot remove members\" do\n    sign_in @member\n    assert_no_difference(\"User.count\") do\n      delete settings_profile_path(user_id: @admin)\n    end\n\n    assert_redirected_to settings_profile_path\n    assert_equal I18n.t(\"settings.profiles.destroy.not_authorized\"), flash[:alert]\n    assert User.find(@admin.id)\n  end\n\n  test \"admin removing a family member also destroys their invitation\" do\n    # Create an invitation for the member\n    invitation = @admin.family.invitations.create!(\n      email: @member.email,\n      role: \"member\",\n      inviter: @admin\n    )\n\n    sign_in @admin\n\n    assert_difference [ \"User.count\", \"Invitation.count\" ], -1 do\n      delete settings_profile_path(user_id: @member)\n    end\n\n    assert_redirected_to settings_profile_path\n    assert_equal \"Member removed successfully.\", flash[:notice]\n    assert_raises(ActiveRecord::RecordNotFound) { User.find(@member.id) }\n    assert_raises(ActiveRecord::RecordNotFound) { Invitation.find(invitation.id) }\n  end\nend\n"
  },
  {
    "path": "test/controllers/subscriptions_controller_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass SubscriptionsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:empty)\n    @family = @user.family\n\n    @mock_stripe = mock\n    Provider::Registry.stubs(:get_provider).with(:stripe).returns(@mock_stripe)\n  end\n\n  test \"disabled for self hosted users\" do\n    Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)\n    post subscription_path\n    assert_response :forbidden\n  end\n\n  # Trial subscriptions are managed internally and do NOT go through Stripe\n  test \"can create trial subscription\" do\n    @family.subscription.destroy\n    @family.reload\n\n    assert_difference \"Subscription.count\", 1 do\n      post subscription_path\n    end\n\n    assert_redirected_to root_path\n    assert_equal \"Welcome to Maybe!\", flash[:notice]\n    assert_equal \"trialing\", @family.subscription.status\n    assert_in_delta Subscription::TRIAL_DAYS.days.from_now, @family.subscription.trial_ends_at, 1.minute\n  end\n\n  test \"users who have already trialed cannot create a new subscription\" do\n    assert_no_difference \"Subscription.count\" do\n      post subscription_path\n    end\n\n    assert_redirected_to root_path\n    assert_equal \"You have already started or completed a trial. Please upgrade to continue.\", flash[:alert]\n  end\n\n  test \"creates new checkout session\" do\n    @mock_stripe.expects(:create_checkout_session).with(\n      plan: \"monthly\",\n      family_id: @family.id,\n      family_email: @family.billing_email,\n      success_url: success_subscription_url + \"?session_id={CHECKOUT_SESSION_ID}\",\n      cancel_url: upgrade_subscription_url\n    ).returns(\n      OpenStruct.new(\n        url: \"https://checkout.stripe.com/c/pay/test-session-id\",\n        customer_id: \"test-customer-id\"\n      )\n    )\n\n    get new_subscription_path(plan: \"monthly\")\n\n    assert_redirected_to \"https://checkout.stripe.com/c/pay/test-session-id\"\n    assert_equal \"test-customer-id\", @family.reload.stripe_customer_id\n  end\n\n  test \"creates active subscription on checkout success\" do\n    @mock_stripe.expects(:get_checkout_result).with(\"test-session-id\").returns(\n      OpenStruct.new(\n        success?: true,\n        subscription_id: \"test-subscription-id\"\n      )\n    )\n\n    get success_subscription_url(session_id: \"test-session-id\")\n\n    assert @family.subscription.active?\n    assert_equal \"Welcome to Maybe!  Your subscription has been created.\", flash[:notice]\n  end\nend\n"
  },
  {
    "path": "test/controllers/tag/deletions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Tag::DeletionsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n    @tag = tags(:one)\n  end\n\n  test \"should get new\" do\n    get new_tag_deletion_url(@tag)\n    assert_response :success\n  end\n\n  test \"create with replacement\" do\n    replacement_tag = tags(:two)\n\n    affected_transaction_count = @tag.transactions.count\n\n    assert affected_transaction_count > 0\n\n    assert_difference -> { Tag.count } => -1, -> { replacement_tag.transactions.count } => affected_transaction_count do\n      post tag_deletions_url(@tag), params: { replacement_tag_id: replacement_tag.id }\n    end\n  end\n\n  test \"create without replacement\" do\n    affected_transactions = @tag.transactions\n\n    assert affected_transactions.count > 0\n\n    assert_difference -> { Tag.count } => -1, -> { Tagging.count } => affected_transactions.count * -1 do\n      post tag_deletions_url(@tag)\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/tags_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass TagsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"should get index\" do\n    get tags_url\n    assert_response :success\n\n    @user.family.tags.each do |tag|\n      assert_select \"#\" + dom_id(tag), count: 1\n    end\n  end\n\n  test \"should get new\" do\n    get new_tag_url\n    assert_response :success\n  end\n\n  test \"should create tag\" do\n    assert_difference(\"Tag.count\") do\n      post tags_url, params: { tag: { name: \"Test Tag\" } }\n    end\n\n    assert_redirected_to tags_url\n    assert_equal \"Tag created\", flash[:notice]\n  end\n\n  test \"should get edit\" do\n    get edit_tag_url(tags.first)\n    assert_response :success\n  end\n\n  test \"should update tag\" do\n    patch tag_url(tags.first), params: { tag: { name: \"Test Tag\" } }\n\n    assert_redirected_to tags_url\n    assert_equal \"Tag updated\", flash[:notice]\n  end\nend\n"
  },
  {
    "path": "test/controllers/trades_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass TradesControllerTest < ActionDispatch::IntegrationTest\n  include EntryableResourceInterfaceTest\n\n  setup do\n    sign_in @user = users(:family_admin)\n    @entry = entries(:trade)\n  end\n\n  test \"updates trade entry\" do\n    assert_no_difference [ \"Entry.count\", \"Trade.count\" ] do\n      patch trade_url(@entry), params: {\n        entry: {\n          currency: \"USD\",\n          entryable_attributes: {\n            id: @entry.entryable_id,\n            qty: 20,\n            price: 20\n          }\n        }\n      }\n    end\n\n    @entry.reload\n\n    assert_enqueued_with job: SyncJob\n\n    assert_equal 20, @entry.trade.qty\n    assert_equal 20, @entry.trade.price\n    assert_equal \"USD\", @entry.currency\n\n    assert_redirected_to account_url(@entry.account)\n  end\n\n  test \"creates deposit entry\" do\n    from_account = accounts(:depository) # Account the deposit is coming from\n\n    assert_difference -> { Entry.count } => 2,\n                      -> { Transaction.count } => 2,\n                      -> { Transfer.count } => 1 do\n      post trades_url(account_id: @entry.account_id), params: {\n        model: {\n          type: \"deposit\",\n          date: Date.current,\n          amount: 10,\n          currency: \"USD\",\n          transfer_account_id: from_account.id\n        }\n      }\n    end\n\n    assert_redirected_to @entry.account\n  end\n\n  test \"creates withdrawal entry\" do\n    to_account = accounts(:depository) # Account the withdrawal is going to\n\n    assert_difference -> { Entry.count } => 2,\n                      -> { Transaction.count } => 2,\n                      -> { Transfer.count } => 1 do\n      post trades_url(account_id: @entry.account_id), params: {\n        model: {\n          type: \"withdrawal\",\n          date: Date.current,\n          amount: 10,\n          currency: \"USD\",\n          transfer_account_id: to_account.id\n        }\n      }\n    end\n\n    assert_redirected_to @entry.account\n  end\n\n  test \"deposit and withdrawal has optional transfer account\" do\n    assert_difference -> { Entry.count } => 1,\n                      -> { Transaction.count } => 1,\n                      -> { Transfer.count } => 0 do\n      post trades_url(account_id: @entry.account_id), params: {\n        model: {\n          type: \"withdrawal\",\n          date: Date.current,\n          amount: 10,\n          currency: \"USD\"\n        }\n      }\n    end\n\n    created_entry = Entry.order(created_at: :desc).first\n\n    assert created_entry.amount.positive?\n    assert_redirected_to @entry.account\n  end\n\n  test \"creates interest entry\" do\n    assert_difference [ \"Entry.count\", \"Transaction.count\" ], 1 do\n      post trades_url(account_id: @entry.account_id), params: {\n        model: {\n          type: \"interest\",\n          date: Date.current,\n          amount: 10,\n          currency: \"USD\"\n        }\n      }\n    end\n\n    created_entry = Entry.order(created_at: :desc).first\n\n    assert created_entry.amount.negative?\n    assert_redirected_to @entry.account\n  end\n\n  test \"creates trade buy entry\" do\n    assert_difference [ \"Entry.count\", \"Trade.count\", \"Security.count\" ], 1 do\n      post trades_url(account_id: @entry.account_id), params: {\n        model: {\n          type: \"buy\",\n          date: Date.current,\n          ticker: \"NVDA (NASDAQ)\",\n          qty: 10,\n          price: 10,\n          currency: \"USD\"\n        }\n      }\n    end\n\n    created_entry = Entry.order(created_at: :desc).first\n\n    assert created_entry.amount.positive?\n    assert created_entry.trade.qty.positive?\n    assert_equal \"Entry created\", flash[:notice]\n    assert_enqueued_with job: SyncJob\n    assert_redirected_to account_url(created_entry.account)\n  end\n\n  test \"creates trade sell entry\" do\n    assert_difference [ \"Entry.count\", \"Trade.count\" ], 1 do\n      post trades_url(account_id: @entry.account_id), params: {\n        model: {\n          type: \"sell\",\n          ticker: \"AAPL (NYSE)\",\n          date: Date.current,\n          currency: \"USD\",\n          qty: 10,\n          price: 10\n        }\n      }\n    end\n\n    created_entry = Entry.order(created_at: :desc).first\n\n    assert created_entry.amount.negative?\n    assert created_entry.trade.qty.negative?\n    assert_equal \"Entry created\", flash[:notice]\n    assert_enqueued_with job: SyncJob\n    assert_redirected_to account_url(created_entry.account)\n  end\nend\n"
  },
  {
    "path": "test/controllers/transactions/bulk_deletions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Transactions::BulkDeletionsControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n    @entry = entries(:transaction)\n  end\n\n  test \"bulk delete\" do\n    transactions = @user.family.entries.transactions\n    delete_count = transactions.size\n\n    assert_difference([ \"Transaction.count\", \"Entry.count\" ], -delete_count) do\n      post transactions_bulk_deletion_url, params: {\n        bulk_delete: {\n          entry_ids: transactions.pluck(:id)\n        }\n      }\n    end\n\n    assert_redirected_to transactions_url\n    assert_equal \"#{delete_count} transactions deleted\", flash[:notice]\n  end\nend\n"
  },
  {
    "path": "test/controllers/transactions/bulk_updates_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass Transactions::BulkUpdatesControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"bulk update\" do\n    transactions = @user.family.entries.transactions\n\n    assert_difference [ \"Entry.count\", \"Transaction.count\" ], 0 do\n      post transactions_bulk_update_url, params: {\n        bulk_update: {\n          entry_ids: transactions.map(&:id),\n          date: 1.day.ago.to_date,\n          category_id: Category.second.id,\n          merchant_id: Merchant.second.id,\n          tag_ids: [ Tag.first.id, Tag.second.id ],\n          notes: \"Updated note\"\n        }\n      }\n    end\n\n    assert_redirected_to transactions_url\n    assert_equal \"#{transactions.count} transactions updated\", flash[:notice]\n\n    transactions.reload.each do |transaction|\n      assert_equal 1.day.ago.to_date, transaction.date\n      assert_equal Category.second, transaction.transaction.category\n      assert_equal Merchant.second, transaction.transaction.merchant\n      assert_equal \"Updated note\", transaction.notes\n      assert_equal [ Tag.first.id, Tag.second.id ], transaction.entryable.tag_ids.sort\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/transactions_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass TransactionsControllerTest < ActionDispatch::IntegrationTest\n  include EntryableResourceInterfaceTest, EntriesTestHelper\n\n  setup do\n    sign_in @user = users(:family_admin)\n    @entry = entries(:transaction)\n  end\n\n  test \"creates with transaction details\" do\n    assert_difference [ \"Entry.count\", \"Transaction.count\" ], 1 do\n      post transactions_url, params: {\n        entry: {\n          account_id: @entry.account_id,\n          name: \"New transaction\",\n          date: Date.current,\n          currency: \"USD\",\n          amount: 100,\n          nature: \"inflow\",\n          entryable_type: @entry.entryable_type,\n          entryable_attributes: {\n            tag_ids: [ Tag.first.id, Tag.second.id ],\n            category_id: Category.first.id,\n            merchant_id: Merchant.first.id\n          }\n        }\n      }\n    end\n\n    created_entry = Entry.order(:created_at).last\n\n    assert_redirected_to account_url(created_entry.account)\n    assert_equal \"Transaction created\", flash[:notice]\n    assert_enqueued_with(job: SyncJob)\n  end\n\n  test \"updates with transaction details\" do\n    assert_no_difference [ \"Entry.count\", \"Transaction.count\" ] do\n      patch transaction_url(@entry), params: {\n        entry: {\n          name: \"Updated name\",\n          date: Date.current,\n          currency: \"USD\",\n          amount: 100,\n          nature: \"inflow\",\n          entryable_type: @entry.entryable_type,\n          notes: \"test notes\",\n          excluded: false,\n          entryable_attributes: {\n            id: @entry.entryable_id,\n            tag_ids: [ Tag.first.id, Tag.second.id ],\n            category_id: Category.first.id,\n            merchant_id: Merchant.first.id\n          }\n        }\n      }\n    end\n\n    @entry.reload\n\n    assert_equal \"Updated name\", @entry.name\n    assert_equal Date.current, @entry.date\n    assert_equal \"USD\", @entry.currency\n    assert_equal -100, @entry.amount\n    assert_equal [ Tag.first.id, Tag.second.id ], @entry.entryable.tag_ids.sort\n    assert_equal Category.first.id, @entry.entryable.category_id\n    assert_equal Merchant.first.id, @entry.entryable.merchant_id\n    assert_equal \"test notes\", @entry.notes\n    assert_equal false, @entry.excluded\n\n    assert_equal \"Transaction updated\", flash[:notice]\n    assert_redirected_to account_url(@entry.account)\n    assert_enqueued_with(job: SyncJob)\n  end\n\n  test \"transaction count represents filtered total\" do\n    family = families(:empty)\n    sign_in users(:empty)\n    account = family.accounts.create! name: \"Test\", balance: 0, currency: \"USD\", accountable: Depository.new\n\n    3.times do\n      create_transaction(account: account)\n    end\n\n    get transactions_url(per_page: 10)\n\n    assert_dom \"#total-transactions\", count: 1, text: family.entries.transactions.size.to_s\n\n    searchable_transaction = create_transaction(account: account, name: \"Unique test name\")\n\n    get transactions_url(q: { search: searchable_transaction.name })\n\n    # Only finds 1 transaction that matches filter\n    assert_dom \"#\" + dom_id(searchable_transaction), count: 1\n    assert_dom \"#total-transactions\", count: 1, text: \"1\"\n  end\n\n  test \"can paginate\" do\n  family = families(:empty)\n  sign_in users(:empty)\n\n  # Clean up any existing entries to ensure clean test\n  family.accounts.each { |account| account.entries.delete_all }\n\n  account = family.accounts.create! name: \"Test\", balance: 0, currency: \"USD\", accountable: Depository.new\n\n  # Create multiple transactions for pagination\n  25.times do |i|\n    create_transaction(\n      account: account,\n      name: \"Transaction #{i + 1}\",\n      amount: 100 + i,  # Different amounts to prevent transfer matching\n      date: Date.current - i.days  # Different dates\n    )\n  end\n\n  total_transactions = family.entries.transactions.count\n  assert_operator total_transactions, :>=, 20, \"Should have at least 20 transactions for testing\"\n\n  # Test page 1 - should show limited transactions\n  get transactions_url(page: 1, per_page: 10)\n  assert_response :success\n\n  page_1_count = css_select(\"turbo-frame[id^='entry_']\").count\n  assert_equal 10, page_1_count, \"Page 1 should respect per_page limit\"\n\n  # Test page 2 - should show different transactions\n  get transactions_url(page: 2, per_page: 10)\n  assert_response :success\n\n  page_2_count = css_select(\"turbo-frame[id^='entry_']\").count\n  assert_operator page_2_count, :>, 0, \"Page 2 should show some transactions\"\n  assert_operator page_2_count, :<=, 10, \"Page 2 should not exceed per_page limit\"\n\n  # Test Pagy overflow handling - should redirect or handle gracefully\n  get transactions_url(page: 9999999, per_page: 10)\n\n  # Either success (if Pagy shows last page) or redirect (if Pagy redirects)\n  assert_includes [ 200, 302 ], response.status, \"Pagy should handle overflow gracefully\"\n\n  if response.status == 302\n    follow_redirect!\n    assert_response :success\n  end\n\n  overflow_count = css_select(\"turbo-frame[id^='entry_']\").count\n  assert_operator overflow_count, :>, 0, \"Overflow should show some transactions\"\nend\n\n  test \"calls Transaction::Search totals method with correct search parameters\" do\n    family = families(:empty)\n    sign_in users(:empty)\n    account = family.accounts.create! name: \"Test\", balance: 0, currency: \"USD\", accountable: Depository.new\n\n    create_transaction(account: account, amount: 100)\n\n    search = Transaction::Search.new(family)\n    totals = OpenStruct.new(\n      count: 1,\n      expense_money: Money.new(10000, \"USD\"),\n      income_money: Money.new(0, \"USD\")\n    )\n\n    Transaction::Search.expects(:new).with(family, filters: {}).returns(search)\n    search.expects(:totals).once.returns(totals)\n\n    get transactions_url\n    assert_response :success\n  end\n\n  test \"calls Transaction::Search totals method with filtered search parameters\" do\n    family = families(:empty)\n    sign_in users(:empty)\n    account = family.accounts.create! name: \"Test\", balance: 0, currency: \"USD\", accountable: Depository.new\n    category = family.categories.create! name: \"Food\", color: \"#ff0000\"\n\n    create_transaction(account: account, amount: 100, category: category)\n\n    search = Transaction::Search.new(family, filters: { \"categories\" => [ \"Food\" ], \"types\" => [ \"expense\" ] })\n    totals = OpenStruct.new(\n      count: 1,\n      expense_money: Money.new(10000, \"USD\"),\n      income_money: Money.new(0, \"USD\")\n    )\n\n    Transaction::Search.expects(:new).with(family, filters: { \"categories\" => [ \"Food\" ], \"types\" => [ \"expense\" ] }).returns(search)\n    search.expects(:totals).once.returns(totals)\n\n    get transactions_url(q: { categories: [ \"Food\" ], types: [ \"expense\" ] })\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/controllers/transfer_matches_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass TransferMatchesControllerTest < ActionDispatch::IntegrationTest\n  include EntriesTestHelper\n\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"matches existing transaction and creates transfer\" do\n    inflow_transaction = create_transaction(amount: 100, account: accounts(:depository))\n    outflow_transaction = create_transaction(amount: -100, account: accounts(:investment))\n\n    assert_difference \"Transfer.count\", 1 do\n      post transaction_transfer_match_path(inflow_transaction), params: {\n        transfer_match: {\n          method: \"existing\",\n          matched_entry_id: outflow_transaction.id\n        }\n      }\n    end\n\n    assert_redirected_to transactions_url\n    assert_equal \"Transfer created\", flash[:notice]\n  end\n\n  test \"creates transfer for target account\" do\n    inflow_transaction = create_transaction(amount: 100, account: accounts(:depository))\n\n    assert_difference [ \"Transfer.count\", \"Entry.count\", \"Transaction.count\" ], 1 do\n      post transaction_transfer_match_path(inflow_transaction), params: {\n        transfer_match: {\n          method: \"new\",\n          target_account_id: accounts(:investment).id\n        }\n      }\n    end\n\n    assert_redirected_to transactions_url\n    assert_equal \"Transfer created\", flash[:notice]\n  end\nend\n"
  },
  {
    "path": "test/controllers/transfers_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass TransfersControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in users(:family_admin)\n  end\n\n  test \"should get new\" do\n    get new_transfer_url\n    assert_response :success\n  end\n\n  test \"can create transfers\" do\n    assert_difference \"Transfer.count\", 1 do\n      post transfers_url, params: {\n        transfer: {\n          from_account_id: accounts(:depository).id,\n          to_account_id: accounts(:credit_card).id,\n          date: Date.current,\n          amount: 100,\n          name: \"Test Transfer\"\n        }\n      }\n      assert_enqueued_with job: SyncJob\n    end\n  end\n\n  test \"soft deletes transfer\" do\n    assert_difference -> { Transfer.count }, -1 do\n      delete transfer_url(transfers(:one))\n    end\n  end\n\n  test \"can add notes to transfer\" do\n    transfer = transfers(:one)\n    assert_nil transfer.notes\n\n    patch transfer_url(transfer), params: { transfer: { notes: \"Test notes\" } }\n\n    assert_redirected_to transactions_url\n    assert_equal \"Transfer updated\", flash[:notice]\n    assert_equal \"Test notes\", transfer.reload.notes\n  end\n\n  test \"handles rejection without FrozenError\" do\n    transfer = transfers(:one)\n\n    assert_difference \"Transfer.count\", -1 do\n      patch transfer_url(transfer), params: {\n        transfer: {\n          status: \"rejected\"\n        }\n      }\n    end\n\n    assert_redirected_to transactions_url\n    assert_equal \"Transfer updated\", flash[:notice]\n\n    # Verify the transfer was actually destroyed\n    assert_raises(ActiveRecord::RecordNotFound) do\n      transfer.reload\n    end\n  end\nend\n"
  },
  {
    "path": "test/controllers/users_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass UsersControllerTest < ActionDispatch::IntegrationTest\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"can supply custom redirect after update\" do\n    patch user_url(@user), params: { user: { redirect_to: \"home\" } }\n    assert_redirected_to root_url\n  end\n\n  test \"can update user profile\" do\n    patch user_url(@user), params: {\n      user: {\n        first_name: \"John\",\n        last_name: \"Doe\",\n        onboarded_at: Time.current,\n        profile_image: file_fixture_upload(\"profile_image.png\", \"image/png\", :binary),\n        family_attributes: {\n          name: \"New Family Name\",\n          country: \"US\",\n          date_format: \"%m/%d/%Y\",\n          currency: \"USD\",\n          locale: \"en\"\n        }\n      }\n    }\n\n    assert_redirected_to settings_profile_url\n    assert_equal \"Your profile has been updated.\", flash[:notice]\n  end\n\n  test \"admin can reset family data\" do\n    account = accounts(:investment)\n    category = categories(:income)\n    tag = tags(:one)\n    merchant = merchants(:netflix)\n    import = imports(:transaction)\n    budget = budgets(:one)\n    plaid_item = plaid_items(:one)\n\n    Provider::Plaid.any_instance.expects(:remove_item).with(plaid_item.access_token).once\n\n    perform_enqueued_jobs(only: FamilyResetJob) do\n      delete reset_user_url(@user)\n    end\n\n    assert_redirected_to settings_profile_url\n    assert_equal I18n.t(\"users.reset.success\"), flash[:notice]\n\n    assert_not Account.exists?(account.id)\n    assert_not Category.exists?(category.id)\n    assert_not Tag.exists?(tag.id)\n    assert_not Merchant.exists?(merchant.id)\n    assert_not Import.exists?(import.id)\n    assert_not Budget.exists?(budget.id)\n    assert_not PlaidItem.exists?(plaid_item.id)\n  end\n\n  test \"non-admin cannot reset family data\" do\n    sign_in @member = users(:family_member)\n\n    delete reset_user_url(@member)\n\n    assert_redirected_to settings_profile_url\n    assert_equal I18n.t(\"users.reset.unauthorized\"), flash[:alert]\n    assert_no_enqueued_jobs only: FamilyResetJob\n  end\n\n  test \"member can deactivate their account\" do\n    sign_in @member = users(:family_member)\n    delete user_url(@member)\n\n    assert_redirected_to root_url\n\n    assert_not User.find(@member.id).active?\n    assert_enqueued_with(job: UserPurgeJob, args: [ @member ])\n  end\n\n  test \"admin prevented from deactivating when other users are present\" do\n    sign_in @admin = users(:family_admin)\n    delete user_url(users(:family_member))\n\n    assert_redirected_to settings_profile_url\n    assert_equal \"Admin cannot delete account while other users are present. Please delete all members first.\", flash[:alert]\n    assert_no_enqueued_jobs only: UserPurgeJob\n    assert User.find(@admin.id).active?\n  end\n\n  test \"admin can deactivate their account when they are the last user in the family\" do\n    sign_in @admin = users(:family_admin)\n    users(:family_member).destroy\n\n    delete user_url(@admin)\n\n    assert_redirected_to root_url\n    assert_not User.find(@admin.id).active?\n    assert_enqueued_with(job: UserPurgeJob, args: [ @admin ])\n  end\nend\n"
  },
  {
    "path": "test/controllers/valuations_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass ValuationsControllerTest < ActionDispatch::IntegrationTest\n  include EntryableResourceInterfaceTest\n\n  setup do\n    sign_in @user = users(:family_admin)\n    @entry = entries(:valuation)\n  end\n\n  test \"can create reconciliation\" do\n    account = accounts(:investment)\n\n    assert_difference [ \"Entry.count\", \"Valuation.count\" ], 1 do\n      post valuations_url, params: {\n        entry: {\n          amount: account.balance + 100,\n          date: Date.current.to_s,\n          account_id: account.id\n        }\n      }\n    end\n\n    created_entry = Entry.order(created_at: :desc).first\n    assert_equal \"Manual value update\", created_entry.name\n    assert_equal Date.current, created_entry.date\n    assert_equal account.balance + 100, created_entry.amount_money.to_f\n\n    assert_enqueued_with job: SyncJob\n\n    assert_redirected_to account_url(created_entry.account)\n  end\n\n  test \"updates entry with basic attributes\" do\n    assert_no_difference [ \"Entry.count\", \"Valuation.count\" ] do\n      patch valuation_url(@entry), params: {\n        entry: {\n          amount: 22000,\n          date: Date.current,\n          notes: \"Test notes\"\n        }\n      }\n    end\n\n    assert_enqueued_with job: SyncJob\n\n    assert_redirected_to account_url(@entry.account)\n\n    @entry.reload\n    assert_equal 22000, @entry.amount\n    assert_equal \"Test notes\", @entry.notes\n  end\nend\n"
  },
  {
    "path": "test/controllers/vehicles_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass VehiclesControllerTest < ActionDispatch::IntegrationTest\n  include AccountableResourceInterfaceTest\n\n  setup do\n    sign_in @user = users(:family_admin)\n    @account = accounts(:vehicle)\n  end\n\n  test \"creates with vehicle details\" do\n    assert_difference -> { Account.count } => 1,\n      -> { Vehicle.count } => 1,\n      -> { Valuation.count } => 1,\n      -> { Entry.count } => 1 do\n      post vehicles_path, params: {\n        account: {\n          name: \"Vehicle\",\n          balance: 30000,\n          currency: \"USD\",\n          accountable_type: \"Vehicle\",\n          accountable_attributes: {\n            make: \"Toyota\",\n            model: \"Camry\",\n            year: 2020,\n            mileage_value: 15000,\n            mileage_unit: \"mi\"\n          }\n        }\n      }\n    end\n\n    created_account = Account.order(:created_at).last\n\n    assert_equal \"Toyota\", created_account.accountable.make\n    assert_equal \"Camry\", created_account.accountable.model\n    assert_equal 2020, created_account.accountable.year\n    assert_equal 15000, created_account.accountable.mileage_value\n    assert_equal \"mi\", created_account.accountable.mileage_unit\n\n    assert_redirected_to created_account\n    assert_equal \"Vehicle account created\", flash[:notice]\n    assert_enqueued_with(job: SyncJob)\n  end\n\n  test \"updates with vehicle details\" do\n    assert_no_difference [ \"Account.count\", \"Vehicle.count\" ] do\n      patch vehicle_path(@account), params: {\n        account: {\n          name: \"Updated Vehicle\",\n          balance: 28000,\n          currency: \"USD\",\n          accountable_type: \"Vehicle\",\n          accountable_attributes: {\n            id: @account.accountable_id,\n            make: \"Honda\",\n            model: \"Accord\",\n            year: 2021,\n            mileage_value: 20000,\n            mileage_unit: \"mi\",\n            purchase_price: 32000\n          }\n        }\n      }\n    end\n\n    assert_redirected_to account_path(@account)\n    assert_equal \"Vehicle account updated\", flash[:notice]\n    assert_enqueued_with(job: SyncJob)\n  end\nend\n"
  },
  {
    "path": "test/controllers/webhooks_controller_test.rb",
    "content": "require \"test_helper\"\n\nclass WebhooksControllerTest < ActionDispatch::IntegrationTest\n  # test \"the truth\" do\n  #   assert true\n  # end\nend\n"
  },
  {
    "path": "test/data_migrations/balance_component_migrator_test.rb",
    "content": "require \"test_helper\"\n\nclass BalanceComponentMigratorTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @depository = accounts(:depository)\n    @investment = accounts(:investment)\n    @loan = accounts(:loan)\n\n    # Start fresh\n    Balance.delete_all\n  end\n\n  test \"depository account with no gaps\" do\n    create_balance_history(@depository, [\n      { date: 5.days.ago, cash_balance: 1000, balance: 1000 },\n      { date: 4.days.ago, cash_balance: 1100, balance: 1100 },\n      { date: 3.days.ago, cash_balance: 1050, balance: 1050 },\n      { date: 2.days.ago, cash_balance: 1200, balance: 1200 },\n      { date: 1.day.ago, cash_balance: 1150, balance: 1150 }\n    ])\n\n    BalanceComponentMigrator.run\n\n    assert_migrated_balances @depository, [\n      { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },\n      { date: 4.days.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 100, non_cash_inflows: 0, end_cash: 1100, end_non_cash: 0, end: 1100 },\n      { date: 3.days.ago, start_cash: 1100, start_non_cash: 0, start: 1100, cash_inflows: -50, non_cash_inflows: 0, end_cash: 1050, end_non_cash: 0, end: 1050 },\n      { date: 2.days.ago, start_cash: 1050, start_non_cash: 0, start: 1050, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1200, end_non_cash: 0, end: 1200 },\n      { date: 1.day.ago, start_cash: 1200, start_non_cash: 0, start: 1200, cash_inflows: -50, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 }\n    ]\n  end\n\n  test \"depository account with gaps\" do\n    create_balance_history(@depository, [\n      { date: 5.days.ago, cash_balance: 1000, balance: 1000 },\n      { date: 1.day.ago, cash_balance: 1150, balance: 1150 }\n    ])\n\n    BalanceComponentMigrator.run\n\n    assert_migrated_balances @depository, [\n      { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },\n      { date: 1.day.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 }\n    ]\n  end\n\n  test \"investment account with no gaps\" do\n    create_balance_history(@investment, [\n      { date: 3.days.ago, cash_balance: 100, balance: 200 },\n      { date: 2.days.ago, cash_balance: 200, balance: 300 },\n      { date: 1.day.ago, cash_balance: 0, balance: 300 }\n    ])\n\n    BalanceComponentMigrator.run\n\n    assert_migrated_balances @investment, [\n      { date: 3.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 100, non_cash_inflows: 100, end_cash: 100, end_non_cash: 100, end: 200 },\n      { date: 2.days.ago, start_cash: 100, start_non_cash: 100, start: 200, cash_inflows: 100, non_cash_inflows: 0, end_cash: 200, end_non_cash: 100, end: 300 },\n      { date: 1.day.ago, start_cash: 200, start_non_cash: 100, start: 300, cash_inflows: -200, non_cash_inflows: 200, end_cash: 0, end_non_cash: 300, end: 300 }\n    ]\n  end\n\n  test \"investment account with gaps\" do\n    create_balance_history(@investment, [\n      { date: 5.days.ago, cash_balance: 1000, balance: 1000 },\n      { date: 1.day.ago, cash_balance: 1150, balance: 1150 }\n    ])\n\n    BalanceComponentMigrator.run\n\n    assert_migrated_balances @investment, [\n      { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },\n      { date: 1.day.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 }\n    ]\n  end\n\n  # Negative flows factor test\n  test \"loan account with no gaps\" do\n    create_balance_history(@loan, [\n      { date: 3.days.ago, cash_balance: 0, balance: 200 },\n      { date: 2.days.ago, cash_balance: 0, balance: 300 },\n      { date: 1.day.ago, cash_balance: 0, balance: 500 }\n    ])\n\n    BalanceComponentMigrator.run\n\n    assert_migrated_balances @loan, [\n      { date: 3.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 0, non_cash_inflows: -200, end_cash: 0, end_non_cash: 200, end: 200 },\n      { date: 2.days.ago, start_cash: 0, start_non_cash: 200, start: 200, cash_inflows: 0, non_cash_inflows: -100, end_cash: 0, end_non_cash: 300, end: 300 },\n      { date: 1.day.ago, start_cash: 0, start_non_cash: 300, start: 300, cash_inflows: 0, non_cash_inflows: -200, end_cash: 0, end_non_cash: 500, end: 500 }\n    ]\n  end\n\n  test \"loan account with gaps\" do\n    create_balance_history(@loan, [\n      { date: 5.days.ago, cash_balance: 0, balance: 1000 },\n      { date: 1.day.ago, cash_balance: 0, balance: 2000 }\n    ])\n\n    BalanceComponentMigrator.run\n\n    assert_migrated_balances @loan, [\n      { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 0, non_cash_inflows: -1000, end_cash: 0, end_non_cash: 1000, end: 1000 },\n      { date: 1.day.ago, start_cash: 0, start_non_cash: 1000, start: 1000, cash_inflows: 0, non_cash_inflows: -1000, end_cash: 0, end_non_cash: 2000, end: 2000 }\n    ]\n  end\n\n  private\n    def create_balance_history(account, balances)\n      balances.each do |balance|\n        account.balances.create!(\n          date: balance[:date].to_date,\n          balance: balance[:balance],\n          cash_balance: balance[:cash_balance],\n          currency: account.currency\n        )\n      end\n    end\n\n    def assert_migrated_balances(account, expected)\n      balances = account.balances.order(:date)\n\n      expected.each_with_index do |expected_values, index|\n        balance = balances.find { |b| b.date == expected_values[:date].to_date }\n        assert balance, \"Expected balance for #{expected_values[:date].to_date} but none found\"\n\n        # Assert expected values\n        assert_equal expected_values[:start_cash], balance.start_cash_balance,\n          \"start_cash_balance mismatch for #{balance.date}\"\n        assert_equal expected_values[:start_non_cash], balance.start_non_cash_balance,\n          \"start_non_cash_balance mismatch for #{balance.date}\"\n        assert_equal expected_values[:start], balance.start_balance,\n          \"start_balance mismatch for #{balance.date}\"\n        assert_equal expected_values[:cash_inflows], balance.cash_inflows,\n          \"cash_inflows mismatch for #{balance.date}\"\n        assert_equal expected_values[:non_cash_inflows], balance.non_cash_inflows,\n          \"non_cash_inflows mismatch for #{balance.date}\"\n        assert_equal expected_values[:end_cash], balance.end_cash_balance,\n          \"end_cash_balance mismatch for #{balance.date}\"\n        assert_equal expected_values[:end_non_cash], balance.end_non_cash_balance,\n          \"end_non_cash_balance mismatch for #{balance.date}\"\n        assert_equal expected_values[:end], balance.end_balance,\n          \"end_balance mismatch for #{balance.date}\"\n\n        # Assert zeros for other fields\n        assert_equal 0, balance.cash_outflows,\n          \"cash_outflows should be zero for #{balance.date}\"\n        assert_equal 0, balance.non_cash_outflows,\n          \"non_cash_outflows should be zero for #{balance.date}\"\n        assert_equal 0, balance.cash_adjustments,\n          \"cash_adjustments should be zero for #{balance.date}\"\n        assert_equal 0, balance.non_cash_adjustments,\n          \"non_cash_adjustments should be zero for #{balance.date}\"\n        assert_equal 0, balance.net_market_flows,\n          \"net_market_flows should be zero for #{balance.date}\"\n      end\n    end\nend\n"
  },
  {
    "path": "test/fixtures/accounts.yml",
    "content": "other_asset:\n  family: dylan_family\n  name: Collectable Account\n  balance: 550\n  currency: USD\n  accountable_type: OtherAsset\n  accountable: one\n  status: active\n\nother_liability:\n  family: dylan_family\n  name: IOU (personal debt to friend)\n  balance: 200\n  currency: USD\n  accountable_type: OtherLiability\n  accountable: one\n  status: active\n\ndepository:\n  family: dylan_family\n  name: Checking Account\n  balance: 5000\n  currency: USD\n  accountable_type: Depository\n  accountable: one\n  status: active\n\nconnected:\n  family: dylan_family\n  name: Plaid Depository Account\n  balance: 5000\n  currency: USD\n  subtype: checking\n  accountable_type: Depository\n  accountable: two\n  plaid_account: one\n  status: active\n\ncredit_card:\n  family: dylan_family\n  name: Credit Card\n  balance: 1000\n  currency: USD\n  accountable_type: CreditCard\n  accountable: one\n  status: active\n\ninvestment:\n  family: dylan_family\n  name: Robinhood Brokerage Account\n  balance: 10000\n  cash_balance: 5000\n  currency: USD\n  accountable_type: Investment\n  accountable: one\n  status: active\n\nloan:\n  family: dylan_family\n  name: Mortgage Loan\n  balance: 500000\n  currency: USD\n  accountable_type: Loan\n  accountable: one\n  status: active\n\nproperty:\n  family: dylan_family\n  name: 123 Maybe Court\n  balance: 550000\n  currency: USD\n  accountable_type: Property\n  accountable: one\n  status: active\n\nvehicle:\n  family: dylan_family\n  name: Honda Accord\n  balance: 18000\n  currency: USD\n  accountable_type: Vehicle\n  accountable: one\n  status: active\n\ncrypto:\n  family: dylan_family\n  name: Bitcoin\n  balance: 10000\n  currency: USD\n  accountable_type: Crypto\n  accountable: one\n  status: active\n"
  },
  {
    "path": "test/fixtures/active_storage/attachments.yml",
    "content": "chase_logo_attachment:\n  name: logo\n  record: chase (Institution)\n  blob: square_placeholder_blob\n"
  },
  {
    "path": "test/fixtures/active_storage/blobs.yml",
    "content": "square_placeholder_blob: <%= ActiveStorage::FixtureSet.blob filename: \"square-placeholder.png\" %>"
  },
  {
    "path": "test/fixtures/addresses.yml",
    "content": "one:\n  line1: 123 Main Street\n  line2: Apt 4B\n  locality: Los Angeles\n  region: CA\n  country: US\n  postal_code: 90001\n  addressable: one\n  addressable_type: Property\n"
  },
  {
    "path": "test/fixtures/api_keys.yml",
    "content": "# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html\n\nactive_key:\n  display_key: \"test_key_123\"\n  name: \"Production API Key\"\n  user: family_admin\n  scopes: [\"read_write\"]\n  last_used_at: <%= 1.hour.ago %>\n  expires_at: <%= 1.year.from_now %>\n  revoked_at: null\n\nexpired_key:\n  display_key: \"expired_key_456\"\n  name: \"Expired API Key\"\n  user: family_member\n  scopes: [\"read\"]\n  last_used_at: <%= 1.week.ago %>\n  expires_at: <%= 1.day.ago %>\n  revoked_at: null\n\nrevoked_key:\n  display_key: \"revoked_key_789\"\n  name: \"Revoked API Key\"\n  user: family_admin\n  scopes: [\"read_write\"]\n  last_used_at: <%= 1.day.ago %>\n  expires_at: null\n  revoked_at: <%= 1.hour.ago %>\n\none:\n  id: <%= SecureRandom.uuid %>\n  user: family_admin\n  name: \"Test API Key\"\n  display_key: \"test_one_key_123\"\n  scopes: [ \"read\" ]\n\ntwo:\n  id: <%= SecureRandom.uuid %>\n  user: family_admin\n  name: \"Second API Key\"\n  display_key: \"test_two_key_456\"\n  scopes: [ \"read_write\" ]\n  revoked_at: <%= 1.day.ago %>\n"
  },
  {
    "path": "test/fixtures/balances.yml",
    "content": "one:\n  date: <%= 2.days.ago.to_date %>\n  balance: 4990\n  currency: USD\n  account: depository\n\ntwo:\n  date: <%= 1.day.ago.to_date %>\n  balance: 4980\n  currency: USD\n  account: depository"
  },
  {
    "path": "test/fixtures/budgets.yml",
    "content": "one:\n  family: dylan_family\n  start_date: <%= Date.current.beginning_of_month %>\n  end_date: <%= Date.current.end_of_month %>\n  budgeted_spending: 5000\n  expected_income: 7000 \n  currency: USD\n"
  },
  {
    "path": "test/fixtures/categories.yml",
    "content": "one:\n  name: Test\n  family: empty\n\nincome:\n  name: Income\n  color: \"#fd7f6f\"\n  family: dylan_family\n\nfood_and_drink:\n  name: Food & Drink\n  family: dylan_family\n\nsubcategory:\n  name: Restaurants\n  parent: food_and_drink\n  family: dylan_family\n"
  },
  {
    "path": "test/fixtures/chats.yml",
    "content": "one:\n  title: First Chat\n  user: family_admin\n\ntwo:\n  title: Second Chat\n  user: family_member"
  },
  {
    "path": "test/fixtures/credit_cards.yml",
    "content": "one:\n  available_credit: 5000.00\n  minimum_payment: 100.00\n  apr: 18.99\n  expiration_date: <%= 4.years.from_now.to_date %>\n  annual_fee: 95.00\n  "
  },
  {
    "path": "test/fixtures/cryptos.yml",
    "content": "one: { }"
  },
  {
    "path": "test/fixtures/depositories.yml",
    "content": "one: { }\ntwo: {}"
  },
  {
    "path": "test/fixtures/entries.yml",
    "content": "valuation:\n  name: Manual valuation\n  date: <%= 4.days.ago.to_date %>\n  amount: 4995\n  currency: USD\n  account: depository\n  entryable_type: Valuation\n  entryable: one\n\ntrade:\n  name: Purchase 10 shares of AAPL\n  date: <%= 1.day.ago.to_date %>\n  amount: 2140 # 10 shares * $214 per share\n  currency: USD\n  account: investment\n  entryable_type: Trade\n  entryable: one\n\ntransaction:\n  name: Starbucks\n  date: <%= 1.day.ago.to_date %>\n  amount: 10\n  currency: USD\n  account: depository\n  entryable_type: Transaction\n  entryable: one\n\ntransfer_out:\n  name: Payment to credit card account\n  date: <%= 3.days.ago.to_date %>\n  amount: 100\n  currency: USD\n  account: depository\n  entryable_type: Transaction\n  entryable: transfer_out\n\ntransfer_in:\n  name: Payment received from checking account\n  date: <%= 3.days.ago.to_date %>\n  amount: -100\n  currency: USD\n  account: credit_card\n  entryable_type: Transaction\n  entryable: transfer_in\n"
  },
  {
    "path": "test/fixtures/exchange_rates.yml",
    "content": "one:\n  from_currency: EUR\n  to_currency: GBP\n  rate: 1.0986\n  date: <%= Date.current %>\n\ntwo:\n  from_currency: EUR\n  to_currency: GBP\n  rate: 1.0926\n  date: <%= 1.day.ago.to_date %>\n"
  },
  {
    "path": "test/fixtures/families.yml",
    "content": "empty:\n  name: Family\n\ndylan_family:\n  name: The Dylan Family\n"
  },
  {
    "path": "test/fixtures/family_exports.yml",
    "content": "# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html\n\n# Empty file - no fixtures needed, tests create them dynamically\n"
  },
  {
    "path": "test/fixtures/files/imports/accounts.csv",
    "content": "type,name,amount,currency\nChecking,Main Checking Account,5000.00,USD\nSavings,Emergency Fund,10000.00,USD\nCredit Card,Rewards Credit Card,1500.00,USD\nInvestment,Retirement Portfolio,75000.00,USD\n"
  },
  {
    "path": "test/fixtures/files/imports/invalid.csv",
    "content": "name,description,amount,currency"
  },
  {
    "path": "test/fixtures/files/imports/mint.csv",
    "content": "Date,Description,Original Description,Amount,Transaction Type,Category,Account Name,Labels,Notes\n05/01/2023,Grocery Store,SAFEWAY #1234,78.32,debit,Groceries,Checking Account,,\n05/02/2023,Gas Station,SHELL OIL 57442893,-45.67,credit,Gas & Fuel,Credit Card,,\n05/03/2023,Monthly Rent,AUTOPAY MORTGAGE,1500.00,debit,Mortgage & Rent,Checking Account,,\n05/04/2023,Paycheck,ACME CORP PAYROLL,-2500.00,credit,Paycheck,Checking Account,Income,\n05/05/2023,Restaurant,CHIPOTLE MEX GRILL,15.75,debit,Restaurants,Credit Card,,\n05/06/2023,Online Shopping,AMAZON.COM,32.99,debit,Shopping,Credit Card,,\n05/07/2023,Utility Bill,CITY POWER & LIGHT,89.50,debit,Utilities,Checking Account,,\n05/08/2023,Coffee Shop,STARBUCKS,4.25,debit,Coffee Shops,Credit Card,,\n05/09/2023,Gym Membership,FITNESS WORLD,49.99,debit,Gym,Checking Account,Health,Monthly membership\n05/10/2023,Movie Theater,AMC THEATERS #123,24.50,debit,Movies & DVDs,Credit Card,Entertainment,\n"
  },
  {
    "path": "test/fixtures/files/imports/trades.csv",
    "content": "date,ticker,qty,price,amount,account,name\n2023-01-15,AAPL,10,150.25,1502.50,Brokerage Account,Buy Apple Inc\n2023-02-03,GOOGL,5,2100.75,10503.75,Retirement Account,Buy Alphabet Inc\n2023-03-10,MSFT,15,245.50,3682.50,Brokerage Account,Buy Microsoft Corp\n2023-04-05,AMZN,8,3200.00,25600.00,Brokerage Account,Buy Amazon.com Inc\n2023-05-20,TSLA,20,180.75,3615.00,Retirement Account,Buy Tesla Inc\n2023-06-15,AAPL,-5,170.50,-852.50,Brokerage Account,Sell Apple Inc\n2023-07-02,GOOGL,-2,2250.00,-4500.00,Retirement Account,Sell Alphabet Inc\n2023-08-18,NVDA,12,450.25,5403.00,Brokerage Account,Buy NVIDIA Corp\n2023-09-07,MSFT,-7,300.75,-2105.25,Brokerage Account,Sell Microsoft Corp\n2023-10-01,META,25,310.50,7762.50,Retirement Account,Buy Meta Platforms Inc\n"
  },
  {
    "path": "test/fixtures/files/imports/transactions.csv",
    "content": "Date,Name,Amount,Category,Tags,Account,Notes\n2023-05-01,Grocery Store,-89.75,Food,Groceries,Checking Account,Weekly grocery shopping\n2023-05-03,Electric Company,-120.50,Utilities,Bills|Home,Credit Card,Monthly electricity bill\n2023-05-05,Coffee Shop,-4.25,Food,Coffee|Work,Debit Card,Morning coffee\n2023-05-07,Gas Station,-45.00,Transportation,Car|Fuel,Credit Card,Fill up car tank\n2023-05-10,Online Retailer,-79.99,Shopping,Clothing,Credit Card,New shoes purchase\n2023-05-12,Restaurant,-65.30,Food,Dining Out|Date Night,Checking Account,Dinner with partner\n2023-05-15,Mobile Phone Provider,-55.00,Utilities,Bills|Communication,Debit Card,Monthly phone bill\n2023-05-18,Movie Theater,-24.00,Entertainment,Movies,Credit Card,Weekend movie night\n2023-05-20,Pharmacy,-32.50,Health,Medicine,Debit Card,Prescription refill\n"
  },
  {
    "path": "test/fixtures/files/imports/valid.csv",
    "content": "date,Custom Name Column,category,amount\ninvalid_date,Starbucks drink,Food,-20.50\n2024-01-01,Amazon purchase,Shopping,-89.50\n"
  },
  {
    "path": "test/fixtures/holdings.yml",
    "content": "one:\n  account: investment\n  security: aapl\n  date: <%= Date.current %>\n  qty: 10\n  price: 215\n  amount: 2150 # 10 * $215\n  currency: USD\n\ntwo:\n  account: investment\n  security: aapl\n  date: <%= 1.day.ago.to_date %>\n  qty: 10\n  price: 214\n  amount: 2140 # 10 * $214\n  currency: USD\n"
  },
  {
    "path": "test/fixtures/impersonation_session_logs.yml",
    "content": "# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html\n\n# This model initially had no columns defined. If you add columns to the\n# model remove the \"{}\" from the fixture names and add the columns immediately\n# below each fixture, per the syntax in the comments below\n#\n#one: {}\n# column: value\n#\n#two: {}\n# column: value\n"
  },
  {
    "path": "test/fixtures/impersonation_sessions.yml",
    "content": "in_progress:\n  impersonator: maybe_support_staff\n  impersonated: family_member\n  status: in_progress\n"
  },
  {
    "path": "test/fixtures/import/mappings.yml",
    "content": "one:\n  import: transaction \n  key: Food \n  type: Import::CategoryMapping \n  mappable: food_and_drink \n  mappable_type: Category \n"
  },
  {
    "path": "test/fixtures/import/rows.yml",
    "content": "one:\n  import: transaction\n  date: 01/01/2024\n  amount: 100\n  currency: USD"
  },
  {
    "path": "test/fixtures/imports.yml",
    "content": "transaction:\n  family: dylan_family\n  type: TransactionImport\n  status: pending\n\ntrade:\n  family: dylan_family\n  type: TradeImport\n  status: pending\n\naccount:\n  family: dylan_family\n  type: AccountImport\n  status: pending\n"
  },
  {
    "path": "test/fixtures/investments.yml",
    "content": "one: {}"
  },
  {
    "path": "test/fixtures/invitations.yml",
    "content": "one:\n  email: \"test@example.com\"\n  token: \"valid-token-123\"\n  role: \"member\"\n  inviter: family_admin\n  family: dylan_family\n  created_at: <%= Time.current %>\n  updated_at: <%= Time.current %>\n  expires_at: <%= 3.days.from_now %>\n\ntwo:\n  email: \"another@example.com\"\n  token: \"valid-token-456\"\n  role: \"admin\"\n  inviter: family_admin\n  family: dylan_family\n  created_at: <%= Time.current %>\n  updated_at: <%= Time.current %>\n  expires_at: <%= 3.days.from_now %>\n\nother_family:\n  email: \"other@example.com\"\n  token: \"valid-token-789\"\n  role: \"member\"\n  inviter: empty\n  family: empty\n  created_at: <%= Time.current %>\n  updated_at: <%= Time.current %>\n  expires_at: <%= 3.days.from_now %>\n"
  },
  {
    "path": "test/fixtures/loans.yml",
    "content": "one:\n  interest_rate: 3.5\n  term_months: 360\n  rate_type: fixed\n"
  },
  {
    "path": "test/fixtures/merchants.yml",
    "content": "one:\n  type: FamilyMerchant\n  name: Test\n  family: empty\n\nnetflix:\n  type: FamilyMerchant\n  name: Netflix\n  color: \"#fd7f6f\"\n  family: dylan_family\n\namazon:\n  type: FamilyMerchant\n  name: Amazon\n  color: \"#fd7f6f\"\n  family: dylan_family\n"
  },
  {
    "path": "test/fixtures/messages.yml",
    "content": "chat1_developer:\n  type: DeveloperMessage\n  content: You are a personal finance assistant.  Be concise and helpful.\n  chat: one\n  created_at: 2025-03-20 12:00:00\n  debug: false\n\nchat1_developer_debug:\n  type: DeveloperMessage\n  content: An internal debug message\n  chat: one\n  created_at: 2025-03-20 12:00:02\n  debug: true\n\nchat1_user:\n  type: UserMessage\n  content: Can you help me understand my spending habits?\n  chat: one\n  ai_model: gpt-4.1\n  created_at: 2025-03-20 12:00:01\n\nchat2_user:\n  type: UserMessage\n  content: Can you help me understand my spending habits?\n  ai_model: gpt-4.1\n  chat: two\n  created_at: 2025-03-20 12:00:01\n\nchat1_assistant_reasoning:\n  type: AssistantMessage\n  content: I'm thinking...\n  ai_model: gpt-4.1\n  chat: one\n  created_at: 2025-03-20 12:01:00\n  reasoning: true\n\nchat1_assistant_response:\n  type: AssistantMessage\n  content: Hello! I can help you understand your spending habits.\n  ai_model: gpt-4.1\n  chat: one\n  created_at: 2025-03-20 12:02:00\n  reasoning: false\n"
  },
  {
    "path": "test/fixtures/mobile_devices.yml",
    "content": "# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html\n\n# Empty fixtures to avoid conflicts"
  },
  {
    "path": "test/fixtures/other_assets.yml",
    "content": "one: { }\n\n"
  },
  {
    "path": "test/fixtures/other_liabilities.yml",
    "content": "one: { }"
  },
  {
    "path": "test/fixtures/plaid_accounts.yml",
    "content": "one:\n  current_balance: 1000\n  available_balance: 1000\n  currency: USD\n  name: Plaid Depository Account\n  plaid_item: one\n  plaid_id: \"acc_mock_1\"\n  plaid_type: depository\n  plaid_subtype: checking"
  },
  {
    "path": "test/fixtures/plaid_items.yml",
    "content": "one:\n  family: dylan_family\n  plaid_id: \"item_mock_1\"\n  access_token: encrypted_token_1\n  name: \"Test Bank\"\n  billed_products: [\"transactions\", \"investments\", \"liabilities\"]\n  available_products: []"
  },
  {
    "path": "test/fixtures/properties.yml",
    "content": "one:\n  year_built: 2002\n  area_value: 1000\n  area_unit: \"sqft\""
  },
  {
    "path": "test/fixtures/rule/actions.yml",
    "content": "one:\n  rule: one\n  action_type: set_transaction_category\n  value: \"some_category_id\""
  },
  {
    "path": "test/fixtures/rule/conditions.yml",
    "content": "one:\n  rule: one\n  condition_type: transaction_name\n  operator: like\n  value: \"starbucks\"\n"
  },
  {
    "path": "test/fixtures/rules.yml",
    "content": "one:\n  family: dylan_family\n  resource_type: \"transaction\""
  },
  {
    "path": "test/fixtures/securities.yml",
    "content": "aapl:\n  ticker: AAPL\n  name: Apple\n  exchange_operating_mic: XNAS\n  country_code: US\n\nmsft:\n  ticker: MSFT\n  name: Microsoft\n  exchange_operating_mic: XNAS\n  country_code: US\n\n"
  },
  {
    "path": "test/fixtures/security/prices.yml",
    "content": "one:\n  security: aapl\n  date: <%= Date.current %>\n  price: 215\n  currency: USD\n\ntwo:\n  security: aapl\n  date: <%= 1.day.ago.to_date %>\n  price: 214\n  currency: USD\n"
  },
  {
    "path": "test/fixtures/sessions.yml",
    "content": "one:\n  user: family_admin\n  user_agent: MyString\n  ip_address: MyString\n"
  },
  {
    "path": "test/fixtures/subscriptions.yml",
    "content": "active:\n  family: dylan_family \n  status: active \n  stripe_id: \"test_1234567890\"\n\ntrialing:\n  family: empty\n  status: trialing\n  trial_ends_at: <%= 12.days.from_now %>\n"
  },
  {
    "path": "test/fixtures/syncs.yml",
    "content": "account:\n  syncable_type: Account\n  syncable: depository\n  status: completed\n  completed_at: <%= Time.now %>\n\nplaid_item:\n  syncable_type: PlaidItem\n  syncable: one\n  status: completed\n  completed_at: <%= Time.now %>\n\nfamily:\n  syncable_type: Family\n  syncable: dylan_family\n  status: completed\n  completed_at: <%= Time.now %>\n"
  },
  {
    "path": "test/fixtures/taggings.yml",
    "content": "one:\n  tag: one\n  taggable: one\n  taggable_type: Transaction\n\ntwo:\n  tag: two\n  taggable: one\n  taggable_type: Transaction\n\n"
  },
  {
    "path": "test/fixtures/tags.yml",
    "content": "one:\n  name: Trips\n  family: dylan_family\n\ntwo:\n  name: Emergency fund\n  family: dylan_family\n\nthree:\n  name: Test\n  family: empty"
  },
  {
    "path": "test/fixtures/tool_calls.yml",
    "content": "one:\n  type: ToolCall::Function\n  function_name: get_user_info\n  provider_id: fc_12345xyz\n  provider_call_id: call_12345xyz\n  function_arguments: {}\n  message: chat1_assistant_response\n"
  },
  {
    "path": "test/fixtures/trades.yml",
    "content": "one:\n  security: aapl\n  qty: 10\n  price: 214\n  currency: USD\n"
  },
  {
    "path": "test/fixtures/transactions.yml",
    "content": "one:\n  category: food_and_drink\n  merchant: amazon\n\ntransfer_out:\n  kind: payment\ntransfer_in:\n  kind: transfer"
  },
  {
    "path": "test/fixtures/transfers.yml",
    "content": "one:\n  inflow_transaction: transfer_in\n  outflow_transaction: transfer_out\n"
  },
  {
    "path": "test/fixtures/users.yml",
    "content": "empty:\n  family: empty\n  first_name: User\n  last_name: One\n  email: user1@email.com\n  password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla \n  onboarded_at: <%= 3.days.ago %>\n  role: admin\n  ai_enabled: true\n\nmaybe_support_staff:\n  family: empty\n  first_name: Support\n  last_name: Admin\n  email: support@maybefinance.com\n  password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla \n  role: super_admin\n  onboarded_at: <%= 3.days.ago %>\n  ai_enabled: true\n\nfamily_admin:\n  family: dylan_family\n  first_name: Bob\n  last_name: Dylan\n  email: bob@bobdylan.com\n  password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla \n  role: admin\n  onboarded_at: <%= 3.days.ago %>\n  ai_enabled: true\n\nfamily_member:\n  family: dylan_family\n  first_name: Jakob\n  last_name: Dylan\n  email: jakobdylan@yahoo.com\n  password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla \n  onboarded_at: <%= 3.days.ago %>\n  ai_enabled: true\n\nnew_email:\n  family: empty\n  first_name: Test\n  last_name: User\n  email: user@example.com\n  unconfirmed_email: new@example.com\n  password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla \n  onboarded_at: <%= Time.current %>\n  ai_enabled: true"
  },
  {
    "path": "test/fixtures/valuations.yml",
    "content": "one:\n  kind: reconciliation\n"
  },
  {
    "path": "test/fixtures/vehicles.yml",
    "content": "one: { }"
  },
  {
    "path": "test/helpers/.keep",
    "content": ""
  },
  {
    "path": "test/helpers/application_helper_test.rb",
    "content": "require \"test_helper\"\n\nclass ApplicationHelperTest < ActionView::TestCase\n  test \"#title(page_title)\" do\n    title(\"Test Title\")\n    assert_equal \"Test Title\", content_for(:title)\n  end\n\n  test \"#header_title(page_title)\" do\n    header_title(\"Test Header Title\")\n    assert_equal \"Test Header Title\", content_for(:header_title)\n  end\n\n  def setup\n    @account1 = Account.new(currency: \"USD\", balance: 1)\n    @account2 = Account.new(currency: \"USD\", balance: 2)\n    @account3 = Account.new(currency: \"EUR\", balance: -7)\n  end\n\n  test \"#totals_by_currency(collection: collection, money_method: money_method)\" do\n    assert_equal \"$3.00\", totals_by_currency(collection: [ @account1, @account2 ], money_method: :balance_money)\n    assert_equal \"$3.00 | -€7.00\", totals_by_currency(collection: [ @account1, @account2, @account3 ], money_method: :balance_money)\n    assert_equal \"\", totals_by_currency(collection: [], money_method: :balance_money)\n    assert_equal \"$0.00\", totals_by_currency(collection: [ Account.new(currency: \"USD\", balance: 0) ], money_method: :balance_money)\n    assert_equal \"-$3.00 | €7.00\", totals_by_currency(collection: [ @account1, @account2, @account3 ], money_method: :balance_money, negate: true)\n  end\nend\n"
  },
  {
    "path": "test/i18n_test.rb",
    "content": "require \"i18n/tasks\"\n\n# We're currently skipping some i18n tests to speed up development.  Eventually, we'll make a dedicated\n# project for getting i18n working.  More details on that here:\n# https://github.com/maybe-finance/maybe/issues/1225\nclass I18nTest < ActiveSupport::TestCase\n  def setup\n    @i18n = I18n::Tasks::BaseTask.new\n  end\n\n  def test_no_missing_keys\n    skip \"Skipping missing keys test\"\n    missing_keys = @i18n.missing_keys(locales: [ :en ])\n    assert_empty missing_keys,\n                 \"Missing #{missing_keys.leaves.count} i18n keys, run `i18n-tasks missing' to show them\"\n  end\n\n  def test_no_unused_keys\n    skip \"Skipping unused keys test\"\n    unused_keys = @i18n.unused_keys(locales: [ :en ])\n    assert_empty unused_keys,\n                 \"#{unused_keys.leaves.count} unused i18n keys, run `i18n-tasks unused' to show them\"\n  end\n\n  def test_files_are_normalized\n    skip \"Skipping file normalization test\"\n    non_normalized = @i18n.non_normalized_paths(locales: [ :en ])\n    error_message = \"The following files need to be normalized:\\n\" \\\n                    \"#{non_normalized.map { |path| \"  #{path}\" }.join(\"\\n\")}\\n\" \\\n                    \"Please run `i18n-tasks normalize' to fix\"\n    assert_empty non_normalized, error_message\n  end\n\n  def test_no_inconsistent_interpolations\n    skip \"Skipping inconsistent interpolations test\"\n    inconsistent_interpolations = @i18n.inconsistent_interpolations(locales: [ :en ])\n    error_message = \"#{inconsistent_interpolations.leaves.count} i18n keys have inconsistent interpolations.\\n\" \\\n                    \"Please run `i18n-tasks check-consistent-interpolations' to show them\"\n    assert_empty inconsistent_interpolations, error_message\n  end\nend\n"
  },
  {
    "path": "test/integration/oauth_basic_test.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nclass OauthBasicTest < ActionDispatch::IntegrationTest\n  test \"oauth authorization endpoint requires authentication\" do\n    oauth_app = Doorkeeper::Application.create!(\n      name: \"Test API Client\",\n      redirect_uri: \"https://client.example.com/callback\",\n      scopes: \"read\"\n    )\n\n    get \"/oauth/authorize?client_id=#{oauth_app.uid}&redirect_uri=#{CGI.escape(oauth_app.redirect_uri)}&response_type=code&scope=read\"\n\n    # Should redirect to login page when not authenticated\n    assert_redirected_to new_session_path\n  end\n\n  test \"oauth token endpoint exists and handles requests\" do\n    post \"/oauth/token\", params: {\n      grant_type: \"authorization_code\",\n      code: \"invalid_code\",\n      redirect_uri: \"https://example.com/callback\",\n      client_id: \"invalid_client\"\n    }\n\n    # Should return 401 for invalid client (correct OAuth behavior)\n    assert_response :unauthorized\n    response_body = JSON.parse(response.body)\n    assert_equal \"invalid_client\", response_body[\"error\"]\n  end\n\n  test \"oauth applications can be created\" do\n    assert_difference(\"Doorkeeper::Application.count\") do\n      Doorkeeper::Application.create!(\n        name: \"Test App\",\n        redirect_uri: \"https://example.com/callback\",\n        scopes: \"read\"\n      )\n    end\n  end\n\n  test \"doorkeeper configuration is properly set up\" do\n    # Test that Doorkeeper is configured and working\n    assert Doorkeeper.configuration.present?, \"Doorkeeper configuration should exist\"\n    assert_equal 1.year, Doorkeeper.configuration.access_token_expires_in\n    assert_equal \"read\", Doorkeeper.configuration.default_scopes.first.to_s\n  end\nend\n"
  },
  {
    "path": "test/integration/oauth_mobile_test.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nclass OauthMobileTest < ActionDispatch::IntegrationTest\n  setup do\n    @user = users(:empty)\n    sign_in(@user)\n\n    @oauth_app = Doorkeeper::Application.create!(\n      name: \"Maybe Mobile App\",\n      redirect_uri: \"maybeapp://oauth/callback\",\n      scopes: \"read\"\n    )\n  end\n\n  test \"mobile oauth authorization with custom scheme redirect\" do\n    get \"/oauth/authorize\", params: {\n      client_id: @oauth_app.uid,\n      redirect_uri: @oauth_app.redirect_uri,\n      response_type: \"code\",\n      scope: \"read\",\n      display: \"mobile\"\n    }\n\n    assert_response :success\n\n    # Check that Turbo is disabled in the form\n    assert_match(/data-turbo=\"false\"/, response.body)\n    assert_match(/maybeapp:\\/\\/oauth\\/callback/, response.body)\n  end\n\n  test \"mobile oauth detects custom scheme in redirect_uri\" do\n    get \"/oauth/authorize\", params: {\n      client_id: @oauth_app.uid,\n      redirect_uri: \"maybeapp://oauth/callback\",\n      response_type: \"code\",\n      scope: \"read\"\n    }\n\n    assert_response :success\n\n    # Should detect mobile flow from redirect_uri\n    assert_match(/data-turbo=\"false\"/, response.body)\n  end\n\n  test \"mobile oauth authorization flow completes successfully\" do\n    post \"/oauth/authorize\", params: {\n      client_id: @oauth_app.uid,\n      redirect_uri: @oauth_app.redirect_uri,\n      response_type: \"code\",\n      scope: \"read\",\n      display: \"mobile\"\n    }\n\n    # Should redirect to the custom scheme\n    assert_response :redirect\n    assert response.location.start_with?(\"maybeapp://oauth/callback\")\n  end\n\n  test \"mobile oauth preserves display parameter through forms\" do\n    get \"/oauth/authorize\", params: {\n      client_id: @oauth_app.uid,\n      redirect_uri: @oauth_app.redirect_uri,\n      response_type: \"code\",\n      scope: \"read\",\n      display: \"mobile\"\n    }\n\n    assert_response :success\n\n    # Check that display parameter is preserved in hidden fields\n    assert_match(/<input[^>]*name=\"display\"[^>]*value=\"mobile\"/, response.body)\n  end\nend\n"
  },
  {
    "path": "test/integration/rack_attack_test.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"test_helper\"\n\nclass RackAttackTest < ActionDispatch::IntegrationTest\n  test \"rack attack is configured\" do\n    # Verify Rack::Attack is enabled in middleware stack\n    middleware_classes = Rails.application.middleware.map(&:klass)\n    assert_includes middleware_classes, Rack::Attack, \"Rack::Attack should be in middleware stack\"\n  end\n\n  test \"oauth token endpoint has rate limiting configured\" do\n    # Test that the throttle is configured (we don't need to trigger it)\n    throttles = Rack::Attack.throttles.keys\n    assert_includes throttles, \"oauth/token\", \"OAuth token endpoint should have rate limiting\"\n  end\n\n  test \"api requests have rate limiting configured\" do\n    # Test that API rate limiting is configured\n    throttles = Rack::Attack.throttles.keys\n    assert_includes throttles, \"api/requests\", \"API requests should have rate limiting\"\n  end\nend\n"
  },
  {
    "path": "test/interfaces/accountable_resource_interface_test.rb",
    "content": "require \"test_helper\"\n\nmodule AccountableResourceInterfaceTest\n  extend ActiveSupport::Testing::Declarative\n\n  test \"shows new form\" do\n    Family.any_instance.stubs(:get_link_token).returns(\"test-link-token\")\n\n    get new_polymorphic_url(@account.accountable)\n    assert_response :success\n  end\n\n  test \"shows edit form\" do\n    get edit_account_url(@account)\n    assert_response :success\n  end\nend\n"
  },
  {
    "path": "test/interfaces/entryable_resource_interface_test.rb",
    "content": "require \"test_helper\"\n\nmodule EntryableResourceInterfaceTest\n  extend ActiveSupport::Testing::Declarative\n\n  test \"shows new form\" do\n    get new_polymorphic_url(@entry.entryable)\n    assert_response :success\n  end\n\n  test \"shows editing drawer\" do\n    get entry_url(@entry)\n    assert_response :success\n  end\n\n  test \"destroys entry\" do\n    assert_difference \"Entry.count\", -1 do\n      delete entry_url(@entry)\n    end\n\n    assert_enqueued_with job: SyncJob\n\n    assert_redirected_to account_url(@entry.account)\n  end\nend\n"
  },
  {
    "path": "test/interfaces/exchange_rate_provider_interface_test.rb",
    "content": "require \"test_helper\"\n\nmodule ExchangeRateProviderInterfaceTest\n  extend ActiveSupport::Testing::Declarative\n\n  test \"fetches single exchange rate\" do\n    VCR.use_cassette(\"#{vcr_key_prefix}/exchange_rate\") do\n      response = @subject.fetch_exchange_rate(\n        from: \"USD\",\n        to: \"GBP\",\n        date: Date.parse(\"01.01.2024\")\n      )\n\n      rate = response.data\n\n      assert_equal \"USD\", rate.from\n      assert_equal \"GBP\", rate.to\n      assert rate.date.is_a?(Date)\n      assert_in_delta 0.78, rate.rate, 0.01\n    end\n  end\n\n  test \"fetches paginated exchange_rate historical data\" do\n    VCR.use_cassette(\"#{vcr_key_prefix}/exchange_rates\") do\n      response = @subject.fetch_exchange_rates(\n        from: \"USD\", to: \"GBP\", start_date: Date.parse(\"01.01.2024\"), end_date: Date.parse(\"31.07.2024\")\n      )\n\n      assert_equal 213, response.data.count # 213 days between 01.01.2024 and 31.07.2024\n      assert response.data.first.date.is_a?(Date)\n    end\n  end\n\n  private\n    def vcr_key_prefix\n      @subject.class.name.demodulize.underscore\n    end\nend\n"
  },
  {
    "path": "test/interfaces/import_interface_test.rb",
    "content": "require \"test_helper\"\n\nmodule ImportInterfaceTest\n  extend ActiveSupport::Testing::Declarative\n\n  test \"import interface\" do\n    assert_respond_to @subject, :publish\n    assert_respond_to @subject, :publish_later\n    assert_respond_to @subject, :generate_rows_from_csv\n    assert_respond_to @subject, :csv_rows\n    assert_respond_to @subject, :csv_headers\n    assert_respond_to @subject, :csv_sample\n    assert_respond_to @subject, :uploaded?\n    assert_respond_to @subject, :configured?\n    assert_respond_to @subject, :cleaned?\n    assert_respond_to @subject, :publishable?\n    assert_respond_to @subject, :importing?\n    assert_respond_to @subject, :complete?\n    assert_respond_to @subject, :failed?\n  end\n\n  test \"publishes later\" do\n    import = imports(:transaction)\n\n    import.stubs(:publishable?).returns(true)\n\n    assert_enqueued_with job: ImportJob, args: [ import ] do\n      import.publish_later\n    end\n\n    assert_equal \"importing\", import.reload.status\n  end\n\n  test \"raises if not publishable\" do\n    import = imports(:transaction)\n\n    import.stubs(:publishable?).returns(false)\n\n    assert_raises(RuntimeError, \"Import is not publishable\") do\n      import.publish_later\n    end\n  end\n\n  test \"handles publish errors\" do\n    import = imports(:transaction)\n\n    import.stubs(:publishable?).returns(true)\n    import.stubs(:import!).raises(StandardError, \"Failed to publish\")\n\n    assert_nil import.error\n\n    import.publish\n\n    assert_equal \"Failed to publish\", import.error\n    assert_equal \"failed\", import.status\n  end\n\n  test \"parses US/UK number format correctly\" do\n    import = imports(:transaction)\n    import.update!(\n      number_format: \"1,234.56\",\n      amount_col_label: \"amount\",\n      date_col_label: \"date\",\n      name_col_label: \"name\",\n      date_format: \"%m/%d/%Y\"\n    )\n\n    csv_data = \"date,amount,name\\n01/01/2024,\\\"1,234.56\\\",Test\"\n    import.update!(raw_file_str: csv_data)\n    import.generate_rows_from_csv\n    import.reload\n    row = import.rows.first\n    assert_equal \"1234.56\", row.amount\n  end\n\n  test \"parses European number format correctly\" do\n    import = imports(:transaction)\n    import.update!(\n      number_format: \"1.234,56\",\n      amount_col_label: \"amount\",\n      date_col_label: \"date\",\n      name_col_label: \"name\",\n      date_format: \"%m/%d/%Y\"\n    )\n\n    csv_data = \"date,amount,name\\n01/01/2024,\\\"1.234,56\\\",Test\"\n    import.update!(raw_file_str: csv_data)\n    import.generate_rows_from_csv\n    import.reload\n\n    row = import.rows.first\n    assert_equal \"1234.56\", row.amount\n  end\n\n  test \"parses French/Scandinavian number format correctly\" do\n    import = imports(:transaction)\n    import.update!(\n      number_format: \"1 234,56\",\n      amount_col_label: \"amount\",\n      date_col_label: \"date\",\n      name_col_label: \"name\",\n      date_format: \"%m/%d/%Y\"\n    )\n\n    # Quote the amount field to ensure proper CSV parsing\n    csv_data = \"date,amount,name\\n01/01/2024,\\\"1 234,56\\\",Test\"\n    import.update!(raw_file_str: csv_data)\n    import.generate_rows_from_csv\n    import.reload\n\n    row = import.rows.first\n    assert_equal \"1234.56\", row.amount\n  end\n\n  test \"parses zero-decimal currency format correctly\" do\n    import = imports(:transaction)\n    import.update!(\n      number_format: \"1,234\",\n      amount_col_label: \"amount\",\n      date_col_label: \"date\",\n      name_col_label: \"name\",\n      date_format: \"%m/%d/%Y\"\n    )\n\n    csv_data = \"date,amount,name\\n01/01/2024,1234,Test\"\n    import.update!(raw_file_str: csv_data)\n    import.generate_rows_from_csv\n    import.reload\n\n    row = import.rows.first\n    assert_equal \"1234\", row.amount\n  end\n\n  test \"currency from CSV takes precedence over default\" do\n    import = imports(:transaction)\n    import.update!(\n      amount_col_label: \"amount\",\n      date_col_label: \"date\",\n      name_col_label: \"name\",\n      currency_col_label: \"currency\",\n      number_format: \"1,234.56\",\n      date_format: \"%m/%d/%Y\"\n    )\n    import.family.update!(currency: \"USD\")\n\n    csv_data = \"date,amount,name,currency\\n01/01/2024,123.45,Test,EUR\"\n    import.update!(raw_file_str: csv_data)\n    import.generate_rows_from_csv\n    import.reload\n\n    row = import.rows.first\n    assert_equal \"EUR\", row.currency\n  end\n\n  test \"uses default currency when CSV currency column is empty\" do\n    import = imports(:transaction)\n    import.update!(\n      amount_col_label: \"amount\",\n      date_col_label: \"date\",\n      name_col_label: \"name\",\n      currency_col_label: \"currency\",\n      number_format: \"1,234.56\",\n      date_format: \"%m/%d/%Y\"\n    )\n    import.family.update!(currency: \"USD\")\n\n    csv_data = \"date,amount,name,currency\\n01/01/2024,123.45,Test,\"\n    import.update!(raw_file_str: csv_data)\n    import.generate_rows_from_csv\n    import.reload\n\n    row = import.rows.first\n    assert_equal \"USD\", row.currency\n  end\n\n  test \"uses default currency when CSV has no currency column\" do\n    import = imports(:transaction)\n    import.update!(\n      amount_col_label: \"amount\",\n      date_col_label: \"date\",\n      name_col_label: \"name\",\n      number_format: \"1,234.56\",\n      date_format: \"%m/%d/%Y\"\n    )\n    import.family.update!(currency: \"USD\")\n\n    csv_data = \"date,amount,name\\n01/01/2024,123.45,Test\"\n    import.update!(raw_file_str: csv_data)\n    import.generate_rows_from_csv\n    import.reload\n\n    row = import.rows.first\n    assert_equal \"USD\", row.currency\n  end\n\n  test \"generates rows with all optional fields\" do\n    import = imports(:transaction)\n    import.update!(\n      amount_col_label: \"amount\",\n      date_col_label: \"date\",\n      name_col_label: \"name\",\n      account_col_label: \"account\",\n      category_col_label: \"category\",\n      tags_col_label: \"tags\",\n      notes_col_label: \"notes\",\n      currency_col_label: \"currency\",\n      number_format: \"1,234.56\",\n      date_format: \"%m/%d/%Y\"\n    )\n\n    csv_data = \"date,amount,name,account,category,tags,notes,currency\\n\" \\\n               \"01/01/2024,1234.56,Salary,Bank Account,Income,\\\"monthly,salary\\\",Salary payment,EUR\"\n    import.update!(raw_file_str: csv_data)\n    import.generate_rows_from_csv\n    import.reload\n\n    row = import.rows.first\n    assert_equal \"01/01/2024\", row.date\n    assert_equal \"1234.56\", row.amount\n    assert_equal \"Salary\", row.name\n    assert_equal \"Bank Account\", row.account\n    assert_equal \"Income\", row.category\n    assert_equal \"monthly,salary\", row.tags\n    assert_equal \"Salary payment\", row.notes\n    assert_equal \"EUR\", row.currency\n  end\n\n  test \"generates rows with minimal required fields\" do\n    import = imports(:transaction)\n    import.update!(\n      amount_col_label: \"amount\",\n      date_col_label: \"date\",\n      number_format: \"1,234.56\",\n      date_format: \"%m/%d/%Y\"\n    )\n\n    csv_data = \"date,amount\\n01/01/2024,1234.56\"\n    import.update!(raw_file_str: csv_data)\n    import.generate_rows_from_csv\n    import.reload\n\n    row = import.rows.first\n    assert_equal \"01/01/2024\", row.date\n    assert_equal \"1234.56\", row.amount\n    assert_equal \"Imported item\", row.name # Default name\n    assert_equal import.family.currency, row.currency # Default currency\n  end\n\n  test \"handles empty values in optional fields\" do\n    import = imports(:transaction)\n    import.update!(\n      amount_col_label: \"amount\",\n      date_col_label: \"date\",\n      name_col_label: \"name\",\n      category_col_label: \"category\",\n      tags_col_label: \"tags\",\n      number_format: \"1,234.56\",\n      date_format: \"%m/%d/%Y\"\n    )\n\n    csv_data = \"date,amount,name,category,tags\\n01/01/2024,1234.56,,,\"\n    import.update!(raw_file_str: csv_data)\n    import.generate_rows_from_csv\n    import.reload\n\n    row = import.rows.first\n    assert_equal \"01/01/2024\", row.date\n    assert_equal \"1234.56\", row.amount\n    assert_equal \"Imported item\", row.name # Falls back to default\n    assert_equal \"\", row.category\n    assert_equal \"\", row.tags\n  end\n\n  test \"can submit configuration that results in invalid rows for user to fix later\" do\n    import = imports(:transaction)\n\n    csv_data = \"date,amount,name\\n01/01/2024,1234.56,Test\"\n    import.update!(raw_file_str: csv_data)\n    import.update!(\n      date_col_label: \"date\",\n      date_format: \"%Y-%m-%d\" # Does not match the raw CSV date, so rows will be invalid, but still generated\n    )\n\n    import.generate_rows_from_csv\n    import.reload\n\n    assert_equal \"01/01/2024\", import.rows.first.date\n    assert import.rows.first.invalid?\n  end\n\n  test \"handles trade-specific fields\" do\n    import = imports(:transaction)\n    import.update!(\n      amount_col_label: \"amount\",\n      date_col_label: \"date\",\n      qty_col_label: \"quantity\",\n      ticker_col_label: \"symbol\",\n      price_col_label: \"price\",\n      number_format: \"1,234.56\",\n      date_format: \"%m/%d/%Y\"\n    )\n\n    csv_data = \"date,amount,quantity,symbol,price\\n01/01/2024,1234.56,10,AAPL,123.456\"\n    import.update!(raw_file_str: csv_data)\n    import.generate_rows_from_csv\n    import.reload\n\n    row = import.rows.first\n    assert_equal \"10\", row.qty\n    assert_equal \"AAPL\", row.ticker\n    assert_equal \"123.456\", row.price\n  end\nend\n"
  },
  {
    "path": "test/interfaces/llm_interface_test.rb",
    "content": "require \"test_helper\"\n\nmodule LLMInterfaceTest\n  extend ActiveSupport::Testing::Declarative\n\n  private\n    def vcr_key_prefix\n      @subject.class.name.demodulize.underscore\n    end\nend\n"
  },
  {
    "path": "test/interfaces/security_provider_interface_test.rb",
    "content": "require \"test_helper\"\n\nmodule SecurityProviderInterfaceTest\n  extend ActiveSupport::Testing::Declarative\n\n  test \"fetches security price\" do\n    aapl = securities(:aapl)\n\n    VCR.use_cassette(\"#{vcr_key_prefix}/security_price\") do\n      response = @subject.fetch_security_price(symbol: aapl.ticker, exchange_operating_mic: aapl.exchange_operating_mic, date: Date.iso8601(\"2024-08-01\"))\n\n      assert response.success?\n      assert response.data.present?\n    end\n  end\n\n  test \"fetches paginated securities prices\" do\n    aapl = securities(:aapl)\n\n    VCR.use_cassette(\"#{vcr_key_prefix}/security_prices\") do\n      response = @subject.fetch_security_prices(\n        symbol: aapl.ticker,\n        exchange_operating_mic: aapl.exchange_operating_mic,\n        start_date: Date.iso8601(\"2024-01-01\"),\n        end_date: Date.iso8601(\"2024-08-01\")\n      )\n\n      assert response.success?\n      assert response.data.first.date.is_a?(Date)\n      assert_equal 147, response.data.count # Synth won't return prices on weekends / holidays, so less than total day count of 213\n    end\n  end\n\n  test \"searches securities\" do\n    VCR.use_cassette(\"#{vcr_key_prefix}/security_search\") do\n      response = @subject.search_securities(\"AAPL\", country_code: \"US\")\n      securities = response.data\n\n      assert securities.any?\n      security = securities.first\n      assert_equal \"AAPL\", security.symbol\n    end\n  end\n\n  test \"fetches security info\" do\n    aapl = securities(:aapl)\n\n    VCR.use_cassette(\"#{vcr_key_prefix}/security_info\") do\n      response = @subject.fetch_security_info(\n        symbol: aapl.ticker,\n        exchange_operating_mic: aapl.exchange_operating_mic\n      )\n\n      info = response.data\n\n      assert_equal \"AAPL\", info.symbol\n      assert_equal \"Apple Inc.\", info.name\n      assert_equal \"common stock\", info.kind\n      assert info.logo_url.present?\n      assert info.description.present?\n    end\n  end\n\n  private\n    def vcr_key_prefix\n      @subject.class.name.demodulize.underscore\n    end\nend\n"
  },
  {
    "path": "test/interfaces/syncable_interface_test.rb",
    "content": "require \"test_helper\"\n\nmodule SyncableInterfaceTest\n  extend ActiveSupport::Testing::Declarative\n  include ActiveJob::TestHelper\n\n  test \"can sync later\" do\n    assert_difference \"@syncable.syncs.count\", 1 do\n      assert_enqueued_with job: SyncJob do\n        @syncable.sync_later(window_start_date: 2.days.ago.to_date)\n      end\n    end\n  end\n\n  test \"can perform sync\" do\n    mock_sync = mock\n    @syncable.class.any_instance.expects(:perform_sync).with(mock_sync).once\n    @syncable.perform_sync(mock_sync)\n  end\n\n  test \"second sync request widens existing pending window\" do\n    later_start = 2.days.ago.to_date\n    first_sync = @syncable.sync_later(window_start_date: later_start, window_end_date: later_start)\n\n    earlier_start = 5.days.ago.to_date\n    wider_end     = Date.current\n\n    assert_no_difference \"@syncable.syncs.count\" do\n      @syncable.sync_later(window_start_date: earlier_start, window_end_date: wider_end)\n    end\n\n    first_sync.reload\n    assert_equal earlier_start, first_sync.window_start_date\n    assert_equal wider_end, first_sync.window_end_date\n  end\nend\n"
  },
  {
    "path": "test/jobs/family_data_export_job_test.rb",
    "content": "require \"test_helper\"\n\nclass FamilyDataExportJobTest < ActiveJob::TestCase\n  setup do\n    @family = families(:dylan_family)\n    @export = @family.family_exports.create!\n  end\n\n  test \"marks export as processing then completed\" do\n    assert_equal \"pending\", @export.status\n\n    perform_enqueued_jobs do\n      FamilyDataExportJob.perform_later(@export)\n    end\n\n    @export.reload\n    assert_equal \"completed\", @export.status\n    assert @export.export_file.attached?\n  end\n\n  test \"marks export as failed on error\" do\n    # Mock the exporter to raise an error\n    Family::DataExporter.any_instance.stubs(:generate_export).raises(StandardError, \"Export failed\")\n\n    perform_enqueued_jobs do\n      FamilyDataExportJob.perform_later(@export)\n    end\n\n    @export.reload\n    assert_equal \"failed\", @export.status\n  end\nend\n"
  },
  {
    "path": "test/jobs/import_job_test.rb",
    "content": "require \"test_helper\"\n\nclass ImportJobTest < ActiveJob::TestCase\n  test \"import is published\" do\n    import = imports(:transaction)\n    import.expects(:publish).once\n\n    ImportJob.perform_now(import)\n  end\nend\n"
  },
  {
    "path": "test/jobs/stripe_event_handler_job_test.rb",
    "content": "require \"test_helper\"\n\nclass StripeEventHandlerJobTest < ActiveJob::TestCase\n  # test \"the truth\" do\n  #   assert true\n  # end\nend\n"
  },
  {
    "path": "test/jobs/sync_job_test.rb",
    "content": "require \"test_helper\"\n\nclass SyncJobTest < ActiveJob::TestCase\n  test \"sync is performed\" do\n    syncable = accounts(:depository)\n\n    sync = syncable.syncs.create!(window_start_date: 2.days.ago.to_date)\n\n    sync.expects(:perform).once\n\n    SyncJob.perform_now(sync)\n  end\nend\n"
  },
  {
    "path": "test/lib/money/currency_test.rb",
    "content": "require \"test_helper\"\n\nclass Money::CurrencyTest < ActiveSupport::TestCase\n  setup do\n    @currency = Money::Currency.new(:usd)\n  end\n\n  test \"has many currencies\" do\n    assert_operator Money::Currency.all.count, :>, 100\n  end\n\n  test \"can test equality of currencies\" do\n    assert_equal Money::Currency.new(:usd), Money::Currency.new(:usd)\n    assert_not_equal Money::Currency.new(:usd), Money::Currency.new(:eur)\n  end\n\n  test \"can get metadata about a currency\" do\n    assert_equal \"USD\", @currency.iso_code\n    assert_equal \"United States Dollar\", @currency.name\n    assert_equal \"$\", @currency.symbol\n    assert_equal 1, @currency.priority\n    assert_equal \"Cent\", @currency.minor_unit\n    assert_equal 100, @currency.minor_unit_conversion\n    assert_equal 1, @currency.smallest_denomination\n    assert_equal \".\", @currency.separator\n    assert_equal \",\", @currency.delimiter\n    assert_equal \"%u%n\", @currency.default_format\n    assert_equal 2, @currency.default_precision\n  end\n\n  test \"step returns the smallest value of the currency\" do\n    assert_equal 0.01, @currency.step\n  end\nend\n"
  },
  {
    "path": "test/lib/money_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass MoneyTest < ActiveSupport::TestCase\n  test \"can create with default currency\" do\n    value = Money.new(1000)\n    assert_equal 1000, value.amount\n  end\n\n  test \"can create with custom currency\" do\n    value1 = Money.new(1000, :EUR)\n    value2 = Money.new(1000, :eur)\n    value3 = Money.new(1000, \"eur\")\n    value4 = Money.new(1000, \"EUR\")\n\n    assert_equal value1.currency.iso_code, value2.currency.iso_code\n    assert_equal value2.currency.iso_code, value3.currency.iso_code\n    assert_equal value3.currency.iso_code, value4.currency.iso_code\n  end\n\n  test \"equality tests amount and currency\" do\n    assert_equal Money.new(1000), Money.new(1000)\n    assert_not_equal Money.new(1000), Money.new(1001)\n    assert_not_equal Money.new(1000, :usd), Money.new(1000, :eur)\n  end\n\n  test \"can compare with zero Numeric\" do\n    assert_equal Money.new(0), 0\n    assert_raises(TypeError) { Money.new(1) == 1 }\n  end\n\n  test \"can negate\" do\n    assert_equal (-Money.new(1000)), Money.new(-1000)\n  end\n\n  test \"can use comparison operators\" do\n    assert_operator Money.new(1000), :>, Money.new(999)\n    assert_operator Money.new(1000), :>=, Money.new(1000)\n    assert_operator Money.new(1000), :<, Money.new(1001)\n    assert_operator Money.new(1000), :<=, Money.new(1000)\n  end\n\n  test \"can add and subtract\" do\n    assert_equal Money.new(1000) + Money.new(1000), Money.new(2000)\n    assert_equal Money.new(1000) + 1000, Money.new(2000)\n    assert_equal Money.new(1000) - Money.new(1000), Money.new(0)\n    assert_equal Money.new(1000) - 1000, Money.new(0)\n  end\n\n  test \"can multiply\" do\n    assert_equal Money.new(1000) * 2, Money.new(2000)\n    assert_raises(TypeError) { Money.new(1000) * Money.new(2) }\n  end\n\n  test \"can divide\" do\n    assert_equal Money.new(1000) / 2, Money.new(500)\n    assert_equal Money.new(1000) / Money.new(500), 2\n    assert_raise(TypeError) { 1000 / Money.new(2) }\n  end\n\n  test \"operator order does not matter\" do\n    assert_equal Money.new(1000) + 1000, 1000 + Money.new(1000)\n    assert_equal Money.new(1000) - 1000, 1000 - Money.new(1000)\n    assert_equal Money.new(1000) * 2, 2 * Money.new(1000)\n  end\n\n  test \"can get absolute value\" do\n    assert_equal Money.new(1000).abs, Money.new(1000)\n    assert_equal Money.new(-1000).abs, Money.new(1000)\n  end\n\n  test \"can test if zero\" do\n    assert Money.new(0).zero?\n    assert_not Money.new(1000).zero?\n  end\n\n  test \"can test if negative\" do\n    assert Money.new(-1000).negative?\n    assert_not Money.new(1000).negative?\n  end\n\n  test \"can test if positive\" do\n    assert Money.new(1000).positive?\n    assert_not Money.new(-1000).positive?\n  end\n\n  test \"can format\" do\n    assert_equal \"$1,000.90\", Money.new(1000.899).to_s\n    assert_equal \"€1,000.12\", Money.new(1000.12, :eur).to_s\n    assert_equal \"€ 1.000,12\", Money.new(1000.12, :eur).format(locale: :nl)\n  end\n\n  test \"converts currency when rate available\" do\n    ExchangeRate.expects(:find_or_fetch_rate).returns(OpenStruct.new(rate: 1.2))\n\n    assert_equal Money.new(1000).exchange_to(:eur), Money.new(1000 * 1.2, :eur)\n  end\n\n  test \"raises when no conversion rate available and no fallback rate provided\" do\n    ExchangeRate.expects(:find_or_fetch_rate).returns(nil)\n\n    assert_raises Money::ConversionError do\n      Money.new(1000).exchange_to(:jpy)\n    end\n  end\n\n  test \"converts currency with a fallback rate\" do\n    ExchangeRate.expects(:find_or_fetch_rate).returns(nil).twice\n\n    assert_equal 0, Money.new(1000).exchange_to(:jpy, fallback_rate: 0)\n    assert_equal Money.new(1000, :jpy), Money.new(1000, :usd).exchange_to(:jpy, fallback_rate: 1)\n  end\nend\n"
  },
  {
    "path": "test/mailers/email_confirmation_mailer_test.rb",
    "content": "require \"test_helper\"\n\nclass EmailConfirmationMailerTest < ActionMailer::TestCase\n  test \"confirmation_email\" do\n    user = users(:new_email)\n    user.unconfirmed_email = \"new@example.com\"\n\n    mail = EmailConfirmationMailer.with(user: user).confirmation_email\n    assert_equal I18n.t(\"email_confirmation_mailer.confirmation_email.subject\"), mail.subject\n    assert_equal [ user.unconfirmed_email ], mail.to\n    assert_equal [ \"hello@maybefinance.com\" ], mail.from\n    assert_match \"confirm\", mail.body.encoded\n  end\nend\n"
  },
  {
    "path": "test/mailers/password_mailer_test.rb",
    "content": "require \"test_helper\"\n\nclass PasswordMailerTest < ActionMailer::TestCase\n  # test \"the truth\" do\n  #   assert true\n  # end\nend\n"
  },
  {
    "path": "test/mailers/previews/email_confirmation_mailer_preview.rb",
    "content": "# Preview all emails at http://localhost:3000/rails/mailers/email_confirmation_mailer\nclass EmailConfirmationMailerPreview < ActionMailer::Preview\n  # Preview this email at http://localhost:3000/rails/mailers/email_confirmation_mailer/confirmation_email\n  def confirmation_email\n    EmailConfirmationMailer.confirmation_email\n  end\nend\n"
  },
  {
    "path": "test/mailers/previews/password_mailer_preview.rb",
    "content": "# Preview all emails at http://localhost:3000/rails/mailers/password_mailer\nclass PasswordMailerPreview < ActionMailer::Preview\nend\n"
  },
  {
    "path": "test/models/account/activity_feed_data_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::ActivityFeedDataTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @family = families(:empty)\n    @checking = @family.accounts.create!(name: \"Test Checking\", accountable: Depository.new, currency: \"USD\", balance: 0)\n    @savings = @family.accounts.create!(name: \"Test Savings\", accountable: Depository.new, currency: \"USD\", balance: 0)\n    @investment = @family.accounts.create!(name: \"Test Investment\", accountable: Investment.new, currency: \"USD\", balance: 0)\n\n    @test_period_start = Date.current - 4.days\n\n    setup_test_data\n  end\n\n  test \"returns balance for date with complete balance history\" do\n    entries = @checking.entries.includes(:entryable).to_a\n    feed_data = Account::ActivityFeedData.new(@checking, entries)\n\n    activities = feed_data.entries_by_date\n    day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)\n\n    assert_not_nil day2_activity\n    assert_not_nil day2_activity.balance\n    assert_equal 1100, day2_activity.balance.end_balance  # End of day 2\n  end\n\n  test \"returns balance for first day\" do\n    entries = @checking.entries.includes(:entryable).to_a\n    feed_data = Account::ActivityFeedData.new(@checking, entries)\n\n    activities = feed_data.entries_by_date\n    day1_activity = find_activity_for_date(activities, @test_period_start)\n\n    assert_not_nil day1_activity\n    assert_not_nil day1_activity.balance\n    assert_equal 1000, day1_activity.balance.end_balance  # End of first day\n  end\n\n  test \"returns nil balance when no balance exists for date\" do\n    @checking.balances.destroy_all\n\n    entries = @checking.entries.includes(:entryable).to_a\n    feed_data = Account::ActivityFeedData.new(@checking, entries)\n\n    activities = feed_data.entries_by_date\n    day1_activity = find_activity_for_date(activities, @test_period_start)\n\n    assert_not_nil day1_activity\n    assert_nil day1_activity.balance\n  end\n\n  test \"returns cash and holdings data for investment accounts\" do\n    entries = @investment.entries.includes(:entryable).to_a\n    feed_data = Account::ActivityFeedData.new(@investment, entries)\n\n    activities = feed_data.entries_by_date\n    day3_activity = find_activity_for_date(activities, @test_period_start + 2.days)\n\n    assert_not_nil day3_activity\n    assert_not_nil day3_activity.balance\n\n    # Balance should have the new schema fields\n    assert_equal 400, day3_activity.balance.end_cash_balance  # End of day 3 cash balance\n    assert_equal 1500, day3_activity.balance.end_non_cash_balance  # Holdings value\n    assert_equal 1900, day3_activity.balance.end_balance  # Total balance\n  end\n\n  test \"identifies transfers for a specific date\" do\n    entries = @checking.entries.includes(:entryable).to_a\n    feed_data = Account::ActivityFeedData.new(@checking, entries)\n\n    activities = feed_data.entries_by_date\n\n    # Day 2 has the transfer\n    day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)\n    assert_not_nil day2_activity\n    assert_equal 1, day2_activity.transfers.size\n    assert_equal @transfer, day2_activity.transfers.first\n\n    # Other days have no transfers\n    day1_activity = find_activity_for_date(activities, @test_period_start)\n    assert_not_nil day1_activity\n    assert_empty day1_activity.transfers\n  end\n\n  test \"returns complete ActivityDateData objects with all required fields\" do\n    entries = @investment.entries.includes(:entryable).to_a\n    feed_data = Account::ActivityFeedData.new(@investment, entries)\n\n    activities = feed_data.entries_by_date\n\n    # Check that we get ActivityDateData objects\n    assert activities.all? { |a| a.is_a?(Account::ActivityFeedData::ActivityDateData) }\n\n    # Check that each ActivityDate has the required fields\n    activities.each do |activity|\n      assert_respond_to activity, :date\n      assert_respond_to activity, :entries\n      assert_respond_to activity, :balance\n      assert_respond_to activity, :transfers\n    end\n  end\n\n  test \"handles valuations correctly with new balance schema\" do\n    # Create account with known balances\n    account = @family.accounts.create!(name: \"Test Investment\", accountable: Investment.new, currency: \"USD\", balance: 0)\n\n    # Day 1: Starting balance\n    account.balances.create!(\n      date: @test_period_start,\n      balance: 7321.56,  # Keep old field for now\n      cash_balance: 1000,  # Keep old field for now\n      start_cash_balance: 0,\n      start_non_cash_balance: 0,\n      cash_inflows: 1000,\n      cash_outflows: 0,\n      non_cash_inflows: 6321.56,\n      non_cash_outflows: 0,\n      net_market_flows: 0,\n      cash_adjustments: 0,\n      non_cash_adjustments: 0,\n      currency: \"USD\"\n    )\n\n    # Day 2: Add transactions, trades and a valuation\n    account.balances.create!(\n      date: @test_period_start + 1.day,\n      balance: 8500,  # Keep old field for now\n      cash_balance: 1070,  # Keep old field for now\n      start_cash_balance: 1000,\n      start_non_cash_balance: 6321.56,\n      cash_inflows: 70,\n      cash_outflows: 0,\n      non_cash_inflows: 750,\n      non_cash_outflows: 0,\n      net_market_flows: 0,\n      cash_adjustments: 0,\n      non_cash_adjustments: 358.44,\n      currency: \"USD\"\n    )\n\n    # Create transactions\n    create_transaction(\n      account: account,\n      date: @test_period_start + 1.day,\n      amount: -50,\n      name: \"Interest payment\"\n    )\n    create_transaction(\n      account: account,\n      date: @test_period_start + 1.day,\n      amount: -20,\n      name: \"Interest payment\"\n    )\n\n    # Create a trade\n    create_trade(\n      securities(:aapl),\n      account: account,\n      qty: 5,\n      date: @test_period_start + 1.day,\n      price: 150  # 5 * 150 = 750\n    )\n\n    # Create valuation\n    create_valuation(\n      account: account,\n      date: @test_period_start + 1.day,\n      amount: 8500\n    )\n\n    entries = account.entries.includes(:entryable).to_a\n    feed_data = Account::ActivityFeedData.new(account, entries)\n\n    activities = feed_data.entries_by_date\n    day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)\n\n    assert_not_nil day2_activity\n    assert_not_nil day2_activity.balance\n\n    # Check new balance fields\n    assert_equal 1070, day2_activity.balance.end_cash_balance\n    assert_equal 7430, day2_activity.balance.end_non_cash_balance\n    assert_equal 8500, day2_activity.balance.end_balance\n  end\n\n  private\n    def find_activity_for_date(activities, date)\n      activities.find { |a| a.date == date }\n    end\n\n    def setup_test_data\n      # Create daily balances for checking account with new schema\n      5.times do |i|\n        date = @test_period_start + i.days\n        prev_balance = i > 0 ? 1000 + ((i - 1) * 100) : 0\n\n        @checking.balances.create!(\n          date: date,\n          balance: 1000 + (i * 100),  # Keep old field for now\n          cash_balance: 1000 + (i * 100),  # Keep old field for now\n          start_balance: prev_balance,\n          start_cash_balance: prev_balance,\n          start_non_cash_balance: 0,\n          cash_inflows: i == 0 ? 1000 : 100,\n          cash_outflows: 0,\n          non_cash_inflows: 0,\n          non_cash_outflows: 0,\n          net_market_flows: 0,\n          cash_adjustments: 0,\n          non_cash_adjustments: 0,\n          currency: \"USD\"\n        )\n      end\n\n      # Create daily balances for investment account with cash_balance\n      @investment.balances.create!(\n        date: @test_period_start,\n        balance: 500,  # Keep old field for now\n        cash_balance: 500,  # Keep old field for now\n        start_balance: 0,\n        start_cash_balance: 0,\n        start_non_cash_balance: 0,\n        cash_inflows: 500,\n        cash_outflows: 0,\n        non_cash_inflows: 0,\n        non_cash_outflows: 0,\n        net_market_flows: 0,\n        cash_adjustments: 0,\n        non_cash_adjustments: 0,\n        currency: \"USD\"\n      )\n      @investment.balances.create!(\n        date: @test_period_start + 1.day,\n        balance: 500,  # Keep old field for now\n        cash_balance: 500,  # Keep old field for now\n        start_balance: 500,\n        start_cash_balance: 500,\n        start_non_cash_balance: 0,\n        cash_inflows: 0,\n        cash_outflows: 0,\n        non_cash_inflows: 0,\n        non_cash_outflows: 0,\n        net_market_flows: 0,\n        cash_adjustments: 0,\n        non_cash_adjustments: 0,\n        currency: \"USD\"\n      )\n      @investment.balances.create!(\n        date: @test_period_start + 2.days,\n        balance: 1900,  # Keep old field for now\n        cash_balance: 400,  # Keep old field for now\n        start_balance: 500,\n        start_cash_balance: 500,\n        start_non_cash_balance: 0,\n        cash_inflows: 0,\n        cash_outflows: 100,\n        non_cash_inflows: 1500,\n        non_cash_outflows: 0,\n        net_market_flows: 0,\n        cash_adjustments: 0,\n        non_cash_adjustments: 0,\n        currency: \"USD\"\n      )\n\n      # Day 1: Regular transaction\n      create_transaction(\n        account: @checking,\n        date: @test_period_start,\n        amount: -50,\n        name: \"Grocery Store\"\n      )\n\n      # Day 2: Transfer between accounts\n      @transfer = create_transfer(\n        from_account: @checking,\n        to_account: @savings,\n        amount: 200,\n        date: @test_period_start + 1.day\n      )\n\n      # Day 3: Trade in investment account\n      create_trade(\n        securities(:aapl),\n        account: @investment,\n        qty: 10,\n        date: @test_period_start + 2.days,\n        price: 150\n      )\n\n      # Day 3: Foreign currency transaction\n      create_transaction(\n        account: @investment,\n        date: @test_period_start + 2.days,\n        amount: -100,\n        currency: \"EUR\",\n        name: \"International Wire\"\n      )\n\n      # Create exchange rate for foreign currency\n      ExchangeRate.create!(\n        date: @test_period_start + 2.days,\n        from_currency: \"EUR\",\n        to_currency: \"USD\",\n        rate: 1.1\n      )\n\n      # Day 4: Valuation\n      create_valuation(\n        account: @investment,\n        date: @test_period_start + 3.days,\n        amount: 25\n      )\n    end\nend\n"
  },
  {
    "path": "test/models/account/chartable_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::ChartableTest < ActiveSupport::TestCase\n  test \"generates series and memoizes\" do\n    account = accounts(:depository)\n\n    test_series = mock\n    builder1 = mock\n    builder2 = mock\n\n    Balance::ChartSeriesBuilder.expects(:new)\n      .with(\n        account_ids: [ account.id ],\n        currency: account.currency,\n        period: Period.last_30_days,\n        favorable_direction: account.favorable_direction,\n        interval: nil\n      )\n      .returns(builder1)\n      .once\n\n    Balance::ChartSeriesBuilder.expects(:new)\n      .with(\n        account_ids: [ account.id ],\n        currency: account.currency,\n        period: Period.last_90_days, # Period changed, so memoization should be invalidated\n        favorable_direction: account.favorable_direction,\n        interval: nil\n      )\n      .returns(builder2)\n      .once\n\n    builder1.expects(:balance_series).returns(test_series).twice\n    series1 = account.balance_series\n    memoized_series1 = account.balance_series\n\n    builder2.expects(:balance_series).returns(test_series).twice\n    builder2.expects(:cash_balance_series).returns(test_series).once\n    builder2.expects(:holdings_balance_series).returns(test_series).once\n\n    series2 = account.balance_series(period: Period.last_90_days)\n    memoized_series2 = account.balance_series(period: Period.last_90_days)\n    memoized_series2_cash_view = account.balance_series(period: Period.last_90_days, view: :cash_balance)\n    memoized_series2_holdings_view = account.balance_series(period: Period.last_90_days, view: :holdings_balance)\n  end\nend\n"
  },
  {
    "path": "test/models/account/current_balance_manager_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::CurrentBalanceManagerTest < ActiveSupport::TestCase\n  setup do\n    @family = families(:empty)\n    @linked_account = accounts(:connected)\n  end\n\n  # -------------------------------------------------------------------------------------------------\n  # Manual account current balance management\n  #\n  # Manual accounts do not manage `current_anchor` valuations and have \"auto-update strategies\" to set the current balance.\n  # -------------------------------------------------------------------------------------------------\n\n  test \"when one or more reconciliations exist, append new reconciliation to represent the current balance\" do\n    account = @family.accounts.create!(\n      name: \"Test\",\n      balance: 1000,\n      cash_balance: 1000,\n      currency: \"USD\",\n      accountable: Depository.new\n    )\n\n    # A reconciliation tells us that the user is tracking this account's value with balance-only updates\n    account.entries.create!(\n      date: 30.days.ago.to_date,\n      name: \"First manual recon valuation\",\n      amount: 1200,\n      currency: \"USD\",\n      entryable: Valuation.new(kind: \"reconciliation\")\n    )\n\n    manager = Account::CurrentBalanceManager.new(account)\n\n    assert_equal 1, account.valuations.count\n\n    # Here, we assume user is once again \"overriding\" the balance to 1400\n    manager.set_current_balance(1400)\n\n    today_valuation = account.entries.valuations.find_by(date: Date.current)\n\n    assert_equal 2, account.valuations.count\n    assert_equal 1400, today_valuation.amount\n\n    assert_equal 1400, account.balance\n  end\n\n  test \"all manual non cash accounts append reconciliations for current balance updates\" do\n    [ Property, Vehicle, OtherAsset, Loan, OtherLiability ].each do |account_type|\n      account = @family.accounts.create!(\n        name: \"Test\",\n        balance: 1000,\n        cash_balance: 1000,\n        currency: \"USD\",\n        accountable: account_type.new\n      )\n\n      manager = Account::CurrentBalanceManager.new(account)\n\n      assert_equal 0, account.valuations.count\n\n      manager.set_current_balance(1400)\n\n      assert_equal 1, account.valuations.count\n\n      today_valuation = account.entries.valuations.find_by(date: Date.current)\n\n      assert_equal 1400, today_valuation.amount\n      assert_equal 1400, account.balance\n    end\n  end\n\n  # Scope: Depository, CreditCard only (i.e. all-cash accounts)\n  #\n  # If a user has an opening balance (valuation) for their manual *Depository* or *CreditCard* account and has 1+ transactions, the intent of\n  # \"updating current balance\" typically means that their start balance is incorrect. We follow that user intent\n  # by default and find the delta required, and update the opening balance so that the timeline reflects this current balance\n  #\n  # The purpose of this is so we're not cluttering up their timeline with \"balance reconciliations\" that reset the balance\n  # on the current date. Our goal is to keep the timeline with as few \"Valuations\" as possible.\n  #\n  # If we ever build a UI that gives user options, this test expectation may require some updates, but for now this\n  # is the least surprising outcome.\n  test \"when no reconciliations exist on cash accounts, adjust opening balance with delta until it gets us to the desired balance\" do\n    account = @family.accounts.create!(\n      name: \"Test\",\n      balance: 900, # the balance after opening valuation + transaction have \"synced\" (1000 - 100 = 900)\n      cash_balance: 900,\n      currency: \"USD\",\n      accountable: Depository.new\n    )\n\n    account.entries.create!(\n      date: 1.year.ago.to_date,\n      name: \"Test opening valuation\",\n      amount: 1000,\n      currency: \"USD\",\n      entryable: Valuation.new(kind: \"opening_anchor\")\n    )\n\n    account.entries.create!(\n      date: 10.days.ago.to_date,\n      name: \"Test expense transaction\",\n      amount: 100,\n      currency: \"USD\",\n      entryable: Transaction.new\n    )\n\n    # What we're asserting here:\n    # 1. User creates the account with an opening balance of 1000\n    # 2. User creates a transaction of 100, which then reduces the balance to 900 (the current balance value on account above)\n    # 3. User requests \"current balance update\" back to 1000, which was their intention\n    # 4. We adjust the opening balance by the delta (100) to 1100, which is the new opening balance, so that the transaction\n    #    of 100 reduces it down to 1000, which is the current balance they intended.\n    assert_equal 1, account.valuations.count\n    assert_equal 1, account.transactions.count\n\n    # No new valuation is appended; we're just adjusting the opening valuation anchor\n    assert_no_difference \"account.entries.count\" do\n      manager = Account::CurrentBalanceManager.new(account)\n      manager.set_current_balance(1000)\n    end\n\n    opening_valuation = account.valuations.find_by(kind: \"opening_anchor\")\n\n    assert_equal 1100, opening_valuation.entry.amount\n    assert_equal 1000, account.balance\n  end\n\n  # (SEE ABOVE TEST FOR MORE DETAILED EXPLANATION)\n  # Same assertions as the test above, but Credit Card accounts are liabilities, which means expenses increase balance; not decrease\n  test \"when no reconciliations exist on credit card accounts, adjust opening balance with delta until it gets us to the desired balance\" do\n    account = @family.accounts.create!(\n      name: \"Test\",\n      balance: 1100, # the balance after opening valuation + transaction have \"synced\" (1000 + 100 = 1100) (expenses increase balance)\n      cash_balance: 1100,\n      currency: \"USD\",\n      accountable: CreditCard.new\n    )\n\n    account.entries.create!(\n      date: 1.year.ago.to_date,\n      name: \"Test opening valuation\",\n      amount: 1000,\n      currency: \"USD\",\n      entryable: Valuation.new(kind: \"opening_anchor\")\n    )\n\n    account.entries.create!(\n      date: 10.days.ago.to_date,\n      name: \"Test expense transaction\",\n      amount: 100,\n      currency: \"USD\",\n      entryable: Transaction.new\n    )\n\n    assert_equal 1, account.valuations.count\n    assert_equal 1, account.transactions.count\n\n    assert_no_difference \"account.entries.count\" do\n      manager = Account::CurrentBalanceManager.new(account)\n      manager.set_current_balance(1000)\n    end\n\n    opening_valuation = account.valuations.find_by(kind: \"opening_anchor\")\n\n    assert_equal 900, opening_valuation.entry.amount\n    assert_equal 1000, account.balance\n  end\n\n  # -------------------------------------------------------------------------------------------------\n  # Linked account current balance management\n  #\n  # Linked accounts manage \"current balance\" via the special `current_anchor` valuation.\n  # This is NOT a user-facing feature, and is primarily used in \"processors\" while syncing\n  # linked account data (e.g. via Plaid)\n  # -------------------------------------------------------------------------------------------------\n\n  test \"when no existing anchor for linked account, creates new anchor\" do\n    manager = Account::CurrentBalanceManager.new(@linked_account)\n\n    assert_difference -> { @linked_account.entries.count } => 1,\n                     -> { @linked_account.valuations.count } => 1 do\n      result = manager.set_current_balance(1000)\n\n      assert result.success?\n      assert result.changes_made?\n      assert_nil result.error\n    end\n\n    current_anchor = @linked_account.valuations.current_anchor.first\n    assert_not_nil current_anchor\n    assert_equal 1000, current_anchor.entry.amount\n    assert_equal \"current_anchor\", current_anchor.kind\n\n    entry = current_anchor.entry\n    assert_equal 1000, entry.amount\n    assert_equal Date.current, entry.date\n    assert_equal \"Current balance\", entry.name  # Depository type returns \"Current balance\"\n\n    assert_equal 1000, @linked_account.balance\n  end\n\n  test \"updates existing anchor for linked account\" do\n    # First create a current anchor\n    manager = Account::CurrentBalanceManager.new(@linked_account)\n    result = manager.set_current_balance(1000)\n    assert result.success?\n\n    current_anchor = @linked_account.valuations.current_anchor.first\n    original_id = current_anchor.id\n    original_entry_id = current_anchor.entry.id\n\n    # Travel to tomorrow to ensure date change\n    travel_to Date.current + 1.day do\n      # Now update it\n      assert_no_difference -> { @linked_account.entries.count } do\n        assert_no_difference -> { @linked_account.valuations.count } do\n          result = manager.set_current_balance(2000)\n          assert result.success?\n          assert result.changes_made?\n        end\n      end\n\n      current_anchor.reload\n      assert_equal original_id, current_anchor.id # Same valuation record\n      assert_equal original_entry_id, current_anchor.entry.id # Same entry record\n      assert_equal 2000, current_anchor.entry.amount\n      assert_equal Date.current, current_anchor.entry.date # Should be updated to current date\n    end\n\n    assert_equal 2000, @linked_account.balance\n  end\n\n  test \"when no changes made, returns success with no changes made\" do\n    # First create a current anchor\n    manager = Account::CurrentBalanceManager.new(@linked_account)\n    result = manager.set_current_balance(1000)\n    assert result.success?\n    assert result.changes_made?\n\n    # Try to set the same value on the same date\n    result = manager.set_current_balance(1000)\n\n    assert result.success?\n    assert_not result.changes_made?\n    assert_nil result.error\n\n    assert_equal 1000, @linked_account.balance\n  end\n\n  test \"updates only amount when balance changes\" do\n    manager = Account::CurrentBalanceManager.new(@linked_account)\n\n    # Create initial anchor\n    result = manager.set_current_balance(1000)\n    assert result.success?\n\n    current_anchor = @linked_account.valuations.current_anchor.first\n    original_date = current_anchor.entry.date\n\n    # Update only the balance\n    result = manager.set_current_balance(1500)\n    assert result.success?\n    assert result.changes_made?\n\n    current_anchor.reload\n    assert_equal 1500, current_anchor.entry.amount\n    assert_equal original_date, current_anchor.entry.date # Date should remain the same if on same day\n\n    assert_equal 1500, @linked_account.balance\n  end\n\n  test \"updates date when called on different day\" do\n    manager = Account::CurrentBalanceManager.new(@linked_account)\n\n    # Create initial anchor\n    result = manager.set_current_balance(1000)\n    assert result.success?\n\n    current_anchor = @linked_account.valuations.current_anchor.first\n    original_amount = current_anchor.entry.amount\n\n    # Travel to tomorrow and update with same balance\n    travel_to Date.current + 1.day do\n      result = manager.set_current_balance(1000)\n      assert result.success?\n      assert result.changes_made? # Should be true because date changed\n\n      current_anchor.reload\n      assert_equal original_amount, current_anchor.entry.amount\n      assert_equal Date.current, current_anchor.entry.date # Should be updated to new current date\n    end\n\n    assert_equal 1000, @linked_account.balance\n  end\n\n  test \"current_balance returns balance from current anchor\" do\n    manager = Account::CurrentBalanceManager.new(@linked_account)\n\n    # Create a current anchor\n    manager.set_current_balance(1500)\n\n    # Should return the anchor's balance\n    assert_equal 1500, manager.current_balance\n\n    # Update the anchor\n    manager.set_current_balance(2500)\n\n    # Should return the updated balance\n    assert_equal 2500, manager.current_balance\n\n    assert_equal 2500, @linked_account.balance\n  end\n\n  test \"current_balance falls back to account balance when no anchor exists\" do\n    manager = Account::CurrentBalanceManager.new(@linked_account)\n\n    # When no current anchor exists, should fall back to account.balance\n    assert_equal @linked_account.balance, manager.current_balance\n\n    assert_equal @linked_account.balance, @linked_account.balance\n  end\nend\n"
  },
  {
    "path": "test/models/account/entry_test.rb",
    "content": "require \"test_helper\"\n\nclass EntryTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @entry = entries :transaction\n  end\n\n  test \"entry cannot be older than 10 years ago\" do\n    assert_raises ActiveRecord::RecordInvalid do\n      @entry.update! date: 50.years.ago.to_date\n    end\n  end\n\n  test \"valuations cannot have more than one entry per day\" do\n    existing_valuation = entries :valuation\n\n    new_valuation = Entry.new \\\n      entryable: Valuation.new(kind: \"reconciliation\"),\n      account: existing_valuation.account,\n      date: existing_valuation.date, # invalid\n      currency: existing_valuation.currency,\n      amount: existing_valuation.amount\n\n    assert new_valuation.invalid?\n  end\n\n  test \"triggers sync with correct start date when transaction is set to prior date\" do\n    prior_date = @entry.date - 1\n    @entry.update! date: prior_date\n\n    @entry.account.expects(:sync_later).with(window_start_date: prior_date)\n    @entry.sync_account_later\n  end\n\n  test \"triggers sync with correct start date when transaction is set to future date\" do\n    prior_date = @entry.date\n    @entry.update! date: @entry.date + 1\n\n    @entry.account.expects(:sync_later).with(window_start_date: prior_date)\n    @entry.sync_account_later\n  end\n\n  test \"triggers sync with correct start date when transaction deleted\" do\n    @entry.destroy!\n\n    @entry.account.expects(:sync_later).with(window_start_date: nil)\n    @entry.sync_account_later\n  end\n\n  test \"can search entries\" do\n    family = families(:empty)\n    account = family.accounts.create! name: \"Test\", balance: 0, currency: \"USD\", accountable: Depository.new\n    category = family.categories.first\n    merchant = family.merchants.first\n\n    create_transaction(account: account, name: \"a transaction\")\n    create_transaction(account: account, name: \"ignored\")\n    create_transaction(account: account, name: \"third transaction\", category: category, merchant: merchant)\n\n    params = { search: \"a\" }\n\n    assert_equal 2, family.entries.search(params).size\n\n    params = { search: \"%\" }\n    assert_equal 0, family.entries.search(params).size\n  end\n\n  test \"visible scope only returns entries from visible accounts\" do\n    # Create transactions for all account types\n    visible_transaction = create_transaction(account: accounts(:depository), name: \"Visible transaction\")\n    invisible_transaction = create_transaction(account: accounts(:credit_card), name: \"Invisible transaction\")\n\n    # Update account statuses\n    accounts(:credit_card).disable!\n\n    # Test the scope\n    visible_entries = Entry.visible\n\n    # Should include entry from active account\n    assert_includes visible_entries, visible_transaction\n\n    # Should not include entry from disabled account\n    assert_not_includes visible_entries, invisible_transaction\n  end\nend\n"
  },
  {
    "path": "test/models/account/market_data_importer_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass Account::MarketDataImporterTest < ActiveSupport::TestCase\n  include ProviderTestHelper\n\n  PROVIDER_BUFFER = 5.days\n\n  setup do\n    # Ensure a clean slate for deterministic assertions\n    Security::Price.delete_all\n    ExchangeRate.delete_all\n    Trade.delete_all\n    Holding.delete_all\n    Security.delete_all\n    Entry.delete_all\n\n    @provider = mock(\"provider\")\n    Provider::Registry.any_instance\n                      .stubs(:get_provider)\n                      .with(:synth)\n                      .returns(@provider)\n  end\n\n  test \"syncs required exchange rates for a foreign-currency account\" do\n    family = Family.create!(name: \"Smith\", currency: \"USD\")\n\n    account = family.accounts.create!(\n      name: \"Chequing\",\n      currency: \"CAD\",\n      balance: 100,\n      accountable: Depository.new\n    )\n\n    # Seed a rate for the first required day so that the importer only needs the next day forward\n    existing_date = account.start_date\n    ExchangeRate.create!(from_currency: \"CAD\", to_currency: \"USD\", date: existing_date, rate: 2.0)\n\n    expected_start_date = (existing_date + 1.day) - PROVIDER_BUFFER\n    end_date            = Date.current.in_time_zone(\"America/New_York\").to_date\n\n    @provider.expects(:fetch_exchange_rates)\n             .with(from: \"CAD\",\n                   to: \"USD\",\n                   start_date: expected_start_date,\n                   end_date: end_date)\n             .returns(provider_success_response([\n               OpenStruct.new(from: \"CAD\", to: \"USD\", date: existing_date, rate: 1.5)\n             ]))\n\n    before = ExchangeRate.count\n    Account::MarketDataImporter.new(account).import_all\n    after  = ExchangeRate.count\n\n    assert_operator after, :>, before, \"Should insert at least one new exchange-rate row\"\n  end\n\n  test \"syncs security prices for securities traded by the account\" do\n    family = Family.create!(name: \"Smith\", currency: \"USD\")\n\n    account = family.accounts.create!(\n      name: \"Brokerage\",\n      currency: \"USD\",\n      balance: 0,\n      accountable: Investment.new\n    )\n\n    security = Security.create!(ticker: \"AAPL\", exchange_operating_mic: \"XNAS\")\n\n    trade_date = 10.days.ago.to_date\n    trade      = Trade.new(security: security, qty: 1, price: 100, currency: \"USD\")\n\n    account.entries.create!(\n      name: \"Buy AAPL\",\n      date: trade_date,\n      amount: 100,\n      currency: \"USD\",\n      entryable: trade\n    )\n\n    expected_start_date = trade_date - PROVIDER_BUFFER\n    end_date            = Date.current.in_time_zone(\"America/New_York\").to_date\n\n    @provider.expects(:fetch_security_prices)\n             .with(symbol: security.ticker,\n                   exchange_operating_mic: security.exchange_operating_mic,\n                   start_date: expected_start_date,\n                   end_date: end_date)\n             .returns(provider_success_response([\n               OpenStruct.new(security: security,\n                              date: trade_date,\n                              price: 100,\n                              currency: \"USD\")\n             ]))\n\n    @provider.stubs(:fetch_security_info)\n             .with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic)\n             .returns(provider_success_response(OpenStruct.new(name: \"Apple\", logo_url: \"logo\")))\n\n    # Ignore exchange-rate calls for this test\n    @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([]))\n\n    Account::MarketDataImporter.new(account).import_all\n\n    assert_equal 1, Security::Price.where(security: security, date: trade_date).count\n  end\nend\n"
  },
  {
    "path": "test/models/account/opening_balance_manager_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::OpeningBalanceManagerTest < ActiveSupport::TestCase\n  setup do\n    @depository_account = accounts(:depository)\n    @investment_account = accounts(:investment)\n  end\n\n  test \"when no existing anchor, creates new anchor\" do\n    manager = Account::OpeningBalanceManager.new(@depository_account)\n\n    assert_difference -> { @depository_account.entries.count } => 1,\n                     -> { @depository_account.valuations.count } => 1 do\n      result = manager.set_opening_balance(\n        balance: 1000,\n        date: 1.year.ago.to_date\n      )\n\n      assert result.success?\n      assert result.changes_made?\n      assert_nil result.error\n    end\n\n    opening_anchor = @depository_account.valuations.opening_anchor.first\n    assert_not_nil opening_anchor\n    assert_equal 1000, opening_anchor.entry.amount\n    assert_equal \"opening_anchor\", opening_anchor.kind\n\n    entry = opening_anchor.entry\n    assert_equal 1000, entry.amount\n    assert_equal 1.year.ago.to_date, entry.date\n    assert_equal \"Opening balance\", entry.name\n  end\n\n  test \"when no existing anchor, creates with provided balance\" do\n    # Test with Depository account (should default to balance)\n    depository_manager = Account::OpeningBalanceManager.new(@depository_account)\n\n    assert_difference -> { @depository_account.valuations.count } => 1 do\n      result = depository_manager.set_opening_balance(balance: 2000)\n      assert result.success?\n      assert result.changes_made?\n    end\n\n    depository_anchor = @depository_account.valuations.opening_anchor.first\n    assert_equal 2000, depository_anchor.entry.amount\n\n    # Test with Investment account (should default to 0)\n    investment_manager = Account::OpeningBalanceManager.new(@investment_account)\n\n    assert_difference -> { @investment_account.valuations.count } => 1 do\n      result = investment_manager.set_opening_balance(balance: 5000)\n      assert result.success?\n      assert result.changes_made?\n    end\n\n    investment_anchor = @investment_account.valuations.opening_anchor.first\n    assert_equal 5000, investment_anchor.entry.amount\n  end\n\n  test \"when no existing anchor and no date provided, provides default based on account type\" do\n    # Test with recent entry (less than 2 years ago)\n    @depository_account.entries.create!(\n      date: 30.days.ago.to_date,\n      name: \"Test transaction\",\n      amount: 100,\n      currency: \"USD\",\n      entryable: Transaction.new\n    )\n\n    manager = Account::OpeningBalanceManager.new(@depository_account)\n\n    assert_difference -> { @depository_account.valuations.count } => 1 do\n      result = manager.set_opening_balance(balance: 1500)\n      assert result.success?\n      assert result.changes_made?\n    end\n\n    opening_anchor = @depository_account.valuations.opening_anchor.first\n    # Default should be MIN(1 day before oldest entry, 2 years ago) = 2 years ago\n    assert_equal 2.years.ago.to_date, opening_anchor.entry.date\n\n    # Test with old entry (more than 2 years ago)\n    loan_account = accounts(:loan)\n    loan_account.entries.create!(\n      date: 3.years.ago.to_date,\n      name: \"Old transaction\",\n      amount: 100,\n      currency: \"USD\",\n      entryable: Transaction.new\n    )\n\n    loan_manager = Account::OpeningBalanceManager.new(loan_account)\n\n    assert_difference -> { loan_account.valuations.count } => 1 do\n      result = loan_manager.set_opening_balance(balance: 5000)\n      assert result.success?\n      assert result.changes_made?\n    end\n\n    loan_anchor = loan_account.valuations.opening_anchor.first\n    # Default should be MIN(3 years ago - 1 day, 2 years ago) = 3 years ago - 1 day\n    assert_equal (3.years.ago.to_date - 1.day), loan_anchor.entry.date\n\n    # Test with account that has no entries\n    property_account = accounts(:property)\n    manager_no_entries = Account::OpeningBalanceManager.new(property_account)\n\n    assert_difference -> { property_account.valuations.count } => 1 do\n      result = manager_no_entries.set_opening_balance(balance: 3000)\n      assert result.success?\n      assert result.changes_made?\n    end\n\n    opening_anchor_no_entries = property_account.valuations.opening_anchor.first\n    # Default should be 2 years ago when no entries exist\n    assert_equal 2.years.ago.to_date, opening_anchor_no_entries.entry.date\n  end\n\n  test \"updates existing anchor\" do\n    # First create an opening anchor\n    manager = Account::OpeningBalanceManager.new(@depository_account)\n    result = manager.set_opening_balance(\n      balance: 1000,\n      date: 6.months.ago.to_date\n    )\n    assert result.success?\n\n    opening_anchor = @depository_account.valuations.opening_anchor.first\n    original_id = opening_anchor.id\n    original_entry_id = opening_anchor.entry.id\n\n    # Now update it\n    assert_no_difference -> { @depository_account.entries.count } do\n      assert_no_difference -> { @depository_account.valuations.count } do\n        result = manager.set_opening_balance(\n          balance: 2000,\n          date: 8.months.ago.to_date\n        )\n        assert result.success?\n        assert result.changes_made?\n      end\n    end\n\n    opening_anchor.reload\n    assert_equal original_id, opening_anchor.id # Same valuation record\n    assert_equal original_entry_id, opening_anchor.entry.id # Same entry record\n    assert_equal 2000, opening_anchor.entry.amount\n    assert_equal 2000, opening_anchor.entry.amount\n    assert_equal 8.months.ago.to_date, opening_anchor.entry.date\n  end\n\n  test \"when existing anchor and no date provided, only update balance\" do\n    # First create an opening anchor\n    manager = Account::OpeningBalanceManager.new(@depository_account)\n    result = manager.set_opening_balance(\n      balance: 1000,\n      date: 3.months.ago.to_date\n    )\n    assert result.success?\n\n    opening_anchor = @depository_account.valuations.opening_anchor.first\n\n    # Update without providing date\n    result = manager.set_opening_balance(balance: 1500)\n    assert result.success?\n    assert result.changes_made?\n\n    opening_anchor.reload\n    assert_equal 1500, opening_anchor.entry.amount\n  end\n\n  test \"when existing anchor and updating balance only, preserves original date\" do\n    # First create an opening anchor with specific date\n    manager = Account::OpeningBalanceManager.new(@depository_account)\n    original_date = 4.months.ago.to_date\n    result = manager.set_opening_balance(\n      balance: 1000,\n      date: original_date\n    )\n    assert result.success?\n\n    opening_anchor = @depository_account.valuations.opening_anchor.first\n\n    # Update without providing date\n    result = manager.set_opening_balance(balance: 2500)\n    assert result.success?\n    assert result.changes_made?\n\n    opening_anchor.reload\n    assert_equal 2500, opening_anchor.entry.amount\n    assert_equal original_date, opening_anchor.entry.date # Should remain unchanged\n  end\n\n  test \"when date is equal to or greater than account's oldest entry, returns error result\" do\n    # Create an entry with a specific date\n    oldest_date = 60.days.ago.to_date\n    @depository_account.entries.create!(\n      date: oldest_date,\n      name: \"Test transaction\",\n      amount: 100,\n      currency: \"USD\",\n      entryable: Transaction.new\n    )\n\n    manager = Account::OpeningBalanceManager.new(@depository_account)\n\n    # Try to set opening balance on the same date as oldest entry\n    result = manager.set_opening_balance(\n      balance: 1000,\n      date: oldest_date\n    )\n\n    assert_not result.success?\n    assert_not result.changes_made?\n    assert_equal \"Opening balance date must be before the oldest entry date\", result.error\n\n    # Try to set opening balance after the oldest entry\n    result = manager.set_opening_balance(\n      balance: 1000,\n      date: oldest_date + 1.day\n    )\n\n    assert_not result.success?\n    assert_not result.changes_made?\n    assert_equal \"Opening balance date must be before the oldest entry date\", result.error\n\n    # Verify no opening anchor was created\n    assert_nil @depository_account.valuations.opening_anchor.first\n  end\n\n  test \"when no changes made, returns success with no changes made\" do\n    # First create an opening anchor\n    manager = Account::OpeningBalanceManager.new(@depository_account)\n    result = manager.set_opening_balance(\n      balance: 1000,\n      date: 2.months.ago.to_date\n    )\n    assert result.success?\n    assert result.changes_made?\n\n    # Try to set the same values\n    result = manager.set_opening_balance(\n      balance: 1000,\n      date: 2.months.ago.to_date\n    )\n\n    assert result.success?\n    assert_not result.changes_made?\n    assert_nil result.error\n  end\nend\n"
  },
  {
    "path": "test/models/account/reconciliation_manager_test.rb",
    "content": "require \"test_helper\"\n\nclass Account::ReconciliationManagerTest < ActiveSupport::TestCase\n  include BalanceTestHelper\n\n  setup do\n    @account = accounts(:investment)\n    @manager = Account::ReconciliationManager.new(@account)\n  end\n\n  test \"new reconciliation\" do\n    create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)\n\n    result = @manager.reconcile_balance(balance: 1200, date: Date.current)\n\n    assert_equal 1200, result.new_balance\n    assert_equal 700, result.new_cash_balance # Non cash stays the same since user is valuing the entire account balance\n    assert_equal 1000, result.old_balance\n    assert_equal 500, result.old_cash_balance\n    assert_equal true, result.success?\n  end\n\n  test \"updates existing reconciliation without date change\" do\n    create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)\n\n    # Existing reconciliation entry\n    existing_entry = @account.entries.create!(name: \"Test\", amount: 1000, date: Date.current, entryable: Valuation.new(kind: \"reconciliation\"), currency: @account.currency)\n\n    result = @manager.reconcile_balance(balance: 1200, date: Date.current, existing_valuation_entry: existing_entry)\n\n    assert_equal 1200, result.new_balance\n    assert_equal 700, result.new_cash_balance # Non cash stays the same since user is valuing the entire account balance\n    assert_equal 1000, result.old_balance\n    assert_equal 500, result.old_cash_balance\n    assert_equal true, result.success?\n  end\n\n  test \"updates existing reconciliation with date and amount change\" do\n    create_balance(account: @account, date: 5.days.ago, balance: 1000, cash_balance: 500)\n    create_balance(account: @account, date: Date.current, balance: 1200, cash_balance: 700)\n\n    # Existing reconciliation entry (5 days ago)\n    existing_entry = @account.entries.create!(name: \"Test\", amount: 1000, date: 5.days.ago, entryable: Valuation.new(kind: \"reconciliation\"), currency: @account.currency)\n\n    # Should update and change date for existing entry; not create a new one\n    assert_no_difference \"Valuation.count\" do\n      # \"Update valuation from 5 days ago to today, set balance from 1000 to 1500\"\n      result = @manager.reconcile_balance(balance: 1500, date: Date.current, existing_valuation_entry: existing_entry)\n\n      assert_equal true, result.success?\n\n      # Reconciliation\n      assert_equal 1500, result.new_balance # Equal to new valuation amount\n      assert_equal 1000, result.new_cash_balance # Get non-cash balance today (1200 - 700 = 500). Then subtract this from new valuation (1500 - 500 = 1000)\n\n      # Prior valuation\n      assert_equal 1000, result.old_balance # This is the balance from the old valuation, NOT the date we're reconciling to\n      assert_equal 500, result.old_cash_balance\n    end\n  end\n\n  test \"handles date conflicts\" do\n    create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 1000)\n\n    # Existing reconciliation entry\n    @account.entries.create!(\n      name: \"Test\",\n      amount: 1000,\n      date: Date.current,\n      entryable: Valuation.new(kind: \"reconciliation\"),\n      currency: @account.currency\n    )\n\n    # Doesn't pass existing_valuation_entry, but reconciliation manager should recognize its the same date and update the existing entry\n    assert_no_difference \"Valuation.count\" do\n      result = @manager.reconcile_balance(balance: 1200, date: Date.current)\n\n      assert result.success?\n      assert_equal 1200, result.new_balance\n    end\n  end\n\n  test \"dry run does not persist account\" do\n    create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)\n\n    assert_no_difference \"Valuation.count\" do\n      @manager.reconcile_balance(balance: 1200, date: Date.current, dry_run: true)\n    end\n\n    assert_difference \"Valuation.count\", 1 do\n      @manager.reconcile_balance(balance: 1200, date: Date.current)\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/account/transaction_test.rb",
    "content": "require \"test_helper\"\n\nclass TransactionTest < ActiveSupport::TestCase\n  include EntriesTestHelper\nend\n"
  },
  {
    "path": "test/models/account_import_test.rb",
    "content": "require \"test_helper\"\n\nclass AccountImportTest < ActiveSupport::TestCase\n  include ActiveJob::TestHelper, ImportInterfaceTest\n\n  setup do\n    @subject = @import = imports(:account)\n  end\n\n  test \"import creates accounts with valuations\" do\n    import_csv = <<~CSV\n      type,name,amount,currency\n      depository,Main Checking,1000.00,USD\n      depository,Savings Account,5000.00,USD\n    CSV\n\n    @import.update!(\n      raw_file_str: import_csv,\n      entity_type_col_label: \"type\",\n      name_col_label: \"name\",\n      amount_col_label: \"amount\",\n      currency_col_label: \"currency\"\n    )\n\n    @import.generate_rows_from_csv\n\n    # Create mappings for account types\n    @import.mappings.create! key: \"depository\", value: \"Depository\", type: \"Import::AccountTypeMapping\"\n\n    @import.reload\n\n    # Store initial counts\n    initial_account_count = Account.count\n    initial_entry_count = Entry.count\n    initial_valuation_count = Valuation.count\n\n    # Perform the import\n    @import.publish\n\n    # Check if import succeeded\n    if @import.failed?\n      fail \"Import failed with error: #{@import.error}\"\n    end\n\n    assert_equal \"complete\", @import.status\n\n    # Check the differences\n    assert_equal initial_account_count + 2, Account.count, \"Expected 2 new accounts\"\n    assert_equal initial_entry_count + 2, Entry.count, \"Expected 2 new entries\"\n    assert_equal initial_valuation_count + 2, Valuation.count, \"Expected 2 new valuations\"\n\n    # Verify accounts were created correctly\n    accounts = @import.accounts.order(:name)\n    assert_equal [ \"Main Checking\", \"Savings Account\" ], accounts.pluck(:name)\n    assert_equal [ 1000.00, 5000.00 ], accounts.map { |a| a.balance.to_f }\n\n    # Verify valuations were created with correct fields\n    accounts.each do |account|\n      valuation = account.valuations.last\n      assert_not_nil valuation\n      assert_equal \"opening_anchor\", valuation.kind\n      assert_equal account.balance, valuation.entry.amount\n    end\n  end\n\n  test \"column_keys returns expected keys\" do\n    assert_equal %i[entity_type name amount currency], @import.column_keys\n  end\n\n  test \"required_column_keys returns expected keys\" do\n    assert_equal %i[name amount], @import.required_column_keys\n  end\n\n  test \"mapping_steps returns account type mapping\" do\n    assert_equal [ Import::AccountTypeMapping ], @import.mapping_steps\n  end\n\n  test \"dry_run returns expected counts\" do\n    @import.rows.create!(\n      entity_type: \"depository\",\n      name: \"Test Account\",\n      amount: \"1000.00\",\n      currency: \"USD\"\n    )\n\n    assert_equal({ accounts: 1 }, @import.dry_run)\n  end\n\n  test \"max_row_count is limited to 50\" do\n    assert_equal 50, @import.max_row_count\n  end\nend\n"
  },
  {
    "path": "test/models/account_test.rb",
    "content": "require \"test_helper\"\n\nclass AccountTest < ActiveSupport::TestCase\n  include SyncableInterfaceTest, EntriesTestHelper\n\n  setup do\n    @account = @syncable = accounts(:depository)\n    @family = families(:dylan_family)\n  end\n\n  test \"can destroy\" do\n    assert_difference \"Account.count\", -1 do\n      @account.destroy\n    end\n  end\n\n  test \"gets short/long subtype label\" do\n    account = @family.accounts.create!(\n      name: \"Test Investment\",\n      balance: 1000,\n      currency: \"USD\",\n      subtype: \"hsa\",\n      accountable: Investment.new\n    )\n\n    assert_equal \"HSA\", account.short_subtype_label\n    assert_equal \"Health Savings Account\", account.long_subtype_label\n\n    # Test with nil subtype\n    account.update!(subtype: nil)\n    assert_equal \"Investments\", account.short_subtype_label\n    assert_equal \"Investments\", account.long_subtype_label\n  end\nend\n"
  },
  {
    "path": "test/models/address_test.rb",
    "content": "require \"test_helper\"\n\nclass AddressTest < ActiveSupport::TestCase\n  test \"can print a formatted address\" do\n    address = Address.new(\n      line1: \"123 Main St\",\n      locality: \"San Francisco\",\n      region: \"CA\",\n      country: \"US\",\n      postal_code: \"94101\"\n    )\n\n    assert_equal \"123 Main St, San Francisco, CA 94101 US\", address.to_s\n  end\n\n  test \"can print a formatted address with line2\" do\n    address = Address.new(\n      line1: \"123 Main St\",\n      line2: \"Apt 1\",\n      locality: \"San Francisco\",\n      region: \"CA\",\n      country: \"US\",\n      postal_code: \"94101\"\n    )\n\n    assert_equal \"123 Main St Apt 1, San Francisco, CA 94101 US\", address.to_s\n  end\n\n  test \"can print empty when address is empty\" do\n    address = Address.new(\n      line1: nil,\n      line2: nil,\n      locality: nil,\n      region: nil,\n      country: nil,\n      postal_code: nil\n    )\n\n    assert_equal \"\", address.to_s\n  end\n\n  test \"can strip extras commas and spaces\" do\n    address = Address.new(\n      line1: \"123 Main St ,\",\n      locality: \" San Francisco, \",\n    )\n\n    assert_equal \"123 Main St, San Francisco\", address.to_s\n  end\nend\n"
  },
  {
    "path": "test/models/api_key_test.rb",
    "content": "require \"test_helper\"\n\nclass ApiKeyTest < ActiveSupport::TestCase\n  def setup\n    @user = users(:family_admin)\n    # Clean up any existing API keys for this user to ensure tests start fresh\n    @user.api_keys.destroy_all\n    @api_key = ApiKey.new(\n      user: @user,\n      name: \"Test API Key\",\n      key: \"test_plain_key_123\",\n      scopes: [ \"read_write\" ]\n    )\n  end\n\n  test \"should be valid with valid attributes\" do\n    assert @api_key.valid?\n  end\n\n  test \"should require display_key presence after save\" do\n    @api_key.key = nil\n    assert_not @api_key.valid?\n  end\n\n  test \"should require name presence\" do\n    @api_key.name = nil\n    assert_not @api_key.valid?\n    assert_includes @api_key.errors[:name], \"can't be blank\"\n  end\n\n  test \"should require scopes presence\" do\n    @api_key.scopes = nil\n    assert_not @api_key.valid?\n    assert_includes @api_key.errors[:scopes], \"can't be blank\"\n  end\n\n  test \"should require user association\" do\n    @api_key.user = nil\n    assert_not @api_key.valid?\n    assert_includes @api_key.errors[:user], \"must exist\"\n  end\n\n  test \"should set display_key from key before saving\" do\n    original_key = @api_key.key\n    @api_key.save!\n\n    # display_key should be encrypted but plain_key should return the original\n    assert_equal original_key, @api_key.plain_key\n  end\n\n  test \"should find api key by plain value\" do\n    plain_key = @api_key.key\n    @api_key.save!\n\n    found_key = ApiKey.find_by_value(plain_key)\n    assert_equal @api_key, found_key\n  end\n\n  test \"should return nil when finding by invalid value\" do\n    @api_key.save!\n\n    found_key = ApiKey.find_by_value(\"invalid_key\")\n    assert_nil found_key\n  end\n\n  test \"should return nil when finding by nil value\" do\n    @api_key.save!\n\n    found_key = ApiKey.find_by_value(nil)\n    assert_nil found_key\n  end\n\n  test \"key_matches? should work with plain key\" do\n    plain_key = @api_key.key\n    @api_key.save!\n\n    assert @api_key.key_matches?(plain_key)\n    assert_not @api_key.key_matches?(\"wrong_key\")\n  end\n\n  test \"should be active when not revoked and not expired\" do\n    @api_key.save!\n\n    assert @api_key.active?\n  end\n\n  test \"should not be active when revoked\" do\n    @api_key.save!\n    @api_key.revoke!\n\n    assert_not @api_key.active?\n    assert @api_key.revoked?\n  end\n\n  test \"should not be active when expired\" do\n    @api_key.expires_at = 1.day.ago\n    @api_key.save!\n\n    assert_not @api_key.active?\n    assert @api_key.expired?\n  end\n\n  test \"should be active when expires_at is in the future\" do\n    @api_key.expires_at = 1.day.from_now\n    @api_key.save!\n\n    assert @api_key.active?\n    assert_not @api_key.expired?\n  end\n\n  test \"should be active when expires_at is nil\" do\n    @api_key.expires_at = nil\n    @api_key.save!\n\n    assert @api_key.active?\n    assert_not @api_key.expired?\n  end\n\n  test \"should generate secure key\" do\n    key = ApiKey.generate_secure_key\n\n    assert_kind_of String, key\n    assert_equal 64, key.length  # hex(32) = 64 characters\n    assert key.match?(/\\A[0-9a-f]+\\z/)  # only hex characters\n  end\n\n  test \"should update last_used_at when update_last_used! is called\" do\n    @api_key.save!\n    original_time = @api_key.last_used_at\n\n    sleep(0.01)  # Ensure time difference\n    @api_key.update_last_used!\n\n    assert_not_equal original_time, @api_key.last_used_at\n    assert @api_key.last_used_at > (original_time || Time.at(0))\n  end\n\n  test \"should prevent user from having multiple active api keys\" do\n    @api_key.save!\n\n    second_key = ApiKey.new(\n      user: @user,\n      name: \"Second API Key\",\n      key: \"another_key_123\",\n      scopes: [ \"read\" ]\n    )\n\n    assert_not second_key.valid?\n    assert_includes second_key.errors[:user], \"can only have one active API key per source (web)\"\n  end\n\n  test \"should allow user to have new active key after revoking old one\" do\n    @api_key.save!\n    @api_key.revoke!\n\n    second_key = ApiKey.new(\n      user: @user,\n      name: \"Second API Key\",\n      key: \"another_key_123\",\n      scopes: [ \"read\" ]\n    )\n\n    assert second_key.valid?\n  end\n\n  test \"should include active api keys in active scope\" do\n    @api_key.save!\n    active_keys = ApiKey.active\n\n    assert_includes active_keys, @api_key\n  end\n\n  test \"should exclude revoked api keys from active scope\" do\n    @api_key.save!\n    @api_key.revoke!\n    active_keys = ApiKey.active\n\n    assert_not_includes active_keys, @api_key\n  end\n\n  test \"should exclude expired api keys from active scope\" do\n    @api_key.expires_at = 1.day.ago\n    @api_key.save!\n    active_keys = ApiKey.active\n\n    assert_not_includes active_keys, @api_key\n  end\n\n  test \"should return plain_key for display\" do\n    original_key = @api_key.key\n    @api_key.save!\n\n    assert_equal original_key, @api_key.plain_key\n  end\n\n  test \"should not allow multiple scopes\" do\n    @api_key.scopes = [ \"read\", \"read_write\" ]\n    assert_not @api_key.valid?\n    assert_includes @api_key.errors[:scopes], \"can only have one permission level\"\n  end\n\n  test \"should validate scope values\" do\n    @api_key.scopes = [ \"invalid_scope\" ]\n    assert_not @api_key.valid?\n    assert_includes @api_key.errors[:scopes], \"must be either 'read' or 'read_write'\"\n  end\nend\n"
  },
  {
    "path": "test/models/assistant_message_test.rb",
    "content": "require \"test_helper\"\n\nclass AssistantMessageTest < ActiveSupport::TestCase\n  setup do\n    @chat = chats(:one)\n  end\n\n  test \"broadcasts append after creation\" do\n    message = AssistantMessage.create!(chat: @chat, content: \"Hello from assistant\", ai_model: \"gpt-4.1\")\n    message.update!(content: \"updated\")\n\n    streams = capture_turbo_stream_broadcasts(@chat)\n    assert_equal 2, streams.size\n    assert_equal \"append\", streams.first[\"action\"]\n    assert_equal \"messages\", streams.first[\"target\"]\n    assert_equal \"update\", streams.last[\"action\"]\n    assert_equal \"assistant_message_#{message.id}\", streams.last[\"target\"]\n  end\nend\n"
  },
  {
    "path": "test/models/assistant_test.rb",
    "content": "require \"test_helper\"\n\nclass AssistantTest < ActiveSupport::TestCase\n  include ProviderTestHelper\n\n  setup do\n    @chat = chats(:two)\n    @message = @chat.messages.create!(\n      type: \"UserMessage\",\n      content: \"What is my net worth?\",\n      ai_model: \"gpt-4.1\"\n    )\n    @assistant = Assistant.for_chat(@chat)\n    @provider = mock\n  end\n\n  test \"errors get added to chat\" do\n    @assistant.expects(:get_model_provider).with(\"gpt-4.1\").returns(@provider)\n\n    error = StandardError.new(\"test error\")\n    @provider.expects(:chat_response).returns(provider_error_response(error))\n\n    @chat.expects(:add_error).with(error).once\n\n    assert_no_difference \"AssistantMessage.count\"  do\n      @assistant.respond_to(@message)\n    end\n  end\n\n  test \"responds to basic prompt\" do\n    @assistant.expects(:get_model_provider).with(\"gpt-4.1\").returns(@provider)\n\n    text_chunks = [\n      provider_text_chunk(\"I do not \"),\n      provider_text_chunk(\"have the information \"),\n      provider_text_chunk(\"to answer that question\")\n    ]\n\n    response_chunk = provider_response_chunk(\n      id: \"1\",\n      model: \"gpt-4.1\",\n      messages: [ provider_message(id: \"1\", text: text_chunks.join) ],\n      function_requests: []\n    )\n\n    response = provider_success_response(response_chunk.data)\n\n    @provider.expects(:chat_response).with do |message, **options|\n      text_chunks.each do |text_chunk|\n        options[:streamer].call(text_chunk)\n      end\n\n      options[:streamer].call(response_chunk)\n      true\n    end.returns(response)\n\n    assert_difference \"AssistantMessage.count\", 1 do\n      @assistant.respond_to(@message)\n      message = @chat.messages.ordered.where(type: \"AssistantMessage\").last\n      assert_equal \"I do not have the information to answer that question\", message.content\n      assert_equal 0, message.tool_calls.size\n    end\n  end\n\n  test \"responds with tool function calls\" do\n    @assistant.expects(:get_model_provider).with(\"gpt-4.1\").returns(@provider).once\n\n    # Only first provider call executes function\n    Assistant::Function::GetAccounts.any_instance.stubs(:call).returns(\"test value\").once\n\n    # Call #1: Function requests\n    call1_response_chunk = provider_response_chunk(\n      id: \"1\",\n      model: \"gpt-4.1\",\n      messages: [],\n      function_requests: [\n        provider_function_request(id: \"1\", call_id: \"1\", function_name: \"get_accounts\", function_args: \"{}\")\n      ]\n    )\n\n    call1_response = provider_success_response(call1_response_chunk.data)\n\n    # Call #2: Text response (that uses function results)\n    call2_text_chunks = [\n      provider_text_chunk(\"Your net worth is \"),\n      provider_text_chunk(\"$124,200\")\n    ]\n\n    call2_response_chunk = provider_response_chunk(\n      id: \"2\",\n      model: \"gpt-4.1\",\n      messages: [ provider_message(id: \"1\", text: call2_text_chunks.join) ],\n      function_requests: []\n    )\n\n    call2_response = provider_success_response(call2_response_chunk.data)\n\n    sequence = sequence(\"provider_chat_response\")\n\n    @provider.expects(:chat_response).with do |message, **options|\n      call2_text_chunks.each do |text_chunk|\n        options[:streamer].call(text_chunk)\n      end\n\n      options[:streamer].call(call2_response_chunk)\n      true\n    end.returns(call2_response).once.in_sequence(sequence)\n\n    @provider.expects(:chat_response).with do |message, **options|\n      options[:streamer].call(call1_response_chunk)\n      true\n    end.returns(call1_response).once.in_sequence(sequence)\n\n    assert_difference \"AssistantMessage.count\", 1 do\n      @assistant.respond_to(@message)\n      message = @chat.messages.ordered.where(type: \"AssistantMessage\").last\n      assert_equal 1, message.tool_calls.size\n    end\n  end\n\n  private\n    def provider_function_request(id:, call_id:, function_name:, function_args:)\n      Provider::LlmConcept::ChatFunctionRequest.new(\n        id: id,\n        call_id: call_id,\n        function_name: function_name,\n        function_args: function_args\n      )\n    end\n\n    def provider_message(id:, text:)\n      Provider::LlmConcept::ChatMessage.new(id: id, output_text: text)\n    end\n\n    def provider_text_chunk(text)\n      Provider::LlmConcept::ChatStreamChunk.new(type: \"output_text\", data: text)\n    end\n\n    def provider_response_chunk(id:, model:, messages:, function_requests:)\n      Provider::LlmConcept::ChatStreamChunk.new(\n        type: \"response\",\n        data: Provider::LlmConcept::ChatResponse.new(\n          id: id,\n          model: model,\n          messages: messages,\n          function_requests: function_requests\n        )\n      )\n    end\nend\n"
  },
  {
    "path": "test/models/balance/chart_series_builder_test.rb",
    "content": "require \"test_helper\"\n\nclass Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase\n  include BalanceTestHelper\n\n  setup do\n  end\n\n  test \"balance series with fallbacks and gapfills\" do\n    account = accounts(:depository)\n    account.balances.destroy_all\n\n    # With gaps\n    create_balance(account: account, date: 3.days.ago.to_date, balance: 1000)\n    create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)\n    create_balance(account: account, date: Date.current, balance: 1200)\n\n    builder = Balance::ChartSeriesBuilder.new(\n      account_ids: [ account.id ],\n      currency: \"USD\",\n      period: Period.last_30_days,\n      interval: \"1 day\"\n    )\n\n    assert_equal 31, builder.balance_series.size # Last 30 days == 31 total balances\n    assert_equal 0, builder.balance_series.first.value\n\n    expected = [\n      0, # No value, so fallback to 0\n      1000,\n      1000, # Last observation carried forward\n      1100,\n      1200\n    ]\n\n    assert_equal expected, builder.balance_series.last(5).map { |v| v.value.amount }\n  end\n\n  test \"exchange rates apply locf when missing\" do\n    account = accounts(:depository)\n    account.balances.destroy_all\n\n    create_balance(account: account, date: 2.days.ago.to_date, balance: 1000)\n    create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)\n    create_balance(account: account, date: Date.current, balance: 1200)\n\n    builder = Balance::ChartSeriesBuilder.new(\n      account_ids: [ account.id ],\n      currency: \"EUR\", # Will need to convert existing balances to EUR\n      period: Period.custom(start_date: 2.days.ago.to_date, end_date: Date.current),\n      interval: \"1 day\"\n    )\n\n    # Only 1 rate in DB. We'll be missing the first and last days in the series.\n    # This rate should be applied to 1 day ago and today, but not 2 days ago (will fall back to 1)\n    ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: \"USD\", to_currency: \"EUR\", rate: 2)\n\n    expected = [\n      1000, # No rate available, so fall back to 1:1 conversion (1000 USD = 1000 EUR)\n      2200, # Rate available, so use 2:1 conversion (1100 USD = 2200 EUR)\n      2400 # Rate NOT available, but LOCF will use the last available rate, so use 2:1 conversion (1200 USD = 2400 EUR)\n    ]\n\n    assert_equal expected, builder.balance_series.map { |v| v.value.amount }\n  end\n\n  test \"combines asset and liability accounts properly\" do\n    asset_account = accounts(:depository)\n    liability_account = accounts(:credit_card)\n\n    Balance.destroy_all\n\n    create_balance(account: asset_account, date: 3.days.ago.to_date, balance: 500)\n    create_balance(account: asset_account, date: 1.day.ago.to_date, balance: 1000)\n    create_balance(account: asset_account, date: Date.current, balance: 1000)\n\n    create_balance(account: liability_account, date: 3.days.ago.to_date, balance: 200)\n    create_balance(account: liability_account, date: 2.days.ago.to_date, balance: 200)\n    create_balance(account: liability_account, date: Date.current, balance: 100)\n\n    builder = Balance::ChartSeriesBuilder.new(\n      account_ids: [ asset_account.id, liability_account.id ],\n      currency: \"USD\",\n      period: Period.custom(start_date: 4.days.ago.to_date, end_date: Date.current),\n      interval: \"1 day\"\n    )\n\n    expected = [\n      0, # No asset or liability balances - 4 days ago\n      300, # 500 - 200 = 300 - 3 days ago\n      300, # 500 - 200 = 300 (500 is locf) - 2 days ago\n      800, # 1000 - 200 = 800 (200 is locf) - 1 day ago\n      900 # 1000 - 100 = 900 - today\n    ]\n\n    assert_equal expected, builder.balance_series.map { |v| v.value.amount }\n  end\n\n  test \"when favorable direction is down balance signage inverts\" do\n    account = accounts(:credit_card)\n    account.balances.destroy_all\n\n    create_balance(account: account, date: 1.day.ago.to_date, balance: 1000)\n    create_balance(account: account, date: Date.current, balance: 500)\n\n    builder = Balance::ChartSeriesBuilder.new(\n      account_ids: [ account.id ],\n      currency: \"USD\",\n      period: Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current),\n      favorable_direction: \"up\"\n    )\n\n    # Since favorable direction is up and balances are liabilities, the values should be negative\n    expected = [ -1000, -500 ]\n\n    assert_equal expected, builder.balance_series.map { |v| v.value.amount }\n\n    builder = Balance::ChartSeriesBuilder.new(\n      account_ids: [ account.id ],\n      currency: \"USD\",\n      period: Period.custom(start_date: 1.day.ago.to_date, end_date: Date.current),\n      favorable_direction: \"down\"\n    )\n\n    # Since favorable direction is down and balances are liabilities, the values should be positive\n    expected = [ 1000, 500 ]\n\n    assert_equal expected, builder.balance_series.map { |v| v.value.amount }\n  end\nend\n"
  },
  {
    "path": "test/models/balance/forward_calculator_test.rb",
    "content": "require \"test_helper\"\n\n# The \"forward calculator\" is used for all **manual** accounts where balance tracking is done through entries and NOT from an external data provider.\nclass Balance::ForwardCalculatorTest < ActiveSupport::TestCase\n  include LedgerTestingHelper\n\n  # ------------------------------------------------------------------------------------------------\n  # General tests for all account types\n  # ------------------------------------------------------------------------------------------------\n\n  # When syncing forwards, we don't care about the account balance.  We generate everything based on entries, starting from 0.\n  test \"no entries sync\" do\n    account = create_account_with_ledger(\n      account: { type: Depository, currency: \"USD\" },\n      entries: []\n    )\n\n    assert_equal 0, account.balances.count\n\n    calculated = Balance::ForwardCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: Date.current,\n          legacy_balances: { balance: 0, cash_balance: 0 },\n          balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 },\n          flows: 0,\n          adjustments: 0\n        }\n      ]\n    )\n  end\n\n  # Our system ensures all manual accounts have an opening anchor (for UX), but we should be able to handle a missing anchor by starting at 0 (i.e. \"fresh account with no history\")\n  test \"account without opening anchor starts at zero balance\" do\n    account = create_account_with_ledger(\n      account: { type: Depository, currency: \"USD\" },\n      entries: [\n        { type: \"transaction\", date: 2.days.ago.to_date, amount: -1000 }\n      ]\n    )\n\n    calculated = Balance::ForwardCalculator.new(account).calculate\n\n    # Since we start at 0, this transaction (inflow) simply increases balance from 0 -> 1000\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: 3.days.ago.to_date,\n          legacy_balances: { balance: 0, cash_balance: 0 },\n          balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 2.days.ago.to_date,\n          legacy_balances: { balance: 1000, cash_balance: 1000 },\n          balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },\n          flows: { cash_inflows: 1000, cash_outflows: 0 },\n          adjustments: 0\n        }\n      ]\n    )\n  end\n\n  test \"reconciliation valuation sets absolute balance before applying subsequent transactions\" do\n    account = create_account_with_ledger(\n      account: { type: Depository, currency: \"USD\" },\n      entries: [\n        { type: \"reconciliation\", date: 3.days.ago.to_date, balance: 18000 },\n        { type: \"transaction\", date: 2.days.ago.to_date, amount: -1000 }\n      ]\n    )\n\n    calculated = Balance::ForwardCalculator.new(account).calculate\n\n    # First valuation sets balance to 18000, then transaction increases balance to 19000\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: 3.days.ago.to_date,\n          legacy_balances: { balance: 18000, cash_balance: 18000 },\n          balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 },\n          flows: 0,\n          adjustments: { cash_adjustments: 18000, non_cash_adjustments: 0 }\n        },\n        {\n          date: 2.days.ago.to_date,\n          legacy_balances: { balance: 19000, cash_balance: 19000 },\n          balances: { start: 18000, start_cash: 18000, start_non_cash: 0, end_cash: 19000, end_non_cash: 0, end: 19000 },\n          flows: { cash_inflows: 1000, cash_outflows: 0 },\n          adjustments: 0\n        }\n      ]\n    )\n  end\n\n  test \"cash-only accounts (depository, credit card) use valuations where cash balance equals total balance\" do\n    [ Depository, CreditCard ].each do |account_type|\n      account = create_account_with_ledger(\n        account: { type: account_type, currency: \"USD\" },\n        entries: [\n          { type: \"opening_anchor\", date: 3.days.ago.to_date, balance: 17000 },\n          { type: \"reconciliation\", date: 2.days.ago.to_date, balance: 18000 }\n        ]\n      )\n\n      calculated = Balance::ForwardCalculator.new(account).calculate\n\n      assert_calculated_ledger_balances(\n        calculated_data: calculated,\n        expected_data: [\n          {\n            date: 3.days.ago.to_date,\n            legacy_balances: { balance: 17000, cash_balance: 17000 },\n            balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },\n            flows: 0,\n            adjustments: 0\n          },\n          {\n            date: 2.days.ago.to_date,\n            legacy_balances: { balance: 18000, cash_balance: 18000 },\n            balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 },\n            flows: 0,\n            adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 }\n          }\n        ]\n      )\n    end\n  end\n\n  test \"non-cash accounts (property, loan) use valuations where cash balance is always zero\" do\n    [ Property, Loan ].each do |account_type|\n      account = create_account_with_ledger(\n        account: { type: account_type, currency: \"USD\" },\n        entries: [\n          { type: \"opening_anchor\", date: 3.days.ago.to_date, balance: 17000 },\n          { type: \"reconciliation\", date: 2.days.ago.to_date, balance: 18000 }\n        ]\n      )\n\n      calculated = Balance::ForwardCalculator.new(account).calculate\n\n      assert_calculated_ledger_balances(\n        calculated_data: calculated,\n        expected_data: [\n          {\n            date: 3.days.ago.to_date,\n            legacy_balances: { balance: 17000, cash_balance: 0.0 },\n            balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 17000, end: 17000 },\n            flows: 0,\n            adjustments: 0\n          },\n          {\n            date: 2.days.ago.to_date,\n            legacy_balances: { balance: 18000, cash_balance: 0.0 },\n            balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 18000, end: 18000 },\n            flows: 0,\n            adjustments: { cash_adjustments: 0, non_cash_adjustments: 1000 }\n          }\n        ]\n      )\n    end\n  end\n\n  test \"mixed accounts (investment) use valuations where cash balance is total minus holdings\" do\n    account = create_account_with_ledger(\n      account: { type: Investment, currency: \"USD\" },\n      entries: [\n        { type: \"opening_anchor\", date: 3.days.ago.to_date, balance: 17000 },\n        { type: \"reconciliation\", date: 2.days.ago.to_date, balance: 18000 }\n      ]\n    )\n\n    # Without holdings, cash balance equals total balance\n    calculated = Balance::ForwardCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: 3.days.ago.to_date,\n          legacy_balances: { balance: 17000, cash_balance: 17000 },\n          balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },\n          flows: { market_flows: 0 },\n          adjustments: 0\n        },\n        {\n          date: 2.days.ago.to_date,\n          legacy_balances: { balance: 18000, cash_balance: 18000 },\n          balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 },\n          flows: { market_flows: 0 },\n          adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash\n        }\n      ]\n    )\n  end\n\n  # ------------------------------------------------------------------------------------------------\n  # All Cash accounts (Depository, CreditCard)\n  # ------------------------------------------------------------------------------------------------\n\n  test \"transactions on depository accounts affect cash balance\" do\n    account = create_account_with_ledger(\n      account: { type: Depository, currency: \"USD\" },\n      entries: [\n        { type: \"opening_anchor\", date: 5.days.ago.to_date, balance: 20000 },\n        { type: \"transaction\", date: 4.days.ago.to_date, amount: -500 }, # income\n        { type: \"transaction\", date: 2.days.ago.to_date, amount: 100 } # expense\n      ]\n    )\n\n    calculated = Balance::ForwardCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: 5.days.ago.to_date,\n          legacy_balances: { balance: 20000, cash_balance: 20000 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 4.days.ago.to_date,\n          legacy_balances: { balance: 20500, cash_balance: 20500 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 },\n          flows: { cash_inflows: 500, cash_outflows: 0 },\n          adjustments: 0\n        },\n        {\n          date: 3.days.ago.to_date,\n          legacy_balances: { balance: 20500, cash_balance: 20500 },\n          balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 2.days.ago.to_date,\n          legacy_balances: { balance: 20400, cash_balance: 20400 },\n          balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20400, end_non_cash: 0, end: 20400 },\n          flows: { cash_inflows: 0, cash_outflows: 100 },\n          adjustments: 0\n        }\n      ]\n    )\n  end\n\n\n  test \"transactions on credit card accounts affect cash balance inversely\" do\n    account = create_account_with_ledger(\n      account: { type: CreditCard, currency: \"USD\" },\n      entries: [\n        { type: \"opening_anchor\", date: 5.days.ago.to_date, balance: 1000 },\n        { type: \"transaction\", date: 4.days.ago.to_date, amount: -500 }, # CC payment\n        { type: \"transaction\", date: 2.days.ago.to_date, amount: 100 } # expense\n      ]\n    )\n\n    calculated = Balance::ForwardCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: 5.days.ago.to_date,\n          legacy_balances: { balance: 1000, cash_balance: 1000 },\n          balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 4.days.ago.to_date,\n          legacy_balances: { balance: 500, cash_balance: 500 },\n          balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },\n          flows: { cash_inflows: 500, cash_outflows: 0 },\n          adjustments: 0\n        },\n        {\n          date: 3.days.ago.to_date,\n          legacy_balances: { balance: 500, cash_balance: 500 },\n          balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 2.days.ago.to_date,\n          legacy_balances: { balance: 600, cash_balance: 600 },\n          balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 600, end_non_cash: 0, end: 600 },\n          flows: { cash_inflows: 0, cash_outflows: 100 },\n          adjustments: 0\n        }\n      ]\n    )\n  end\n\n  test \"depository account with transactions and balance reconciliations\" do\n    account = create_account_with_ledger(\n      account: { type: Depository, currency: \"USD\" },\n      entries: [\n        { type: \"opening_anchor\", date: 4.days.ago.to_date, balance: 20000 },\n        { type: \"transaction\", date: 3.days.ago.to_date, amount: -5000 },\n        { type: \"reconciliation\", date: 2.days.ago.to_date, balance: 17000 },\n        { type: \"transaction\", date: 1.day.ago.to_date, amount: -500 }\n      ]\n    )\n\n    calculated = Balance::ForwardCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: 4.days.ago.to_date,\n          legacy_balances: { balance: 20000, cash_balance: 20000 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 3.days.ago.to_date,\n          legacy_balances: { balance: 25000, cash_balance: 25000 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 25000, end_non_cash: 0, end: 25000 },\n          flows: { cash_inflows: 5000, cash_outflows: 0 },\n          adjustments: 0\n        },\n        {\n          date: 2.days.ago.to_date,\n          legacy_balances: { balance: 17000, cash_balance: 17000 },\n          balances: { start: 25000, start_cash: 25000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },\n          flows: 0,\n          adjustments: { cash_adjustments: -8000, non_cash_adjustments: 0 }\n        },\n        {\n          date: 1.day.ago.to_date,\n          legacy_balances: { balance: 17500, cash_balance: 17500 },\n          balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17500, end_non_cash: 0, end: 17500 },\n          flows: { cash_inflows: 500, cash_outflows: 0 },\n          adjustments: 0\n        }\n      ]\n    )\n  end\n\n  test \"accounts with transactions in multiple currencies convert to the account currency and flows are stored in account currency\" do\n    account = create_account_with_ledger(\n      account: { type: Depository, currency: \"USD\" },\n      entries: [\n        { type: \"opening_anchor\", date: 4.days.ago.to_date, balance: 100 },\n        { type: \"transaction\", date: 3.days.ago.to_date, amount: -100 },\n        { type: \"transaction\", date: 2.days.ago.to_date, amount: -300 },\n        # Transaction in different currency than the account's main currency\n        { type: \"transaction\", date: 1.day.ago.to_date, amount: -500, currency: \"EUR\" } # €500 * 1.2 = $600\n      ],\n      exchange_rates: [\n        { date: 1.day.ago.to_date, from: \"EUR\", to: \"USD\", rate: 1.2 }\n      ]\n    )\n\n    calculated = Balance::ForwardCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: 4.days.ago.to_date,\n          legacy_balances: { balance: 100, cash_balance: 100 },\n          balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 3.days.ago.to_date,\n          legacy_balances: { balance: 200, cash_balance: 200 },\n          balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 200, end_non_cash: 0, end: 200 },\n          flows: { cash_inflows: 100, cash_outflows: 0 },\n          adjustments: 0\n        },\n        {\n          date: 2.days.ago.to_date,\n          legacy_balances: { balance: 500, cash_balance: 500 },\n          balances: { start: 200, start_cash: 200, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },\n          flows: { cash_inflows: 300, cash_outflows: 0 },\n          adjustments: 0\n        },\n        {\n          date: 1.day.ago.to_date,\n          legacy_balances: { balance: 1100, cash_balance: 1100 },\n          balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 1100, end_non_cash: 0, end: 1100 },\n          flows: { cash_inflows: 600, cash_outflows: 0 }, # Cash inflow is the USD equivalent of €500 (converted for balances table)\n          adjustments: 0\n        }\n      ]\n    )\n  end\n\n  # A loan is a special case where despite being a \"non-cash\" account, it is typical to have \"payment\" transactions that reduce the loan principal (non cash balance)\n  test \"loan payment transactions affect non cash balance\" do\n    account = create_account_with_ledger(\n      account: { type: Loan, currency: \"USD\" },\n      entries: [\n        { type: \"opening_anchor\", date: 2.days.ago.to_date, balance: 20000 },\n        # \"Loan payment\" of $2000, which reduces the principal\n        # TODO: We'll eventually need to calculate which portion of the txn was \"interest\" vs. \"principal\", but for now we'll just assume it's all principal\n        # since we don't have a first-class way to track interest payments yet.\n        { type: \"transaction\", date: 1.day.ago.to_date, amount: -2000 }\n      ]\n    )\n\n    calculated = Balance::ForwardCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: 2.days.ago.to_date,\n          legacy_balances: { balance: 20000, cash_balance: 0 },\n          balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 20000, end: 20000 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 1.day.ago.to_date,\n          legacy_balances: { balance: 18000, cash_balance: 0 },\n          balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 18000, end: 18000 },\n          flows: { non_cash_inflows: 2000, non_cash_outflows: 0, cash_inflows: 0, cash_outflows: 0 }, # Loans are \"special cases\" where transactions do affect non-cash balance\n          adjustments: 0\n        }\n      ]\n    )\n  end\n\n  test \"non cash accounts can only use valuations and transactions will be recorded but ignored for balance calculation\" do\n    [ Property, Vehicle, OtherAsset, OtherLiability ].each do |account_type|\n      account = create_account_with_ledger(\n        account: { type: account_type, currency: \"USD\" },\n        entries: [\n          { type: \"opening_anchor\", date: 3.days.ago.to_date, balance: 500000 },\n\n          # Will be ignored for balance calculation due to account type of non-cash\n          { type: \"transaction\", date: 2.days.ago.to_date, amount: -50000 }\n        ]\n      )\n\n      calculated = Balance::ForwardCalculator.new(account).calculate\n\n      assert_calculated_ledger_balances(\n        calculated_data: calculated,\n        expected_data: [\n          {\n            date: 3.days.ago.to_date,\n            legacy_balances: { balance: 500000, cash_balance: 0 },\n            balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 },\n            flows: 0,\n            adjustments: 0\n          },\n          {\n            date: 2.days.ago.to_date,\n            legacy_balances: { balance: 500000, cash_balance: 0 },\n            balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 },\n            flows: 0, # Despite having a transaction, non-cash accounts ignore it for balance calculation\n            adjustments: 0\n          }\n        ]\n      )\n    end\n  end\n\n  # ------------------------------------------------------------------------------------------------\n  # Hybrid accounts (Investment, Crypto) - these have both cash and non-cash balance components\n  # ------------------------------------------------------------------------------------------------\n\n  # A transaction increases/decreases cash balance (i.e. \"deposits\" and \"withdrawals\")\n  # A trade increases/decreases cash balance (i.e. \"buys\" and \"sells\", which consume/add \"brokerage cash\" and create/destroy \"holdings\")\n  # A valuation can set both cash and non-cash balances to \"override\" investment account value.\n  # Holdings are calculated separately and fed into the balance calculator; treated as \"non-cash\"\n  test \"investment account calculates balance from transactions and trades and treats holdings as non-cash, additive to balance\" do\n    account = create_account_with_ledger(\n      account: { type: Investment, currency: \"USD\" },\n      entries: [\n        # Account starts with brokerage cash of $5000 and no holdings\n        { type: \"opening_anchor\", date: 3.days.ago.to_date, balance: 5000 },\n        # Share purchase reduces cash balance by $1000, but keeps overall balance same\n        { type: \"trade\", date: 1.day.ago.to_date, ticker: \"AAPL\", qty: 10, price: 100 }\n      ],\n      holdings: [\n        # Holdings calculator will calculate $1000 worth of holdings\n        { date: 1.day.ago.to_date, ticker: \"AAPL\", qty: 10, price: 100, amount: 1000 },\n        { date: Date.current, ticker: \"AAPL\", qty: 10, price: 110, amount: 1100 } # Price increased by 10%, so holdings value goes up by $100 without a trade\n      ]\n    )\n\n    # Given constant prices, overall balance (account value) should be constant\n    # (the single trade doesn't affect balance; it just alters cash vs. holdings composition)\n    calculated = Balance::ForwardCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: 3.days.ago.to_date,\n          legacy_balances: { balance: 5000, cash_balance: 5000 },\n          balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 2.days.ago.to_date,\n          legacy_balances: { balance: 5000, cash_balance: 5000 },\n          balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 1.day.ago.to_date,\n          legacy_balances: { balance: 5000, cash_balance: 4000 },\n          balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 4000, end_non_cash: 1000, end: 5000 },\n          flows: { cash_inflows: 0, cash_outflows: 1000, non_cash_inflows: 1000, non_cash_outflows: 0, net_market_flows: 0 }, # Decrease cash by 1000, increase holdings by 1000 (i.e. \"buy\" of $1000 worth of AAPL)\n          adjustments: 0\n        },\n        {\n          date: Date.current,\n          legacy_balances: { balance: 5100, cash_balance: 4000 },\n          balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1100, end: 5100 },\n          flows: { net_market_flows: 100 }, # Holdings value increased by 100, despite no change in portfolio quantities\n          adjustments: 0\n        }\n      ]\n    )\n  end\n\n  test \"investment account can have valuations that override balance\" do\n    account = create_account_with_ledger(\n      account: { type: Investment, currency: \"USD\" },\n      entries: [\n        { type: \"opening_anchor\", date: 2.days.ago.to_date, balance: 5000 },\n        { type: \"reconciliation\", date: 1.day.ago.to_date, balance: 10000 }\n      ],\n      holdings: [\n        { date: 3.days.ago.to_date, ticker: \"AAPL\", qty: 10, price: 100, amount: 1000 },\n        { date: 2.days.ago.to_date, ticker: \"AAPL\", qty: 10, price: 100, amount: 1000 },\n        { date: 1.day.ago.to_date, ticker: \"AAPL\", qty: 10, price: 110, amount: 1100 },\n        { date: Date.current, ticker: \"AAPL\", qty: 10, price: 120, amount: 1200 }\n      ]\n    )\n\n    # Given constant prices, overall balance (account value) should be constant\n    # (the single trade doesn't affect balance; it just alters cash vs. holdings composition)\n    calculated = Balance::ForwardCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: 2.days.ago.to_date,\n          legacy_balances: { balance: 5000, cash_balance: 4000 },\n          balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1000, end: 5000 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 1.day.ago.to_date,\n          legacy_balances: { balance: 10000, cash_balance: 8900 },\n          balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 8900, end_non_cash: 1100, end: 10000 },\n          flows: { net_market_flows: 100 },\n          adjustments: { cash_adjustments: 4900, non_cash_adjustments: 0 }\n        },\n        {\n          date: Date.current,\n          legacy_balances: { balance: 10100, cash_balance: 8900 },\n          balances: { start: 10000, start_cash: 8900, start_non_cash: 1100, end_cash: 8900, end_non_cash: 1200, end: 10100 },\n          flows: { net_market_flows: 100 },\n          adjustments: 0\n        }\n      ]\n    )\n  end\n\n  private\n    def assert_balances(calculated_data:, expected_balances:)\n      # Sort calculated data by date to ensure consistent ordering\n      sorted_data = calculated_data.sort_by(&:date)\n\n      # Extract actual values as [date, { balance:, cash_balance: }]\n      actual_balances = sorted_data.map do |b|\n        [ b.date, { balance: b.balance, cash_balance: b.cash_balance } ]\n      end\n\n      assert_equal expected_balances, actual_balances\n    end\nend\n"
  },
  {
    "path": "test/models/balance/materializer_test.rb",
    "content": "require \"test_helper\"\n\nclass Balance::MaterializerTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n  include BalanceTestHelper\n\n  setup do\n    @account = families(:empty).accounts.create!(\n      name: \"Test\",\n      balance: 20000,\n      cash_balance: 20000,\n      currency: \"USD\",\n      accountable: Investment.new\n    )\n  end\n\n  test \"syncs balances\" do\n    Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once\n\n    expected_balances = [\n      Balance.new(\n        date: 1.day.ago.to_date,\n        balance: 1000,\n        cash_balance: 1000,\n        currency: \"USD\",\n        start_cash_balance: 500,\n        start_non_cash_balance: 0,\n        cash_inflows: 500,\n        cash_outflows: 0,\n        non_cash_inflows: 0,\n        non_cash_outflows: 0,\n        net_market_flows: 0,\n        cash_adjustments: 0,\n        non_cash_adjustments: 0,\n        flows_factor: 1\n      ),\n      Balance.new(\n        date: Date.current,\n        balance: 1000,\n        cash_balance: 1000,\n        currency: \"USD\",\n        start_cash_balance: 1000,\n        start_non_cash_balance: 0,\n        cash_inflows: 0,\n        cash_outflows: 0,\n        non_cash_inflows: 0,\n        non_cash_outflows: 0,\n        net_market_flows: 0,\n        cash_adjustments: 0,\n        non_cash_adjustments: 0,\n        flows_factor: 1\n      )\n    ]\n\n    Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances)\n\n    assert_difference \"@account.balances.count\", 2 do\n      Balance::Materializer.new(@account, strategy: :forward).materialize_balances\n    end\n\n    assert_balance_fields_persisted(expected_balances)\n  end\n\n  test \"purges stale balances outside calculated range\" do\n    # Create existing balances that will be stale\n    stale_old = create_balance(account: @account, date: 5.days.ago.to_date, balance: 5000)\n    stale_future = create_balance(account: @account, date: 2.days.from_now.to_date, balance: 15000)\n\n    # Calculator will return balances for only these dates\n    expected_balances = [\n      Balance.new(\n        date: 2.days.ago.to_date,\n        balance: 10000,\n        cash_balance: 10000,\n        currency: \"USD\",\n        start_cash_balance: 10000,\n        start_non_cash_balance: 0,\n        cash_inflows: 0,\n        cash_outflows: 0,\n        non_cash_inflows: 0,\n        non_cash_outflows: 0,\n        net_market_flows: 0,\n        cash_adjustments: 0,\n        non_cash_adjustments: 0,\n        flows_factor: 1\n      ),\n      Balance.new(\n        date: 1.day.ago.to_date,\n        balance: 1000,\n        cash_balance: 1000,\n        currency: \"USD\",\n        start_cash_balance: 10000,\n        start_non_cash_balance: 0,\n        cash_inflows: 0,\n        cash_outflows: 9000,\n        non_cash_inflows: 0,\n        non_cash_outflows: 0,\n        net_market_flows: 0,\n        cash_adjustments: 0,\n        non_cash_adjustments: 0,\n        flows_factor: 1\n      ),\n      Balance.new(\n        date: Date.current,\n        balance: 1000,\n        cash_balance: 1000,\n        currency: \"USD\",\n        start_cash_balance: 1000,\n        start_non_cash_balance: 0,\n        cash_inflows: 0,\n        cash_outflows: 0,\n        non_cash_inflows: 0,\n        non_cash_outflows: 0,\n        net_market_flows: 0,\n        cash_adjustments: 0,\n        non_cash_adjustments: 0,\n        flows_factor: 1\n      )\n    ]\n\n    Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances)\n    Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once\n\n    # Should end up with 3 balances (stale ones deleted, new ones created)\n    assert_difference \"@account.balances.count\", 1 do\n      Balance::Materializer.new(@account, strategy: :forward).materialize_balances\n    end\n\n    # Verify stale balances were deleted\n    assert_nil @account.balances.find_by(id: stale_old.id)\n    assert_nil @account.balances.find_by(id: stale_future.id)\n\n    # Verify expected balances were persisted\n    assert_balance_fields_persisted(expected_balances)\n  end\n\n  private\n\n    def assert_balance_fields_persisted(expected_balances)\n      expected_balances.each do |expected|\n        persisted = @account.balances.find_by(date: expected.date)\n        assert_not_nil persisted, \"Balance for #{expected.date} should be persisted\"\n\n        # Check all balance component fields\n        assert_equal expected.balance, persisted.balance\n        assert_equal expected.cash_balance, persisted.cash_balance\n        assert_equal expected.start_cash_balance, persisted.start_cash_balance\n        assert_equal expected.start_non_cash_balance, persisted.start_non_cash_balance\n        assert_equal expected.cash_inflows, persisted.cash_inflows\n        assert_equal expected.cash_outflows, persisted.cash_outflows\n        assert_equal expected.non_cash_inflows, persisted.non_cash_inflows\n        assert_equal expected.non_cash_outflows, persisted.non_cash_outflows\n        assert_equal expected.net_market_flows, persisted.net_market_flows\n        assert_equal expected.cash_adjustments, persisted.cash_adjustments\n        assert_equal expected.non_cash_adjustments, persisted.non_cash_adjustments\n        assert_equal expected.flows_factor, persisted.flows_factor\n      end\n    end\nend\n"
  },
  {
    "path": "test/models/balance/reverse_calculator_test.rb",
    "content": "require \"test_helper\"\n\nclass Balance::ReverseCalculatorTest < ActiveSupport::TestCase\n  include LedgerTestingHelper\n\n  # When syncing backwards, we start with the account balance and generate everything from there.\n  test \"when missing anchor and no entries, falls back to cached account balance\" do\n    account = create_account_with_ledger(\n      account: { type: Depository, balance: 20000, cash_balance: 20000, currency: \"USD\" },\n      entries: []\n    )\n\n    assert_equal 20000, account.balance\n\n    calculated = Balance::ReverseCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: Date.current,\n          legacy_balances: { balance: 20000, cash_balance: 20000 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },\n          flows: 0,\n          adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }\n        }\n      ]\n    )\n  end\n\n  # An artificial constraint we put on the reverse sync because it's confusing in both the code and the UI\n  # to think about how an absolute \"Valuation\" affects balances when syncing backwards. Furthermore, since\n  # this is typically a Plaid sync, we expect Plaid to provide us the history.\n  # Note: while \"reconciliation\" valuations don't affect balance, `current_anchor` and `opening_anchor` do.\n  test \"reconciliation valuations do not affect balance for reverse syncs\" do\n    account = create_account_with_ledger(\n      account: { type: Depository, balance: 20000, cash_balance: 20000, currency: \"USD\" },\n      entries: [\n        { type: \"current_anchor\", date: Date.current, balance: 20000 },\n        { type: \"reconciliation\", date: 1.day.ago, balance: 17000 }, # Ignored\n        { type: \"reconciliation\", date: 2.days.ago, balance: 17000 }, # Ignored\n        { type: \"opening_anchor\", date: 4.days.ago, balance: 15000 }\n      ]\n    )\n\n    calculated = Balance::ReverseCalculator.new(account).calculate\n\n    # The \"opening anchor\" works slightly differently than most would expect. Since it's an artificial\n    # value provided by the user to set the date/balance of the start of the account, we must assume\n    # that there are \"missing\" entries following it. Because of this, we cannot \"carry forward\" this value\n    # like we do for a \"forward sync\". We simply sync backwards normally, then set the balance on opening\n    # date equal to this anchor. This is not \"ideal\", but is a constraint put on us since we cannot guarantee\n    # a 100% full entries history.\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: Date.current,\n          legacy_balances: { balance: 20000, cash_balance: 20000 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },\n          flows: 0,\n          adjustments: 0\n        }, # Current anchor\n        {\n          date: 1.day.ago,\n          legacy_balances: { balance: 20000, cash_balance: 20000 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 2.days.ago,\n          legacy_balances: { balance: 20000, cash_balance: 20000 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 3.days.ago,\n          legacy_balances: { balance: 20000, cash_balance: 20000 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },\n          flows: 0,\n          adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }\n        },\n        {\n          date: 4.days.ago,\n          legacy_balances: { balance: 15000, cash_balance: 15000 },\n          balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 15000, end_non_cash: 0, end: 15000 },\n          flows: 0,\n          adjustments: 0\n        } # Opening anchor\n      ]\n    )\n  end\n\n  # Investment account balances are made of two components: cash and holdings.\n  test \"anchors on investment accounts calculate cash balance dynamically based on holdings value\" do\n    account = create_account_with_ledger(\n      account: { type: Investment, balance: 20000, cash_balance: 10000, currency: \"USD\" },\n      entries: [\n        { type: \"current_anchor\", date: Date.current, balance: 20000 }, # \"Total account value is $20,000 today\"\n        { type: \"opening_anchor\", date: 1.day.ago, balance: 15000 } # \"Total account value was $15,000 at the start of the account\"\n      ],\n      holdings: [\n        { date: Date.current, ticker: \"AAPL\", qty: 100, price: 100, amount: 10000 },\n        { date: 1.day.ago, ticker: \"AAPL\", qty: 100, price: 100, amount: 10000 }\n      ]\n    )\n\n    calculated = Balance::ReverseCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: Date.current,\n          legacy_balances: { balance: 20000, cash_balance: 10000 },\n          balances: { start: 20000, start_cash: 10000, start_non_cash: 10000, end_cash: 10000, end_non_cash: 10000, end: 20000 },\n          flows: { market_flows: 0 },\n          adjustments: 0\n        }, # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value\n        {\n          date: 1.day.ago,\n          legacy_balances: { balance: 15000, cash_balance: 5000 },\n          balances: { start: 15000, start_cash: 5000, start_non_cash: 10000, end_cash: 5000, end_non_cash: 10000, end: 15000 },\n          flows: { market_flows: 0 },\n          adjustments: 0\n        } # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value\n      ]\n    )\n  end\n\n  test \"transactions on depository accounts affect cash balance\" do\n    account = create_account_with_ledger(\n      account: { type: Depository, balance: 20000, cash_balance: 20000, currency: \"USD\" },\n      entries: [\n        { type: \"current_anchor\", date: Date.current, balance: 20000 },\n        { type: \"transaction\", date: 2.days.ago, amount: 100 }, # expense\n        { type: \"transaction\", date: 4.days.ago, amount: -500 } # income\n      ]\n    )\n\n    calculated = Balance::ReverseCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: Date.current,\n          legacy_balances: { balance: 20000, cash_balance: 20000 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },\n          flows: 0,\n          adjustments: 0\n        }, # Current balance\n        {\n          date: 1.day.ago,\n          legacy_balances: { balance: 20000, cash_balance: 20000 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },\n          flows: 0,\n          adjustments: 0\n        }, # No change\n        {\n          date: 2.days.ago,\n          legacy_balances: { balance: 20000, cash_balance: 20000 },\n          balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },\n          flows: { cash_inflows: 0, cash_outflows: 100 },\n          adjustments: 0\n        }, # After expense (+100)\n        {\n          date: 3.days.ago,\n          legacy_balances: { balance: 20100, cash_balance: 20100 },\n          balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 },\n          flows: 0,\n          adjustments: 0\n        }, # Before expense\n        {\n          date: 4.days.ago,\n          legacy_balances: { balance: 20100, cash_balance: 20100 },\n          balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 },\n          flows: { cash_inflows: 500, cash_outflows: 0 },\n          adjustments: 0\n        }, # After income (-500)\n        {\n          date: 5.days.ago,\n          legacy_balances: { balance: 19600, cash_balance: 19600 },\n          balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 19600, end_non_cash: 0, end: 19600 },\n          flows: 0,\n          adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }\n        } # After income (-500)\n      ]\n    )\n  end\n\n  test \"transactions on credit card accounts affect cash balance inversely\" do\n    account = create_account_with_ledger(\n      account: { type: CreditCard, balance: 2000, cash_balance: 2000, currency: \"USD\" },\n      entries: [\n        { type: \"current_anchor\", date: Date.current, balance: 2000 },\n        { type: \"transaction\", date: 2.days.ago, amount: 100 }, # expense (increases cash balance)\n        { type: \"transaction\", date: 4.days.ago, amount: -500 } # CC payment (reduces cash balance)\n      ]\n    )\n\n    calculated = Balance::ReverseCalculator.new(account).calculate\n\n    # Reversed order: showing how we work backwards\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: Date.current,\n          legacy_balances: { balance: 2000, cash_balance: 2000 },\n          balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 },\n          flows: 0,\n          adjustments: 0\n        }, # Current balance\n        {\n          date: 1.day.ago,\n          legacy_balances: { balance: 2000, cash_balance: 2000 },\n          balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 },\n          flows: 0,\n          adjustments: 0\n        }, # No change\n        {\n          date: 2.days.ago,\n          legacy_balances: { balance: 2000, cash_balance: 2000 },\n          balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 },\n          flows: { cash_inflows: 0, cash_outflows: 100 },\n          adjustments: 0\n        }, # After expense (+100)\n        {\n          date: 3.days.ago,\n          legacy_balances: { balance: 1900, cash_balance: 1900 },\n          balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 },\n          flows: 0,\n          adjustments: 0\n        }, # Before expense\n        {\n          date: 4.days.ago,\n          legacy_balances: { balance: 1900, cash_balance: 1900 },\n          balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 },\n          flows: { cash_inflows: 500, cash_outflows: 0 },\n          adjustments: 0\n        }, # After CC payment (-500)\n        {\n          date: 5.days.ago,\n          legacy_balances: { balance: 2400, cash_balance: 2400 },\n          balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 2400, end_non_cash: 0, end: 2400 },\n          flows: 0,\n          adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }\n        }\n      ]\n    )\n  end\n\n  # A loan is a special case where despite being a \"non-cash\" account, it is typical to have \"payment\" transactions that reduce the loan principal (non cash balance)\n  test \"loan payment transactions affect non cash balance\" do\n    account = create_account_with_ledger(\n      account: { type: Loan, balance: 198000, cash_balance: 0, currency: \"USD\" },\n      entries: [\n        { type: \"current_anchor\", date: Date.current, balance: 198000 },\n        # \"Loan payment\" of $2000, which reduces the principal\n        # TODO: We'll eventually need to calculate which portion of the txn was \"interest\" vs. \"principal\", but for now we'll just assume it's all principal\n        # since we don't have a first-class way to track interest payments yet.\n        { type: \"transaction\", date: 1.day.ago.to_date, amount: -2000 }\n      ]\n    )\n\n    calculated = Balance::ReverseCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: Date.current,\n          legacy_balances: { balance: 198000, cash_balance: 0 },\n          balances: { start: 198000, start_cash: 0, start_non_cash: 198000, end_cash: 0, end_non_cash: 198000, end: 198000 },\n          flows: 0,\n          adjustments: 0\n        },\n        {\n          date: 1.day.ago,\n          legacy_balances: { balance: 198000, cash_balance: 0 },\n          balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 198000, end: 198000 },\n          flows: { non_cash_inflows: 2000, non_cash_outflows: 0, cash_inflows: 0, cash_outflows: 0 },\n          adjustments: 0\n        },\n        {\n          date: 2.days.ago,\n          legacy_balances: { balance: 200000, cash_balance: 0 },\n          balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 200000, end: 200000 },\n          flows: 0,\n          adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }\n        }\n      ]\n    )\n  end\n\n  test \"non cash accounts can only use valuations and transactions will be recorded but ignored for balance calculation\" do\n    [ Property, Vehicle, OtherAsset, OtherLiability ].each do |account_type|\n      account = create_account_with_ledger(\n        account: { type: account_type, balance: 1000, cash_balance: 0, currency: \"USD\" },\n        entries: [\n          { type: \"current_anchor\", date: Date.current, balance: 1000 },\n\n          # Will be ignored for balance calculation due to account type of non-cash\n          { type: \"transaction\", date: 1.day.ago, amount: -100 }\n        ]\n      )\n\n      calculated = Balance::ReverseCalculator.new(account).calculate\n\n      assert_calculated_ledger_balances(\n        calculated_data: calculated,\n        expected_data: [\n          {\n            date: Date.current,\n            legacy_balances: { balance: 1000, cash_balance: 0 },\n            balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 },\n            flows: 0,\n            adjustments: 0\n          },\n          {\n            date: 1.day.ago,\n            legacy_balances: { balance: 1000, cash_balance: 0 },\n            balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 },\n            flows: 0,\n            adjustments: 0\n          },\n          {\n            date: 2.days.ago,\n            legacy_balances: { balance: 1000, cash_balance: 0 },\n            balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 },\n            flows: 0,\n            adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }\n          }\n        ]\n      )\n    end\n  end\n\n  # When syncing backwards, trades from the past should NOT affect the current balance or previous balances.\n  # They should only affect the *cash* component of the historical balances\n  test \"holdings and trades sync\" do\n    # Account starts with $20,000 total value, $19,000 cash, $1,000 in holdings\n    account = create_account_with_ledger(\n      account: { type: Investment, balance: 20000, cash_balance: 19000, currency: \"USD\" },\n      entries: [\n        { type: \"current_anchor\", date: Date.current, balance: 20000 },\n        # Bought 10 AAPL shares 1 day ago, so cash is $19,000, $1,000 in holdings, total value is $20,000\n        { type: \"trade\", date: 1.day.ago.to_date, ticker: \"AAPL\", qty: 10, price: 100 }\n      ],\n      holdings: [\n        { date: Date.current, ticker: \"AAPL\", qty: 10, price: 100, amount: 1000 },\n        { date: 1.day.ago.to_date, ticker: \"AAPL\", qty: 10, price: 100, amount: 1000 }\n      ]\n    )\n\n    calculated = Balance::ReverseCalculator.new(account).calculate\n\n    # Given constant prices, overall balance (account value) should be constant\n    # (the single trade doesn't affect balance; it just alters cash vs. holdings composition)\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: Date.current,\n          legacy_balances: { balance: 20000, cash_balance: 19000 },\n          balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },\n          flows: { market_flows: 0 },\n          adjustments: 0\n        }, # Current: $19k cash + $1k holdings (anchor)\n        {\n          date: 1.day.ago.to_date,\n          legacy_balances: { balance: 20000, cash_balance: 19000 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 19000, end_non_cash: 1000, end: 20000 },\n          flows: { cash_inflows: 0, cash_outflows: 1000, non_cash_inflows: 1000, non_cash_outflows: 0, net_market_flows: 0 },\n          adjustments: 0\n        }, # After trade: $19k cash + $1k holdings\n        {\n          date: 2.days.ago.to_date,\n          legacy_balances: { balance: 20000, cash_balance: 20000 },\n          balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },\n          flows: { market_flows: 0 },\n          adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }\n        } # At first, account is 100% cash, no holdings (no trades)\n      ]\n    )\n  end\n\n  # A common scenario with Plaid is they'll give us holding records for today, but no trade history for some of them.\n  # This is because they only supply 2 years worth of historical data.  Our system must properly handle this.\n  test \"properly calculates balances when a holding has no trade history\" do\n    # Account starts with $20,000 total value, $19,000 cash, $1,000 in holdings ($500 AAPL, $500 MSFT)\n    account = create_account_with_ledger(\n      account: { type: Investment, balance: 20000, cash_balance: 19000, currency: \"USD\" },\n      entries: [\n        { type: \"current_anchor\", date: Date.current, balance: 20000 },\n        # A holding *with* trade history (5 shares of AAPL, purchased 1 day ago)\n        { type: \"trade\", date: 1.day.ago.to_date, ticker: \"AAPL\", qty: 5, price: 100 }\n      ],\n      holdings: [\n        # AAPL holdings\n        { date: Date.current, ticker: \"AAPL\", qty: 5, price: 100, amount: 500 },\n        { date: 1.day.ago.to_date, ticker: \"AAPL\", qty: 5, price: 100, amount: 500 },\n        # MSFT holdings without trade history - Balance calculator doesn't care how the holdings were created. It just reads them and assumes they are accurate.\n        { date: Date.current, ticker: \"MSFT\", qty: 5, price: 100, amount: 500 },\n        { date: 1.day.ago.to_date, ticker: \"MSFT\", qty: 5, price: 100, amount: 500 },\n        { date: 2.days.ago.to_date, ticker: \"MSFT\", qty: 5, price: 100, amount: 500 }\n      ]\n    )\n\n    calculated = Balance::ReverseCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        {\n          date: Date.current,\n          legacy_balances: { balance: 20000, cash_balance: 19000 },\n          balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },\n          flows: { market_flows: 0 },\n          adjustments: 0\n        }, # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL)\n        {\n          date: 1.day.ago.to_date,\n          legacy_balances: { balance: 20000, cash_balance: 19000 },\n          balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19000, end_non_cash: 1000, end: 20000 },\n          flows: { cash_inflows: 0, cash_outflows: 500, non_cash_inflows: 500, non_cash_outflows: 0, market_flows: 0 },\n          adjustments: 0\n        }, # After AAPL trade: $19k cash + $1k holdings\n        {\n          date: 2.days.ago.to_date,\n          legacy_balances: { balance: 20000, cash_balance: 19500 },\n          balances: { start: 19500, start_cash: 19500, start_non_cash: 0, end_cash: 19500, end_non_cash: 500, end: 20000 },\n          flows: { market_flows: -500 },\n          adjustments: 0\n        } # Before AAPL trade: $19.5k cash + $500 MSFT\n      ]\n    )\n  end\n\n  test \"uses provider reported holdings and cash value on current day\" do\n    # Implied holdings value of $1,000 from provider\n    account = create_account_with_ledger(\n      account: { type: Investment, balance: 20000, cash_balance: 19000, currency: \"USD\" },\n      entries: [\n        { type: \"current_anchor\", date: Date.current, balance: 20000 },\n        { type: \"opening_anchor\", date: 2.days.ago, balance: 15000 }\n      ],\n      holdings: [\n        # Create holdings that differ in value from provider ($2,000 vs. the $1,000 reported by provider)\n        { date: Date.current, ticker: \"AAPL\", qty: 10, price: 100, amount: 1000 },\n        { date: 1.day.ago, ticker: \"AAPL\", qty: 10, price: 100, amount: 1000 },\n        { date: 2.days.ago, ticker: \"AAPL\", qty: 10, price: 100, amount: 1000 }\n      ]\n    )\n\n    calculated = Balance::ReverseCalculator.new(account).calculate\n\n    assert_calculated_ledger_balances(\n      calculated_data: calculated,\n      expected_data: [\n        # No matter what, we force current day equal to the \"anchor\" balance (what provider gave us), and let \"cash\" float based on holdings value\n        # This ensures the user sees the same top-line number reported by the provider (even if it creates a discrepancy in the cash balance)\n        {\n          date: Date.current,\n          legacy_balances: { balance: 20000, cash_balance: 19000 },\n          balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },\n          flows: { market_flows: 0 },\n          adjustments: 0\n        },\n        {\n          date: 1.day.ago,\n          legacy_balances: { balance: 20000, cash_balance: 19000 },\n          balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },\n          flows: { market_flows: 0 },\n          adjustments: 0\n        },\n        {\n          date: 2.days.ago,\n          legacy_balances: { balance: 15000, cash_balance: 14000 },\n          balances: { start: 15000, start_cash: 14000, start_non_cash: 1000, end_cash: 14000, end_non_cash: 1000, end: 15000 },\n          flows: { market_flows: 0 },\n          adjustments: 0\n        } # Opening anchor sets absolute balance\n      ]\n    )\n  end\nend\n"
  },
  {
    "path": "test/models/balance_sheet_test.rb",
    "content": "require \"test_helper\"\n\nclass BalanceSheetTest < ActiveSupport::TestCase\n  setup do\n    @family = families(:empty)\n  end\n\n  test \"calculates total assets\" do\n    assert_equal 0, BalanceSheet.new(@family).assets.total\n\n    create_account(balance: 1000, accountable: Depository.new)\n    create_account(balance: 5000, accountable: OtherAsset.new)\n    create_account(balance: 10000, accountable: CreditCard.new) # ignored\n\n    assert_equal 1000 + 5000, BalanceSheet.new(@family).assets.total\n  end\n\n  test \"calculates total liabilities\" do\n    assert_equal 0, BalanceSheet.new(@family).liabilities.total\n\n    create_account(balance: 1000, accountable: CreditCard.new)\n    create_account(balance: 5000, accountable: OtherLiability.new)\n    create_account(balance: 10000, accountable: Depository.new) # ignored\n\n    assert_equal 1000 + 5000, BalanceSheet.new(@family).liabilities.total\n  end\n\n  test \"calculates net worth\" do\n    assert_equal 0, BalanceSheet.new(@family).net_worth\n\n    create_account(balance: 1000, accountable: CreditCard.new)\n    create_account(balance: 50000, accountable: Depository.new)\n\n    assert_equal 50000 - 1000, BalanceSheet.new(@family).net_worth\n  end\n\n  test \"disabled accounts do not affect totals\" do\n    create_account(balance: 1000, accountable: CreditCard.new)\n    create_account(balance: 10000, accountable: Depository.new)\n\n    other_liability = create_account(balance: 5000, accountable: OtherLiability.new)\n    other_liability.disable!\n\n    assert_equal 10000 - 1000, BalanceSheet.new(@family).net_worth\n    assert_equal 10000, BalanceSheet.new(@family).assets.total\n    assert_equal 1000, BalanceSheet.new(@family).liabilities.total\n  end\n\n  test \"calculates asset group totals\" do\n    create_account(balance: 1000, accountable: Depository.new)\n    create_account(balance: 2000, accountable: Depository.new)\n    create_account(balance: 3000, accountable: Investment.new)\n    create_account(balance: 5000, accountable: OtherAsset.new)\n    create_account(balance: 10000, accountable: CreditCard.new) # ignored\n\n    asset_groups = BalanceSheet.new(@family).assets.account_groups\n\n    assert_equal 3, asset_groups.size\n    assert_equal 1000 + 2000, asset_groups.find { |ag| ag.name == \"Cash\" }.total\n    assert_equal 3000, asset_groups.find { |ag| ag.name == \"Investments\" }.total\n    assert_equal 5000, asset_groups.find { |ag| ag.name == \"Other Assets\" }.total\n  end\n\n  test \"calculates liability group totals\" do\n    create_account(balance: 1000, accountable: CreditCard.new)\n    create_account(balance: 2000, accountable: CreditCard.new)\n    create_account(balance: 3000, accountable: OtherLiability.new)\n    create_account(balance: 5000, accountable: OtherLiability.new)\n    create_account(balance: 10000, accountable: Depository.new) # ignored\n\n    liability_groups = BalanceSheet.new(@family).liabilities.account_groups\n\n    assert_equal 2, liability_groups.size\n    assert_equal 1000 + 2000, liability_groups.find { |ag| ag.name == \"Credit Cards\" }.total\n    assert_equal 3000 + 5000, liability_groups.find { |ag| ag.name == \"Other Liabilities\" }.total\n  end\n\n  private\n    def create_account(attributes = {})\n      account = @family.accounts.create! name: \"Test\", currency: \"USD\", **attributes\n      account\n    end\nend\n"
  },
  {
    "path": "test/models/budget_test.rb",
    "content": "require \"test_helper\"\n\nclass BudgetTest < ActiveSupport::TestCase\n  setup do\n    @family = families(:empty)\n  end\n\n  test \"budget_date_valid? allows going back 2 years even without entries\" do\n    two_years_ago = 2.years.ago.beginning_of_month\n    assert Budget.budget_date_valid?(two_years_ago, family: @family)\n  end\n\n  test \"budget_date_valid? allows going back to earliest entry date if more than 2 years ago\" do\n    # Create an entry 3 years ago\n    old_account = Account.create!(\n      family: @family,\n      accountable: Depository.new,\n      name: \"Old Account\",\n      status: \"active\",\n      currency: \"USD\",\n      balance: 1000\n    )\n\n    old_entry = Entry.create!(\n      account: old_account,\n      entryable: Transaction.new(category: categories(:income)),\n      date: 3.years.ago,\n      name: \"Old Transaction\",\n      amount: 100,\n      currency: \"USD\"\n    )\n\n    # Should allow going back to the old entry date\n    assert Budget.budget_date_valid?(3.years.ago.beginning_of_month, family: @family)\n  end\n\n  test \"budget_date_valid? does not allow dates before earliest entry or 2 years ago\" do\n    # Create an entry 1 year ago\n    account = Account.create!(\n      family: @family,\n      accountable: Depository.new,\n      name: \"Test Account\",\n      status: \"active\",\n      currency: \"USD\",\n      balance: 500\n    )\n\n    Entry.create!(\n      account: account,\n      entryable: Transaction.new(category: categories(:income)),\n      date: 1.year.ago,\n      name: \"Recent Transaction\",\n      amount: 100,\n      currency: \"USD\"\n    )\n\n    # Should not allow going back more than 2 years\n    refute Budget.budget_date_valid?(3.years.ago.beginning_of_month, family: @family)\n  end\n\n  test \"budget_date_valid? does not allow future dates beyond current month\" do\n    refute Budget.budget_date_valid?(2.months.from_now, family: @family)\n  end\n\n  test \"previous_budget_param returns nil when date is too old\" do\n    # Create a budget at the oldest allowed date\n    two_years_ago = 2.years.ago.beginning_of_month\n    budget = Budget.create!(\n      family: @family,\n      start_date: two_years_ago,\n      end_date: two_years_ago.end_of_month,\n      currency: \"USD\"\n    )\n\n    assert_nil budget.previous_budget_param\n  end\n\n  test \"previous_budget_param returns param when date is valid\" do\n    budget = Budget.create!(\n      family: @family,\n      start_date: Date.current.beginning_of_month,\n      end_date: Date.current.end_of_month,\n      currency: \"USD\"\n    )\n\n    assert_not_nil budget.previous_budget_param\n  end\nend\n"
  },
  {
    "path": "test/models/category_test.rb",
    "content": "require \"test_helper\"\n\nclass CategoryTest < ActiveSupport::TestCase\n  def setup\n    @family = families(:dylan_family)\n  end\n\n  test \"replacing and destroying\" do\n    transactions = categories(:food_and_drink).transactions.to_a\n\n    categories(:food_and_drink).replace_and_destroy!(categories(:income))\n\n    assert_equal categories(:income), transactions.map { |t| t.reload.category }.uniq.first\n  end\n\n  test \"replacing with nil should nullify the category\" do\n    transactions = categories(:food_and_drink).transactions.to_a\n\n    categories(:food_and_drink).replace_and_destroy!(nil)\n\n    assert_nil transactions.map { |t| t.reload.category }.uniq.first\n  end\n\n  test \"subcategory can only be one level deep\" do\n    category = categories(:subcategory)\n\n    error = assert_raises(ActiveRecord::RecordInvalid) do\n      category.subcategories.create!(name: \"Invalid category\", family: @family)\n    end\n\n    assert_equal \"Validation failed: Parent can't have more than 2 levels of subcategories\", error.message\n  end\nend\n"
  },
  {
    "path": "test/models/chat_test.rb",
    "content": "require \"test_helper\"\n\nclass ChatTest < ActiveSupport::TestCase\n  setup do\n    @user = users(:family_admin)\n    @assistant = mock\n  end\n\n  test \"user sees all messages in debug mode\" do\n    chat = chats(:one)\n    with_env_overrides AI_DEBUG_MODE: \"true\" do\n      assert_equal chat.messages.count, chat.conversation_messages.count\n    end\n  end\n\n  test \"user sees assistant and user messages in normal mode\" do\n    chat = chats(:one)\n    assert_equal 3, chat.conversation_messages.count\n  end\n\n  test \"creates with initial message\" do\n    prompt = \"Test prompt\"\n\n    assert_difference \"@user.chats.count\", 1 do\n      chat = @user.chats.start!(prompt, model: \"gpt-4.1\")\n\n      assert_equal 1, chat.messages.count\n      assert_equal 1, chat.messages.where(type: \"UserMessage\").count\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/concerns/enrichable_test.rb",
    "content": "require \"test_helper\"\n\nclass EnrichableTest < ActiveSupport::TestCase\n  setup do\n    @enrichable = accounts(:depository)\n  end\n\n  test \"can enrich multiple attributes\" do\n    assert_difference \"DataEnrichment.count\", 2 do\n      @enrichable.enrich_attributes({ name: \"Updated Checking\", balance: 6_000 }, source: \"plaid\")\n    end\n\n    assert_equal \"Updated Checking\", @enrichable.name\n    assert_equal 6_000, @enrichable.balance.to_d\n  end\n\n  test \"can enrich a single attribute\" do\n    assert_difference \"DataEnrichment.count\", 1 do\n      @enrichable.enrich_attribute(:name, \"Single Update\", source: \"ai\")\n    end\n\n    assert_equal \"Single Update\", @enrichable.name\n  end\n\n  test \"can lock an attribute\" do\n    refute @enrichable.locked?(:name)\n\n    @enrichable.lock_attr!(:name)\n    assert @enrichable.locked?(:name)\n  end\n\n  test \"can unlock an attribute\" do\n    @enrichable.lock_attr!(:name)\n    assert @enrichable.locked?(:name)\n\n    @enrichable.unlock_attr!(:name)\n    refute @enrichable.locked?(:name)\n  end\n\n  test \"can lock saved attributes\" do\n    @enrichable.name = \"User Override\"\n    @enrichable.balance = 1_234\n    @enrichable.save!\n\n    @enrichable.lock_saved_attributes!\n\n    assert @enrichable.locked?(:name)\n    assert @enrichable.locked?(:balance)\n  end\n\n  test \"does not enrich locked attributes\" do\n    original_name = @enrichable.name\n\n    @enrichable.lock_attr!(:name)\n\n    assert_no_difference \"DataEnrichment.count\" do\n      @enrichable.enrich_attribute(:name, \"Should Not Change\", source: \"plaid\")\n    end\n\n    assert_equal original_name, @enrichable.reload.name\n  end\n\n  test \"enrichable? reflects lock state\" do\n    assert @enrichable.enrichable?(:name)\n\n    @enrichable.lock_attr!(:name)\n\n    refute @enrichable.enrichable?(:name)\n  end\n\n  test \"enrichable scope includes and excludes records based on lock state\" do\n    # Initially, the record should be enrichable for :name\n    assert_includes Account.enrichable(:name), @enrichable\n\n    @enrichable.lock_attr!(:name)\n\n    refute_includes Account.enrichable(:name), @enrichable\n  end\nend\n"
  },
  {
    "path": "test/models/current_test.rb",
    "content": "require \"test_helper\"\n\nclass CurrentTest < ActiveSupport::TestCase\n  test \"family returns user family\" do\n    user = users(:family_admin)\n    Current.session = user.sessions.create!\n    assert_equal user.family, Current.family\n  end\nend\n"
  },
  {
    "path": "test/models/developer_message_test.rb",
    "content": "require \"test_helper\"\n\nclass DeveloperMessageTest < ActiveSupport::TestCase\n  setup do\n    @chat = chats(:one)\n  end\n\n  test \"does not broadcast\" do\n    message = DeveloperMessage.create!(chat: @chat, content: \"Some instructions\")\n    message.update!(content: \"updated\")\n\n    assert_no_turbo_stream_broadcasts(@chat)\n  end\n\n  test \"broadcasts if debug mode is enabled\" do\n    with_env_overrides AI_DEBUG_MODE: \"true\" do\n      message = DeveloperMessage.create!(chat: @chat, content: \"Some instructions\")\n      message.update!(content: \"updated\")\n\n      streams = capture_turbo_stream_broadcasts(@chat)\n      assert_equal 2, streams.size\n      assert_equal \"append\", streams.first[\"action\"]\n      assert_equal \"messages\", streams.first[\"target\"]\n      assert_equal \"update\", streams.last[\"action\"]\n      assert_equal \"developer_message_#{message.id}\", streams.last[\"target\"]\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/exchange_rate/importer_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass ExchangeRate::ImporterTest < ActiveSupport::TestCase\n  include ProviderTestHelper\n\n  setup do\n    @provider = mock\n  end\n\n  test \"syncs missing rates from provider\" do\n    ExchangeRate.delete_all\n\n    provider_response = provider_success_response([\n      OpenStruct.new(from: \"USD\", to: \"EUR\", date: 2.days.ago.to_date, rate: 1.3),\n      OpenStruct.new(from: \"USD\", to: \"EUR\", date: 1.day.ago.to_date, rate: 1.4),\n      OpenStruct.new(from: \"USD\", to: \"EUR\", date: Date.current, rate: 1.5)\n    ])\n\n    @provider.expects(:fetch_exchange_rates)\n             .with(from: \"USD\", to: \"EUR\", start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current)\n             .returns(provider_response)\n\n    ExchangeRate::Importer.new(\n      exchange_rate_provider: @provider,\n      from: \"USD\",\n      to: \"EUR\",\n      start_date: 2.days.ago.to_date,\n      end_date: Date.current\n    ).import_provider_rates\n\n    db_rates = ExchangeRate.where(from_currency: \"USD\", to_currency: \"EUR\", date: 2.days.ago.to_date..Date.current)\n                           .order(:date)\n\n    assert_equal 3, db_rates.count\n    assert_equal 1.3, db_rates[0].rate\n    assert_equal 1.4, db_rates[1].rate\n    assert_equal 1.5, db_rates[2].rate\n  end\n\n  test \"syncs diff when some rates already exist\" do\n    ExchangeRate.delete_all\n\n    # Pre-populate DB with the first two days\n    ExchangeRate.create!(from_currency: \"USD\", to_currency: \"EUR\", date: 3.days.ago.to_date, rate: 1.2)\n    ExchangeRate.create!(from_currency: \"USD\", to_currency: \"EUR\", date: 2.days.ago.to_date, rate: 1.25)\n\n    provider_response = provider_success_response([\n      OpenStruct.new(from: \"USD\", to: \"EUR\", date: 1.day.ago.to_date, rate: 1.3)\n    ])\n\n    @provider.expects(:fetch_exchange_rates)\n             .with(from: \"USD\", to: \"EUR\", start_date: get_provider_fetch_start_date(1.day.ago.to_date), end_date: Date.current)\n             .returns(provider_response)\n\n    ExchangeRate::Importer.new(\n      exchange_rate_provider: @provider,\n      from: \"USD\",\n      to: \"EUR\",\n      start_date: 3.days.ago.to_date,\n      end_date: Date.current\n    ).import_provider_rates\n\n    db_rates = ExchangeRate.order(:date)\n    assert_equal 4, db_rates.count\n    assert_equal [ 1.2, 1.25, 1.3, 1.3 ], db_rates.map(&:rate)\n  end\n\n  test \"no provider calls when all rates exist\" do\n    ExchangeRate.delete_all\n\n    (3.days.ago.to_date..Date.current).each_with_index do |date, idx|\n      ExchangeRate.create!(from_currency: \"USD\", to_currency: \"EUR\", date:, rate: 1.2 + idx * 0.01)\n    end\n\n    @provider.expects(:fetch_exchange_rates).never\n\n    ExchangeRate::Importer.new(\n      exchange_rate_provider: @provider,\n      from: \"USD\",\n      to: \"EUR\",\n      start_date: 3.days.ago.to_date,\n      end_date: Date.current\n    ).import_provider_rates\n  end\n\n  # A helpful \"reset\" option for when we need to refresh provider data\n  test \"full upsert if clear_cache is true\" do\n    ExchangeRate.delete_all\n\n    # Seed DB with stale data\n    (2.days.ago.to_date..Date.current).each do |date|\n      ExchangeRate.create!(from_currency: \"USD\", to_currency: \"EUR\", date:, rate: 1.0)\n    end\n\n    provider_response = provider_success_response([\n      OpenStruct.new(from: \"USD\", to: \"EUR\", date: 2.days.ago.to_date, rate: 1.3),\n      OpenStruct.new(from: \"USD\", to: \"EUR\", date: 1.day.ago.to_date, rate: 1.4),\n      OpenStruct.new(from: \"USD\", to: \"EUR\", date: Date.current,        rate: 1.5)\n    ])\n\n    @provider.expects(:fetch_exchange_rates)\n             .with(from: \"USD\", to: \"EUR\", start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current)\n             .returns(provider_response)\n\n    ExchangeRate::Importer.new(\n      exchange_rate_provider: @provider,\n      from: \"USD\",\n      to: \"EUR\",\n      start_date: 2.days.ago.to_date,\n      end_date: Date.current,\n      clear_cache: true\n    ).import_provider_rates\n\n    db_rates = ExchangeRate.where(from_currency: \"USD\", to_currency: \"EUR\").order(:date)\n    assert_equal [ 1.3, 1.4, 1.5 ], db_rates.map(&:rate)\n  end\n\n  test \"clamps end_date to today when future date is provided\" do\n    ExchangeRate.delete_all\n\n    future_date = Date.current + 3.days\n\n    provider_response = provider_success_response([\n      OpenStruct.new(from: \"USD\", to: \"EUR\", date: Date.current, rate: 1.6)\n    ])\n\n    @provider.expects(:fetch_exchange_rates)\n             .with(from: \"USD\", to: \"EUR\", start_date: get_provider_fetch_start_date(Date.current), end_date: Date.current)\n             .returns(provider_response)\n\n    ExchangeRate::Importer.new(\n      exchange_rate_provider: @provider,\n      from: \"USD\",\n      to: \"EUR\",\n      start_date: Date.current,\n      end_date: future_date\n    ).import_provider_rates\n\n    assert_equal 1, ExchangeRate.count\n  end\n\n  private\n    def get_provider_fetch_start_date(start_date)\n      # We fetch with a 5 day buffer to account for weekends and holidays\n      start_date - 5.days\n    end\nend\n"
  },
  {
    "path": "test/models/exchange_rate_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass ExchangeRateTest < ActiveSupport::TestCase\n  include ProviderTestHelper\n\n  setup do\n    @provider = mock\n\n    ExchangeRate.stubs(:provider).returns(@provider)\n  end\n\n  test \"finds rate in DB\" do\n    existing_rate = exchange_rates(:one)\n\n    @provider.expects(:fetch_exchange_rate).never\n\n    assert_equal existing_rate, ExchangeRate.find_or_fetch_rate(\n                                              from: existing_rate.from_currency,\n                                              to: existing_rate.to_currency,\n                                              date: existing_rate.date\n                                            )\n  end\n\n  test \"fetches rate from provider without cache\" do\n    ExchangeRate.delete_all\n\n    provider_response = provider_success_response(\n      OpenStruct.new(\n        from: \"USD\",\n        to: \"EUR\",\n        date: Date.current,\n        rate: 1.2\n      )\n    )\n\n    @provider.expects(:fetch_exchange_rate).returns(provider_response)\n\n    assert_no_difference \"ExchangeRate.count\" do\n      assert_equal 1.2, ExchangeRate.find_or_fetch_rate(from: \"USD\", to: \"EUR\", date: Date.current, cache: false).rate\n    end\n  end\n\n  test \"fetches rate from provider with cache\" do\n    ExchangeRate.delete_all\n\n    provider_response = provider_success_response(\n      OpenStruct.new(\n        from: \"USD\",\n        to: \"EUR\",\n        date: Date.current,\n        rate: 1.2\n      )\n    )\n\n    @provider.expects(:fetch_exchange_rate).returns(provider_response)\n\n    assert_difference \"ExchangeRate.count\", 1 do\n      assert_equal 1.2, ExchangeRate.find_or_fetch_rate(from: \"USD\", to: \"EUR\", date: Date.current, cache: true).rate\n    end\n  end\n\n  test \"returns nil on provider error\" do\n    provider_response = provider_error_response(StandardError.new(\"Test error\"))\n\n    @provider.expects(:fetch_exchange_rate).returns(provider_response)\n\n    assert_nil ExchangeRate.find_or_fetch_rate(from: \"USD\", to: \"EUR\", date: Date.current, cache: true)\n  end\nend\n"
  },
  {
    "path": "test/models/family/auto_categorizer_test.rb",
    "content": "require \"test_helper\"\n\nclass Family::AutoCategorizerTest < ActiveSupport::TestCase\n  include EntriesTestHelper, ProviderTestHelper\n\n  setup do\n    @family = families(:dylan_family)\n    @account = @family.accounts.create!(name: \"Rule test\", balance: 100, currency: \"USD\", accountable: Depository.new)\n    @llm_provider = mock\n    Provider::Registry.stubs(:get_provider).with(:openai).returns(@llm_provider)\n  end\n\n  test \"auto-categorizes transactions\" do\n    txn1 = create_transaction(account: @account, name: \"McDonalds\").transaction\n    txn2 = create_transaction(account: @account, name: \"Amazon purchase\").transaction\n    txn3 = create_transaction(account: @account, name: \"Netflix subscription\").transaction\n\n    test_category = @family.categories.create!(name: \"Test category\")\n\n    provider_response = provider_success_response([\n      AutoCategorization.new(transaction_id: txn1.id, category_name: test_category.name),\n      AutoCategorization.new(transaction_id: txn2.id, category_name: test_category.name),\n      AutoCategorization.new(transaction_id: txn3.id, category_name: nil)\n    ])\n\n    @llm_provider.expects(:auto_categorize).returns(provider_response).once\n\n    assert_difference \"DataEnrichment.count\", 2 do\n      Family::AutoCategorizer.new(@family, transaction_ids: [ txn1.id, txn2.id, txn3.id ]).auto_categorize\n    end\n\n    assert_equal test_category, txn1.reload.category\n    assert_equal test_category, txn2.reload.category\n    assert_nil txn3.reload.category\n\n    # After auto-categorization, all transactions are locked and no longer enrichable\n    assert_equal 0, @account.transactions.reload.enrichable(:category_id).count\n  end\n\n  private\n    AutoCategorization = Provider::LlmConcept::AutoCategorization\nend\n"
  },
  {
    "path": "test/models/family/auto_merchant_detector_test.rb",
    "content": "require \"test_helper\"\n\nclass Family::AutoMerchantDetectorTest < ActiveSupport::TestCase\n  include EntriesTestHelper, ProviderTestHelper\n\n  setup do\n    @family = families(:dylan_family)\n    @account = @family.accounts.create!(name: \"Rule test\", balance: 100, currency: \"USD\", accountable: Depository.new)\n    @llm_provider = mock\n    Provider::Registry.stubs(:get_provider).with(:openai).returns(@llm_provider)\n  end\n\n  test \"auto detects transaction merchants\" do\n    txn1 = create_transaction(account: @account, name: \"McDonalds\").transaction\n    txn2 = create_transaction(account: @account, name: \"Chipotle\").transaction\n    txn3 = create_transaction(account: @account, name: \"generic\").transaction\n\n    provider_response = provider_success_response([\n      AutoDetectedMerchant.new(transaction_id: txn1.id, business_name: \"McDonalds\", business_url: \"mcdonalds.com\"),\n      AutoDetectedMerchant.new(transaction_id: txn2.id, business_name: \"Chipotle\", business_url: \"chipotle.com\"),\n      AutoDetectedMerchant.new(transaction_id: txn3.id, business_name: nil, business_url: nil)\n    ])\n\n    @llm_provider.expects(:auto_detect_merchants).returns(provider_response).once\n\n    assert_difference \"DataEnrichment.count\", 2 do\n      Family::AutoMerchantDetector.new(@family, transaction_ids: [ txn1.id, txn2.id, txn3.id ]).auto_detect\n    end\n\n    assert_equal \"McDonalds\", txn1.reload.merchant.name\n    assert_equal \"Chipotle\", txn2.reload.merchant.name\n    assert_equal \"https://logo.synthfinance.com/mcdonalds.com\", txn1.reload.merchant.logo_url\n    assert_equal \"https://logo.synthfinance.com/chipotle.com\", txn2.reload.merchant.logo_url\n    assert_nil txn3.reload.merchant\n\n    # After auto-detection, all transactions are locked and no longer enrichable\n    assert_equal 0, @account.transactions.reload.enrichable(:merchant_id).count\n  end\n\n  private\n    AutoDetectedMerchant = Provider::LlmConcept::AutoDetectedMerchant\nend\n"
  },
  {
    "path": "test/models/family/auto_transfer_matchable_test.rb",
    "content": "require \"test_helper\"\n\nclass Family::AutoTransferMatchableTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @family = families(:dylan_family)\n    @depository = accounts(:depository)\n    @credit_card = accounts(:credit_card)\n  end\n\n  test \"auto-matches transfers\" do\n    outflow_entry = create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 500)\n    inflow_entry = create_transaction(date: Date.current, account: @credit_card, amount: -500)\n\n    assert_difference -> { Transfer.count } => 1 do\n      @family.auto_match_transfers!\n    end\n  end\n\n  test \"auto-matches multi-currency transfers\" do\n    load_exchange_prices\n    create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 500)\n    create_transaction(date: Date.current, account: @credit_card, amount: -700, currency: \"CAD\")\n\n    assert_difference -> { Transfer.count } => 1 do\n      @family.auto_match_transfers!\n    end\n\n    # test match within lower 5% bound\n    create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 1000)\n    create_transaction(date: Date.current, account: @credit_card, amount: -1330, currency: \"CAD\")\n\n    assert_difference -> { Transfer.count } => 1 do\n      @family.auto_match_transfers!\n    end\n\n    # test match within upper 5% bound\n    create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 1500)\n    create_transaction(date: Date.current, account: @credit_card, amount: -2189, currency: \"CAD\")\n\n    assert_difference -> { Transfer.count } => 1 do\n      @family.auto_match_transfers!\n    end\n\n    # test no match outside of slippage tolerance\n    create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 1000)\n    create_transaction(date: Date.current, account: @credit_card, amount: -1320, currency: \"CAD\")\n\n    assert_difference -> { Transfer.count } => 0 do\n      @family.auto_match_transfers!\n    end\n  end\n\n  test \"only matches inflow with correct currency when duplicate amounts exist\" do\n    load_exchange_prices\n    create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 500)\n    create_transaction(date: Date.current, account: @credit_card, amount: -500, currency: \"CAD\")\n    create_transaction(date: Date.current, account: @credit_card, amount: -500)\n\n    assert_difference -> { Transfer.count } => 1 do\n      @family.auto_match_transfers!\n    end\n  end\n\n  # In this scenario, our matching logic should find 4 potential matches.  These matches should be ranked based on\n  # days apart, then de-duplicated so that we aren't auto-matching the same transaction across multiple transfers.\n  test \"when 2 options exist, only auto-match one at a time, ranked by days apart\" do\n    yesterday_outflow = create_transaction(date: 1.day.ago.to_date, account: @depository, amount: 500)\n    yesterday_inflow = create_transaction(date: 1.day.ago.to_date, account: @credit_card, amount: -500)\n\n    today_outflow = create_transaction(date: Date.current, account: @depository, amount: 500)\n    today_inflow = create_transaction(date: Date.current, account: @credit_card, amount: -500)\n\n    assert_difference -> { Transfer.count } => 2 do\n      @family.auto_match_transfers!\n    end\n  end\n\n  test \"does not auto-match any transfers that have been rejected by user already\" do\n    outflow = create_transaction(date: Date.current, account: @depository, amount: 500)\n    inflow = create_transaction(date: Date.current, account: @credit_card, amount: -500)\n\n    RejectedTransfer.create!(inflow_transaction_id: inflow.entryable_id, outflow_transaction_id: outflow.entryable_id)\n\n    assert_no_difference -> { Transfer.count } do\n      @family.auto_match_transfers!\n    end\n  end\n\n  test \"does not consider inactive accounts when matching transfers\" do\n    @depository.disable!\n\n    outflow = create_transaction(date: Date.current, account: @depository, amount: 500)\n    inflow = create_transaction(date: Date.current, account: @credit_card, amount: -500)\n\n    assert_no_difference -> { Transfer.count } do\n      @family.auto_match_transfers!\n    end\n  end\n\n  test \"does not match transactions outside the 4-day window\" do\n    create_transaction(date: 10.days.ago.to_date, account: @depository, amount: 500)\n    create_transaction(date: Date.current, account: @credit_card, amount: -500)\n\n    assert_no_difference -> { Transfer.count } do\n      @family.auto_match_transfers!\n    end\n  end\n\n  test \"does not match multi-currency transfer with missing exchange rate\" do\n    create_transaction(date: Date.current, account: @depository, amount: 500)\n    create_transaction(date: Date.current, account: @credit_card, amount: -700, currency: \"GBP\")\n\n    assert_no_difference -> { Transfer.count } do\n      @family.auto_match_transfers!\n    end\n  end\n\n  private\n    def load_exchange_prices\n      rates = {\n        4.days.ago.to_date => 1.36,\n        3.days.ago.to_date => 1.37,\n        2.days.ago.to_date => 1.38,\n        1.day.ago.to_date  => 1.39,\n        Date.current => 1.40\n      }\n\n      rates.each do |date, rate|\n        # USD to CAD\n        ExchangeRate.create!(\n          from_currency: \"USD\",\n          to_currency: \"CAD\",\n          date: date,\n          rate: rate\n        )\n\n        # CAD to USD (inverse)\n        ExchangeRate.create!(\n          from_currency: \"CAD\",\n          to_currency: \"USD\",\n          date: date,\n          rate: (1.0 / rate).round(6)\n        )\n      end\n    end\nend\n"
  },
  {
    "path": "test/models/family/data_exporter_test.rb",
    "content": "require \"test_helper\"\n\nclass Family::DataExporterTest < ActiveSupport::TestCase\n  setup do\n    @family = families(:dylan_family)\n    @other_family = families(:empty)\n    @exporter = Family::DataExporter.new(@family)\n\n    # Create some test data for the family\n    @account = @family.accounts.create!(\n      name: \"Test Account\",\n      accountable: Depository.new,\n      balance: 1000,\n      currency: \"USD\"\n    )\n\n    @category = @family.categories.create!(\n      name: \"Test Category\",\n      color: \"#FF0000\"\n    )\n\n    @tag = @family.tags.create!(\n      name: \"Test Tag\",\n      color: \"#00FF00\"\n    )\n  end\n\n  test \"generates a zip file with all required files\" do\n    zip_data = @exporter.generate_export\n\n    assert zip_data.is_a?(StringIO)\n\n    # Check that the zip contains all expected files\n    expected_files = [ \"accounts.csv\", \"transactions.csv\", \"trades.csv\", \"categories.csv\", \"all.ndjson\" ]\n\n    Zip::File.open_buffer(zip_data) do |zip|\n      actual_files = zip.entries.map(&:name)\n      assert_equal expected_files.sort, actual_files.sort\n    end\n  end\n\n  test \"generates valid CSV files\" do\n    zip_data = @exporter.generate_export\n\n    Zip::File.open_buffer(zip_data) do |zip|\n      # Check accounts.csv\n      accounts_csv = zip.read(\"accounts.csv\")\n      assert accounts_csv.include?(\"id,name,type,subtype,balance,currency,created_at\")\n\n      # Check transactions.csv\n      transactions_csv = zip.read(\"transactions.csv\")\n      assert transactions_csv.include?(\"date,account_name,amount,name,category,tags,notes,currency\")\n\n      # Check trades.csv\n      trades_csv = zip.read(\"trades.csv\")\n      assert trades_csv.include?(\"date,account_name,ticker,quantity,price,amount,currency\")\n\n      # Check categories.csv\n      categories_csv = zip.read(\"categories.csv\")\n      assert categories_csv.include?(\"name,color,parent_category,classification\")\n    end\n  end\n\n  test \"generates valid NDJSON file\" do\n    zip_data = @exporter.generate_export\n\n    Zip::File.open_buffer(zip_data) do |zip|\n      ndjson_content = zip.read(\"all.ndjson\")\n      lines = ndjson_content.split(\"\\n\")\n\n      lines.each do |line|\n        assert_nothing_raised { JSON.parse(line) }\n      end\n\n      # Check that each line has expected structure\n      first_line = JSON.parse(lines.first)\n      assert first_line.key?(\"type\")\n      assert first_line.key?(\"data\")\n    end\n  end\n\n  test \"only exports data from the specified family\" do\n    # Create data for another family that should NOT be exported\n    other_account = @other_family.accounts.create!(\n      name: \"Other Family Account\",\n      accountable: Depository.new,\n      balance: 5000,\n      currency: \"USD\"\n    )\n\n    other_category = @other_family.categories.create!(\n      name: \"Other Family Category\",\n      color: \"#0000FF\"\n    )\n\n    zip_data = @exporter.generate_export\n\n    Zip::File.open_buffer(zip_data) do |zip|\n      # Check accounts.csv doesn't contain other family's data\n      accounts_csv = zip.read(\"accounts.csv\")\n      assert accounts_csv.include?(@account.name)\n      refute accounts_csv.include?(other_account.name)\n\n      # Check categories.csv doesn't contain other family's data\n      categories_csv = zip.read(\"categories.csv\")\n      assert categories_csv.include?(@category.name)\n      refute categories_csv.include?(other_category.name)\n\n      # Check NDJSON doesn't contain other family's data\n      ndjson_content = zip.read(\"all.ndjson\")\n      refute ndjson_content.include?(other_account.id)\n      refute ndjson_content.include?(other_category.id)\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/family/subscribeable_test.rb",
    "content": "require \"test_helper\"\n\nclass Family::SubscribeableTest < ActiveSupport::TestCase\n  setup do\n    @family = families(:dylan_family)\n  end\n\n  # We keep the status eventually consistent, but don't rely on it for guarding the app\n  test \"trial respects end date even if status is not yet updated\" do\n    @family.subscription.update!(trial_ends_at: 1.day.ago, status: \"trialing\")\n    assert_not @family.trialing?\n  end\nend\n"
  },
  {
    "path": "test/models/family/syncer_test.rb",
    "content": "require \"test_helper\"\n\nclass Family::SyncerTest < ActiveSupport::TestCase\n  setup do\n    @family = families(:dylan_family)\n  end\n\n  test \"syncs plaid items and manual accounts\" do\n    family_sync = syncs(:family)\n\n    manual_accounts_count = @family.accounts.manual.count\n    items_count = @family.plaid_items.count\n\n    syncer = Family::Syncer.new(@family)\n\n    Account.any_instance\n           .expects(:sync_later)\n           .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil)\n           .times(manual_accounts_count)\n\n    PlaidItem.any_instance\n             .expects(:sync_later)\n             .with(parent_sync: family_sync, window_start_date: nil, window_end_date: nil)\n             .times(items_count)\n\n    syncer.perform_sync(family_sync)\n\n    assert_equal \"completed\", family_sync.reload.status\n  end\nend\n"
  },
  {
    "path": "test/models/family_export_test.rb",
    "content": "require \"test_helper\"\n\nclass FamilyExportTest < ActiveSupport::TestCase\n  # test \"the truth\" do\n  #   assert true\n  # end\nend\n"
  },
  {
    "path": "test/models/family_test.rb",
    "content": "require \"test_helper\"\n\nclass FamilyTest < ActiveSupport::TestCase\n  include SyncableInterfaceTest\n\n  def setup\n    @syncable = families(:dylan_family)\n  end\nend\n"
  },
  {
    "path": "test/models/holding/forward_calculator_test.rb",
    "content": "require \"test_helper\"\n\nclass Holding::ForwardCalculatorTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @account = families(:empty).accounts.create!(\n      name: \"Test\",\n      balance: 20000,\n      cash_balance: 20000,\n      currency: \"USD\",\n      accountable: Investment.new\n    )\n  end\n\n  test \"no holdings\" do\n    calculated = Holding::ForwardCalculator.new(@account).calculate\n    assert_equal [], calculated\n  end\n\n  test \"holding generation respects user timezone and last generated date is current user date\" do\n    # Simulate user in EST timezone\n    Time.use_zone(\"America/New_York\") do\n      # Set current time to 1am UTC on Jan 5, 2025\n      # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for)\n      travel_to Time.utc(2025, 01, 05, 1, 0, 0)\n\n      voo = Security.create!(ticker: \"VOO\", name: \"Vanguard S&P 500 ETF\")\n      Security::Price.create!(security: voo, date: \"2025-01-02\", price: 500)\n      Security::Price.create!(security: voo, date: \"2025-01-03\", price: 500)\n      Security::Price.create!(security: voo, date: \"2025-01-04\", price: 500)\n      create_trade(voo, qty: 10, date: \"2025-01-03\", price: 500, account: @account)\n\n      expected = [ [ \"2025-01-02\", 0 ], [ \"2025-01-03\", 5000 ], [ \"2025-01-04\", 5000 ] ]\n      calculated = Holding::ForwardCalculator.new(@account).calculate\n\n      assert_equal expected, calculated.map { |b| [ b.date.to_s, b.amount ] }\n    end\n  end\n\n  test \"forward portfolio calculation\" do\n    load_prices\n\n    # Build up to 10 shares of VOO (current value $5000)\n    create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account)\n    create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account)\n    create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account)\n\n    # Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio\n    create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account)\n    create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account)\n\n    # Build up to 100 shares of WMT (current value $10000)\n    create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)\n\n    expected = [\n      # 4 days ago\n      Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0),\n      Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0),\n      Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0),\n\n      # 3 days ago\n      Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400),\n      Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0),\n      Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0),\n\n      # 2 days ago\n      Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400),\n      Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),\n      Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200),\n\n      # 1 day ago\n      Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900),\n      Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),\n      Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0),\n\n      # Today\n      Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000),\n      Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000),\n      Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)\n    ]\n\n    calculated = Holding::ForwardCalculator.new(@account).calculate\n\n    assert_equal expected.length, calculated.length\n    assert_holdings(expected, calculated)\n  end\n\n  # Carries the previous record forward if no holding exists for a date\n  # to ensure that net worth historical rollups have a value for every date\n  test \"uses locf to fill missing holdings\" do\n    load_prices\n\n    create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)\n\n    expected = [\n      Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),\n      Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),\n      Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000)\n    ]\n\n    # Price missing today, so we should carry forward the holding from 1 day ago\n    Security.stubs(:find).returns(@wmt)\n    Security::Price.stubs(:find_price).with(security: @wmt, date: 2.days.ago.to_date).returns(Security::Price.new(price: 100))\n    Security::Price.stubs(:find_price).with(security: @wmt, date: 1.day.ago.to_date).returns(Security::Price.new(price: 100))\n    Security::Price.stubs(:find_price).with(security: @wmt, date: Date.current).returns(nil)\n\n    calculated = Holding::ForwardCalculator.new(@account).calculate\n\n    assert_equal expected.length, calculated.length\n    assert_holdings(expected, calculated)\n  end\n\n  test \"offline tickers sync holdings based on most recent trade price\" do\n    offline_security = Security.create!(ticker: \"OFFLINE\", name: \"Offline Ticker\")\n\n    create_trade(offline_security, qty: 1, date: 3.days.ago.to_date, price: 90, account: @account)\n    create_trade(offline_security, qty: 1, date: 1.day.ago.to_date, price: 100, account: @account)\n\n    expected = [\n      Holding.new(security: offline_security, date: 3.days.ago.to_date, qty: 1, price: 90, amount: 90),\n      Holding.new(security: offline_security, date: 2.days.ago.to_date, qty: 1, price: 90, amount: 90),\n      Holding.new(security: offline_security, date: 1.day.ago.to_date, qty: 2, price: 100, amount: 200),\n      Holding.new(security: offline_security, date: Date.current, qty: 2, price: 100, amount: 200)\n    ]\n\n    calculated = Holding::ForwardCalculator.new(@account).calculate\n\n    assert_equal expected.length, calculated.length\n    assert_holdings(expected, calculated)\n  end\n\n  private\n    def assert_holdings(expected, calculated)\n      expected.each do |expected_entry|\n        calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date }\n\n        assert_equal expected_entry.qty, calculated_entry.qty, \"Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}\"\n        assert_equal expected_entry.price, calculated_entry.price, \"Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}\"\n        assert_equal expected_entry.amount, calculated_entry.amount, \"Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}\"\n      end\n    end\n\n    def load_prices\n      @voo = Security.create!(ticker: \"VOO\", name: \"Vanguard S&P 500 ETF\")\n      Security::Price.create!(security: @voo, date: 4.days.ago.to_date, price: 460)\n      Security::Price.create!(security: @voo, date: 3.days.ago.to_date, price: 470)\n      Security::Price.create!(security: @voo, date: 2.days.ago.to_date, price: 480)\n      Security::Price.create!(security: @voo, date: 1.day.ago.to_date, price: 490)\n      Security::Price.create!(security: @voo, date: Date.current, price: 500)\n\n      @wmt = Security.create!(ticker: \"WMT\", name: \"Walmart Inc.\")\n      Security::Price.create!(security: @wmt, date: 4.days.ago.to_date, price: 100)\n      Security::Price.create!(security: @wmt, date: 3.days.ago.to_date, price: 100)\n      Security::Price.create!(security: @wmt, date: 2.days.ago.to_date, price: 100)\n      Security::Price.create!(security: @wmt, date: 1.day.ago.to_date, price: 100)\n      Security::Price.create!(security: @wmt, date: Date.current, price: 100)\n\n      @amzn = Security.create!(ticker: \"AMZN\", name: \"Amazon.com Inc.\")\n      Security::Price.create!(security: @amzn, date: 4.days.ago.to_date, price: 200)\n      Security::Price.create!(security: @amzn, date: 3.days.ago.to_date, price: 200)\n      Security::Price.create!(security: @amzn, date: 2.days.ago.to_date, price: 200)\n      Security::Price.create!(security: @amzn, date: 1.day.ago.to_date, price: 200)\n      Security::Price.create!(security: @amzn, date: Date.current, price: 200)\n    end\nend\n"
  },
  {
    "path": "test/models/holding/materializer_test.rb",
    "content": "require \"test_helper\"\n\nclass Holding::MaterializerTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @family = families(:empty)\n    @account = @family.accounts.create!(name: \"Test\", balance: 20000, cash_balance: 20000, currency: \"USD\", accountable: Investment.new)\n    @aapl = securities(:aapl)\n  end\n\n  test \"syncs holdings\" do\n    create_trade(@aapl, account: @account, qty: 1, price: 200, date: Date.current)\n\n    # Should have yesterday's and today's holdings\n    assert_difference \"@account.holdings.count\", 2 do\n      Holding::Materializer.new(@account, strategy: :forward).materialize_holdings\n    end\n  end\n\n  test \"purges stale holdings for unlinked accounts\" do\n    # Since the account has no entries, there should be no holdings\n    Holding.create!(account: @account, security: @aapl, qty: 1, price: 100, amount: 100, currency: \"USD\", date: Date.current)\n\n    assert_difference \"Holding.count\", -1 do\n      Holding::Materializer.new(@account, strategy: :forward).materialize_holdings\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/holding/portfolio_cache_test.rb",
    "content": "require \"test_helper\"\n\nclass Holding::PortfolioCacheTest < ActiveSupport::TestCase\n  include EntriesTestHelper, ProviderTestHelper\n\n  setup do\n    @provider = mock\n    Security.stubs(:provider).returns(@provider)\n\n    @account = families(:empty).accounts.create!(\n      name: \"Test Brokerage\",\n      balance: 10000,\n      currency: \"USD\",\n      accountable: Investment.new\n    )\n\n    @security = Security.create!(name: \"Test Security\", ticker: \"TEST\", exchange_operating_mic: \"TEST\")\n\n    @trade = create_trade(@security, account: @account, qty: 1, date: 2.days.ago.to_date, price: 210.23).trade\n  end\n\n  test \"gets price from DB if available\" do\n    db_price = 210\n\n    Security::Price.create!(\n      security: @security,\n      date: Date.current,\n      price: db_price\n    )\n\n    cache = Holding::PortfolioCache.new(@account)\n    assert_equal db_price, cache.get_price(@security.id, Date.current).price\n  end\n\n  test \"if no price from db, try getting the price from trades\" do\n    Security::Price.destroy_all\n\n    cache = Holding::PortfolioCache.new(@account)\n    assert_equal @trade.price, cache.get_price(@security.id, @trade.entry.date).price\n  end\n\n  test \"if no price from db or trades, search holdings\" do\n    Security::Price.delete_all\n    Entry.delete_all\n\n    holding = Holding.create!(\n      security: @security,\n      account: @account,\n      date: Date.current,\n      qty: 1,\n      price: 250,\n      amount: 250 * 1,\n      currency: \"USD\"\n    )\n\n    cache = Holding::PortfolioCache.new(@account, use_holdings: true)\n    assert_equal holding.price, cache.get_price(@security.id, holding.date).price\n  end\nend\n"
  },
  {
    "path": "test/models/holding/portfolio_snapshot_test.rb",
    "content": "require \"test_helper\"\n\nclass Holding::PortfolioSnapshotTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n  setup do\n    @account = accounts(:investment)\n    @aapl = securities(:aapl)\n    @msft = securities(:msft)\n  end\n\n  test \"captures the most recent holding quantities for each security\" do\n    # Clear any existing data\n    @account.holdings.destroy_all\n    @account.entries.destroy_all\n\n    # Create some trades to establish which securities are in the portfolio\n    create_trade(@aapl, account: @account, qty: 10, price: 100, date: 5.days.ago)\n    create_trade(@msft, account: @account, qty: 30, price: 200, date: 5.days.ago)\n\n    # Create holdings for AAPL at different dates\n    @account.holdings.create!(security: @aapl, date: 3.days.ago, qty: 10, price: 100, amount: 1000, currency: \"USD\")\n    @account.holdings.create!(security: @aapl, date: 1.day.ago, qty: 20, price: 150, amount: 3000, currency: \"USD\")\n\n    # Create holdings for MSFT at different dates\n    @account.holdings.create!(security: @msft, date: 5.days.ago, qty: 30, price: 200, amount: 6000, currency: \"USD\")\n    @account.holdings.create!(security: @msft, date: 2.days.ago, qty: 40, price: 250, amount: 10000, currency: \"USD\")\n\n    snapshot = Holding::PortfolioSnapshot.new(@account)\n    portfolio = snapshot.to_h\n\n    assert_equal 2, portfolio.size\n    assert_equal 20, portfolio[@aapl.id]\n    assert_equal 40, portfolio[@msft.id]\n  end\n\n  test \"includes securities from trades with zero quantities when no holdings exist\" do\n    # Clear any existing data\n    @account.holdings.destroy_all\n    @account.entries.destroy_all\n\n    # Create a trade to establish AAPL is in the portfolio\n    create_trade(@aapl, account: @account, qty: 10, price: 100, date: 5.days.ago)\n\n    snapshot = Holding::PortfolioSnapshot.new(@account)\n    portfolio = snapshot.to_h\n\n    assert_equal 1, portfolio.size\n    assert_equal 0, portfolio[@aapl.id]\n  end\nend\n"
  },
  {
    "path": "test/models/holding/reverse_calculator_test.rb",
    "content": "require \"test_helper\"\n\nclass Holding::ReverseCalculatorTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @account = families(:empty).accounts.create!(\n      name: \"Test\",\n      balance: 20000,\n      cash_balance: 20000,\n      currency: \"USD\",\n      accountable: Investment.new\n    )\n  end\n\n  test \"no holdings\" do\n    empty_snapshot = OpenStruct.new(to_h: {})\n    calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: empty_snapshot).calculate\n    assert_equal [], calculated\n  end\n\n  test \"holding generation respects user timezone and last generated date is current user date\" do\n    # Simulate user in EST timezone\n    Time.use_zone(\"America/New_York\") do\n      # Set current time to 1am UTC on Jan 5, 2025\n      # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for)\n      travel_to Time.utc(2025, 01, 05, 1, 0, 0)\n\n      voo = Security.create!(ticker: \"VOO\", name: \"Vanguard S&P 500 ETF\")\n      Security::Price.create!(security: voo, date: \"2025-01-02\", price: 500)\n      Security::Price.create!(security: voo, date: \"2025-01-03\", price: 500)\n      Security::Price.create!(security: voo, date: \"2025-01-04\", price: 500)\n\n      # Today's holdings (provided)\n      @account.holdings.create!(security: voo, date: \"2025-01-04\", qty: 10, price: 500, amount: 5000, currency: \"USD\")\n\n      create_trade(voo, qty: 10, date: \"2025-01-03\", price: 500, account: @account)\n\n      expected = [ [ \"2025-01-02\", 0 ], [ \"2025-01-03\", 5000 ], [ \"2025-01-04\", 5000 ] ]\n      # Mock snapshot with the holdings we created\n      snapshot = OpenStruct.new(to_h: { voo.id => 10 })\n      calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot).calculate\n\n      assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.amount ] }\n    end\n  end\n\n  # Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings\n  test \"reverse portfolio with trades but without current day holdings\" do\n    voo = Security.create!(ticker: \"VOO\", name: \"Vanguard S&P 500 ETF\")\n    Security::Price.create!(security: voo, date: Date.current, price: 470)\n    Security::Price.create!(security: voo, date: 1.day.ago.to_date, price: 470)\n\n    create_trade(voo, qty: -10, date: Date.current, price: 470, account: @account)\n\n    # Mock empty portfolio since no current day holdings\n    snapshot = OpenStruct.new(to_h: { voo.id => 0 })\n    calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot).calculate\n    assert_equal 2, calculated.length\n  end\n\n  test \"reverse portfolio calculation\" do\n    load_today_portfolio\n\n    # Build up to 10 shares of VOO (current value $5000)\n    create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account)\n    create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account)\n    create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account)\n\n    # Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio\n    create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account)\n    create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account)\n\n    # Build up to 100 shares of WMT (current value $10000)\n    create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)\n\n    expected = [\n      # 4 days ago\n      Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0),\n      Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0),\n      Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0),\n\n      # 3 days ago\n      Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400),\n      Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0),\n      Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0),\n\n      # 2 days ago\n      Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400),\n      Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),\n      Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200),\n\n      # 1 day ago\n      Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900),\n      Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),\n      Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0),\n\n      # Today\n      Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000),\n      Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000),\n      Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)\n    ]\n\n    # Mock snapshot with today's portfolio from load_today_portfolio\n    snapshot = OpenStruct.new(to_h: { @voo.id => 10, @wmt.id => 100, @amzn.id => 0 })\n    calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot).calculate\n\n    assert_equal expected.length, calculated.length\n\n    expected.each do |expected_entry|\n      calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date }\n\n      assert_equal expected_entry.qty, calculated_entry.qty, \"Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}\"\n      assert_equal expected_entry.price, calculated_entry.price, \"Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}\"\n      assert_equal expected_entry.amount, calculated_entry.amount, \"Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}\"\n    end\n  end\n\n  # For a reverse sync, Plaid will provide today's holdings + prices.  We need to match those exactly so balances match in net worth rollups.\n  test \"current day holdings always match provided holdings and prices\" do\n    # Provider gives us total value of $10,000 ($5,000 cash, $5,000 in holdings)\n    @account.update!(balance: 10000, cash_balance: 5000)\n\n    wmt = Security.create!(ticker: \"WMT\", name: \"Walmart Inc.\")\n    create_trade(wmt, qty: 50, date: 1.day.ago.to_date, price: 98, account: @account)\n\n    @account.holdings.create!(\n      date: Date.current,\n      price: 100,\n      qty: 50,\n      amount: 5000,\n      currency: \"USD\",\n      security: wmt\n    )\n\n    Security::Price.create!(security: wmt, date: Date.current, price: 102) # This price should be ignored on current day\n    Security::Price.create!(security: wmt, date: 1.day.ago, price: 98) # This price will be used for historical holding calculation\n    Security::Price.create!(security: wmt, date: 2.days.ago, price: 95) # This price will be used for historical holding calculation\n\n    expected = [\n      Holding.new(security: wmt, date: 2.days.ago.to_date, qty: 0, price: 95, amount: 0), # Uses market price, empty holding\n      Holding.new(security: wmt, date: 1.day.ago.to_date, qty: 50, price: 98, amount: 4900), # Uses market price\n      Holding.new(security: wmt, date: Date.current, qty: 50, price: 100, amount: 5000) # Uses holding price, not market price\n    ]\n\n    # Mock snapshot with WMT holding from the test setup\n    snapshot = OpenStruct.new(to_h: { wmt.id => 50 })\n    calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot).calculate\n\n    assert_equal expected.length, calculated.length\n\n    expected.each do |expected_entry|\n      calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date }\n\n      assert_equal expected_entry.qty, calculated_entry.qty, \"Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}\"\n      assert_equal expected_entry.price, calculated_entry.price, \"Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}\"\n      assert_equal expected_entry.amount, calculated_entry.amount, \"Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}\"\n    end\n  end\n\n  private\n    def assert_holdings(expected, calculated)\n      expected.each do |expected_entry|\n        calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date }\n\n        assert_equal expected_entry.qty, calculated_entry.qty, \"Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}\"\n        assert_equal expected_entry.price, calculated_entry.price, \"Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}\"\n        assert_equal expected_entry.amount, calculated_entry.amount, \"Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}\"\n      end\n    end\n\n    def load_prices\n      @voo = Security.create!(ticker: \"VOO\", name: \"Vanguard S&P 500 ETF\")\n      Security::Price.create!(security: @voo, date: 4.days.ago.to_date, price: 460)\n      Security::Price.create!(security: @voo, date: 3.days.ago.to_date, price: 470)\n      Security::Price.create!(security: @voo, date: 2.days.ago.to_date, price: 480)\n      Security::Price.create!(security: @voo, date: 1.day.ago.to_date, price: 490)\n      Security::Price.create!(security: @voo, date: Date.current, price: 500)\n\n      @wmt = Security.create!(ticker: \"WMT\", name: \"Walmart Inc.\")\n      Security::Price.create!(security: @wmt, date: 4.days.ago.to_date, price: 100)\n      Security::Price.create!(security: @wmt, date: 3.days.ago.to_date, price: 100)\n      Security::Price.create!(security: @wmt, date: 2.days.ago.to_date, price: 100)\n      Security::Price.create!(security: @wmt, date: 1.day.ago.to_date, price: 100)\n      Security::Price.create!(security: @wmt, date: Date.current, price: 100)\n\n      @amzn = Security.create!(ticker: \"AMZN\", name: \"Amazon.com Inc.\")\n      Security::Price.create!(security: @amzn, date: 4.days.ago.to_date, price: 200)\n      Security::Price.create!(security: @amzn, date: 3.days.ago.to_date, price: 200)\n      Security::Price.create!(security: @amzn, date: 2.days.ago.to_date, price: 200)\n      Security::Price.create!(security: @amzn, date: 1.day.ago.to_date, price: 200)\n      Security::Price.create!(security: @amzn, date: Date.current, price: 200)\n    end\n\n    # Portfolio holdings:\n    # +--------+-----+--------+---------+\n    # | Ticker | Qty | Price  | Amount  |\n    # +--------+-----+--------+---------+\n    # | VOO    |  10 | $500   | $5,000  |\n    # | WMT    | 100 | $100   | $10,000 |\n    # +--------+-----+--------+---------+\n    # Brokerage Cash: $5,000\n    # Holdings Value: $15,000\n    # Total Balance: $20,000\n    def load_today_portfolio\n      @account.update!(cash_balance: 5000)\n\n      load_prices\n\n      @account.holdings.create!(\n        date: Date.current,\n        price: 500,\n        qty: 10,\n        amount: 5000,\n        currency: \"USD\",\n        security: @voo\n      )\n\n      @account.holdings.create!(\n        date: Date.current,\n        price: 100,\n        qty: 100,\n        amount: 10000,\n        currency: \"USD\",\n        security: @wmt\n      )\n    end\nend\n"
  },
  {
    "path": "test/models/holding_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass HoldingTest < ActiveSupport::TestCase\n  include EntriesTestHelper, SecuritiesTestHelper\n\n  setup do\n    @account = families(:empty).accounts.create!(name: \"Test Brokerage\", balance: 20000, cash_balance: 0, currency: \"USD\", accountable: Investment.new)\n\n    # Current day holding instances\n    @amzn, @nvda = load_holdings\n  end\n\n  test \"calculates portfolio weight\" do\n    expected_amzn_weight = 3240.0 / @account.balance * 100\n    expected_nvda_weight = 3720.0 / @account.balance * 100\n\n    assert_in_delta expected_amzn_weight, @amzn.weight, 0.001\n    assert_in_delta expected_nvda_weight, @nvda.weight, 0.001\n  end\n\n  test \"calculates average cost basis\" do\n    create_trade(@amzn.security, account: @account, qty: 10, price: 212.00, date: 1.day.ago.to_date)\n    create_trade(@amzn.security, account: @account, qty: 15, price: 216.00, date: Date.current)\n\n    create_trade(@nvda.security, account: @account, qty: 5, price: 128.00, date: 1.day.ago.to_date)\n    create_trade(@nvda.security, account: @account, qty: 30, price: 124.00, date: Date.current)\n\n    assert_equal Money.new((212.00 + 216.00).to_d / 2), @amzn.avg_cost\n    assert_equal Money.new((128.00 + 124.00).to_d / 2), @nvda.avg_cost\n  end\n\n  test \"calculates average cost basis from another currency\" do\n    create_trade(@amzn.security, account: @account, qty: 10, price: 212.00, date: 1.day.ago.to_date, currency: \"CAD\")\n    create_trade(@amzn.security, account: @account, qty: 15, price: 216.00, date: Date.current, currency: \"CAD\")\n\n    create_trade(@nvda.security, account: @account, qty: 5, price: 128.00, date: 1.day.ago.to_date, currency: \"CAD\")\n    create_trade(@nvda.security, account: @account, qty: 30, price: 124.00, date: Date.current, currency: \"CAD\")\n\n    assert_equal Money.new((212.00 + 216.00).to_d / 2, \"CAD\").exchange_to(\"USD\", fallback_rate: 1), @amzn.avg_cost\n    assert_equal Money.new((128.00 + 124.00).to_d / 2, \"CAD\").exchange_to(\"USD\", fallback_rate: 1), @nvda.avg_cost\n  end\n\n  test \"calculates total return trend\" do\n    @amzn.stubs(:avg_cost).returns(Money.new(214.00))\n    @nvda.stubs(:avg_cost).returns(Money.new(126.00))\n\n    # Gained $30, or 0.93%\n    assert_equal Money.new(30), @amzn.trend.value\n    assert_in_delta 0.9, @amzn.trend.percent, 0.001\n\n    # Lost $60, or -1.59%\n    assert_equal Money.new(-60), @nvda.trend.value\n    assert_in_delta -1.6, @nvda.trend.percent, 0.001\n  end\n\n  private\n\n    def load_holdings\n      security1 = create_security(\"AMZN\", prices: [\n        { date: 1.day.ago.to_date, price: 212.00 },\n        { date: Date.current, price: 216.00 }\n      ])\n\n      security2 = create_security(\"NVDA\", prices: [\n        { date: 1.day.ago.to_date, price: 128.00 },\n        { date: Date.current, price: 124.00 }\n      ])\n\n      create_holding(security1, 1.day.ago.to_date, 10)\n      amzn = create_holding(security1, Date.current, 15)\n\n      create_holding(security2, 1.day.ago.to_date, 5)\n      nvda = create_holding(security2, Date.current, 30)\n\n      [ amzn, nvda ]\n    end\n\n    def create_holding(security, date, qty)\n      price = Security::Price.find_by(date: date, security: security).price\n\n      @account.holdings.create! \\\n        date: date,\n        security: security,\n        qty: qty,\n        price: price,\n        amount: qty * price,\n        currency: \"USD\"\n    end\nend\n"
  },
  {
    "path": "test/models/impersonation_session_test.rb",
    "content": "require \"test_helper\"\n\nclass ImpersonationSessionTest < ActiveSupport::TestCase\n  test \"only super admin can impersonate\" do\n    regular_user = users(:family_member)\n\n    assert_not regular_user.super_admin?\n\n    assert_raises(ActiveRecord::RecordInvalid) do\n      ImpersonationSession.create!(\n        impersonator: regular_user,\n        impersonated: users(:maybe_support_staff)\n      )\n    end\n  end\n\n  test \"super admin cannot be impersonated\" do\n    super_admin = users(:maybe_support_staff)\n\n    assert super_admin.super_admin?\n\n    assert_raises(ActiveRecord::RecordInvalid) do\n      ImpersonationSession.create!(\n        impersonator: users(:family_member),\n        impersonated: super_admin\n      )\n    end\n  end\n\n  test \"impersonation session must have different impersonator and impersonated\" do\n    super_admin = users(:maybe_support_staff)\n\n    assert_raises(ActiveRecord::RecordInvalid) do\n      ImpersonationSession.create!(\n        impersonator: super_admin,\n        impersonated: super_admin\n      )\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/income_statement_test.rb",
    "content": "require \"test_helper\"\n\nclass IncomeStatementTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @family = families(:empty)\n\n    @income_category = @family.categories.create! name: \"Income\", classification: \"income\"\n    @food_category = @family.categories.create! name: \"Food\", classification: \"expense\"\n    @groceries_category = @family.categories.create! name: \"Groceries\", classification: \"expense\", parent: @food_category\n\n    @checking_account = @family.accounts.create! name: \"Checking\", currency: @family.currency, balance: 5000, accountable: Depository.new\n    @credit_card_account = @family.accounts.create! name: \"Credit Card\", currency: @family.currency, balance: 1000, accountable: CreditCard.new\n    @loan_account = @family.accounts.create! name: \"Mortgage\", currency: @family.currency, balance: 50000, accountable: Loan.new\n\n    create_transaction(account: @checking_account, amount: -1000, category: @income_category)\n    create_transaction(account: @checking_account, amount: 200, category: @groceries_category)\n    create_transaction(account: @credit_card_account, amount: 300, category: @groceries_category)\n    create_transaction(account: @credit_card_account, amount: 400, category: @groceries_category)\n  end\n\n  test \"calculates totals for transactions\" do\n    income_statement = IncomeStatement.new(@family)\n    assert_equal Money.new(1000, @family.currency), income_statement.totals.income_money\n    assert_equal Money.new(200 + 300 + 400, @family.currency), income_statement.totals.expense_money\n    assert_equal 4, income_statement.totals.transactions_count\n  end\n\n  test \"calculates expenses for a period\" do\n    income_statement = IncomeStatement.new(@family)\n    expense_totals = income_statement.expense_totals(period: Period.last_30_days)\n\n    expected_total_expense = 200 + 300 + 400\n\n    assert_equal expected_total_expense, expense_totals.total\n    assert_equal expected_total_expense, expense_totals.category_totals.find { |ct| ct.category.id == @groceries_category.id }.total\n    assert_equal expected_total_expense, expense_totals.category_totals.find { |ct| ct.category.id == @food_category.id }.total\n  end\n\n  test \"calculates income for a period\" do\n    income_statement = IncomeStatement.new(@family)\n    income_totals = income_statement.income_totals(period: Period.last_30_days)\n\n    expected_total_income = 1000\n\n    assert_equal expected_total_income, income_totals.total\n    assert_equal expected_total_income, income_totals.category_totals.find { |ct| ct.category.id == @income_category.id }.total\n  end\n\n  test \"calculates median expense\" do\n    income_statement = IncomeStatement.new(@family)\n    assert_equal 200 + 300 + 400, income_statement.expense_totals(period: Period.last_30_days).total\n  end\n\n  test \"calculates median income\" do\n    income_statement = IncomeStatement.new(@family)\n    assert_equal 1000, income_statement.income_totals(period: Period.last_30_days).total\n  end\n\n  # NEW TESTS: Statistical Methods\n  test \"calculates median expense correctly with known dataset\" do\n    # Clear existing transactions by deleting entries\n    Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all\n\n    # Create expenses: 100, 200, 300, 400, 500 (median should be 300)\n    create_transaction(account: @checking_account, amount: 100, category: @groceries_category)\n    create_transaction(account: @checking_account, amount: 200, category: @groceries_category)\n    create_transaction(account: @checking_account, amount: 300, category: @groceries_category)\n    create_transaction(account: @checking_account, amount: 400, category: @groceries_category)\n    create_transaction(account: @checking_account, amount: 500, category: @groceries_category)\n\n    income_statement = IncomeStatement.new(@family)\n    # CORRECT BUSINESS LOGIC: Calculates median of time-period totals for budget planning\n    # All transactions in same month = monthly total of 1500, so median = 1500.0\n    assert_equal 1500.0, income_statement.median_expense(interval: \"month\")\n  end\n\n  test \"calculates median income correctly with known dataset\" do\n    # Clear existing transactions by deleting entries\n    Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all\n\n    # Create income: -200, -300, -400, -500, -600 (median should be -400, displayed as 400)\n    create_transaction(account: @checking_account, amount: -200, category: @income_category)\n    create_transaction(account: @checking_account, amount: -300, category: @income_category)\n    create_transaction(account: @checking_account, amount: -400, category: @income_category)\n    create_transaction(account: @checking_account, amount: -500, category: @income_category)\n    create_transaction(account: @checking_account, amount: -600, category: @income_category)\n\n    income_statement = IncomeStatement.new(@family)\n    # CORRECT BUSINESS LOGIC: Calculates median of time-period totals for budget planning\n    # All transactions in same month = monthly total of -2000, so median = 2000.0\n    assert_equal 2000.0, income_statement.median_income(interval: \"month\")\n  end\n\n  test \"calculates average expense correctly with known dataset\" do\n    # Clear existing transactions by deleting entries\n    Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all\n\n    # Create expenses: 100, 200, 300 (average should be 200)\n    create_transaction(account: @checking_account, amount: 100, category: @groceries_category)\n    create_transaction(account: @checking_account, amount: 200, category: @groceries_category)\n    create_transaction(account: @checking_account, amount: 300, category: @groceries_category)\n\n    income_statement = IncomeStatement.new(@family)\n    # CORRECT BUSINESS LOGIC: Calculates average of time-period totals for budget planning\n    # All transactions in same month = monthly total of 600, so average = 600.0\n    assert_equal 600.0, income_statement.avg_expense(interval: \"month\")\n  end\n\n  test \"calculates category-specific median expense\" do\n    # Clear existing transactions by deleting entries\n    Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all\n\n    # Create different amounts for groceries vs other food\n    other_food_category = @family.categories.create! name: \"Restaurants\", classification: \"expense\", parent: @food_category\n\n    # Groceries: 100, 300, 500 (median = 300)\n    create_transaction(account: @checking_account, amount: 100, category: @groceries_category)\n    create_transaction(account: @checking_account, amount: 300, category: @groceries_category)\n    create_transaction(account: @checking_account, amount: 500, category: @groceries_category)\n\n    # Restaurants: 50, 150 (median = 100)\n    create_transaction(account: @checking_account, amount: 50, category: other_food_category)\n    create_transaction(account: @checking_account, amount: 150, category: other_food_category)\n\n    income_statement = IncomeStatement.new(@family)\n    # CORRECT BUSINESS LOGIC: Calculates median of time-period totals for budget planning\n    # All groceries in same month = monthly total of 900, so median = 900.0\n    assert_equal 900.0, income_statement.median_expense(interval: \"month\", category: @groceries_category)\n    # For restaurants: monthly total = 200, so median = 200.0\n    restaurants_median = income_statement.median_expense(interval: \"month\", category: other_food_category)\n    assert_equal 200.0, restaurants_median\n  end\n\n  test \"calculates category-specific average expense\" do\n    # Clear existing transactions by deleting entries\n    Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all\n\n    # Create different amounts for groceries\n    # Groceries: 100, 200, 300 (average = 200)\n    create_transaction(account: @checking_account, amount: 100, category: @groceries_category)\n    create_transaction(account: @checking_account, amount: 200, category: @groceries_category)\n    create_transaction(account: @checking_account, amount: 300, category: @groceries_category)\n\n    income_statement = IncomeStatement.new(@family)\n    # CORRECT BUSINESS LOGIC: Calculates average of time-period totals for budget planning\n    # All transactions in same month = monthly total of 600, so average = 600.0\n    assert_equal 600.0, income_statement.avg_expense(interval: \"month\", category: @groceries_category)\n  end\n\n  # NEW TESTS: Transfer and Kind Filtering\n  # NOTE: These tests now pass because kind filtering is working after the refactoring!\n  test \"excludes regular transfers from income statement calculations\" do\n    # Create a regular transfer between accounts\n    outflow_transaction = create_transaction(account: @checking_account, amount: 500, kind: \"funds_movement\")\n    inflow_transaction = create_transaction(account: @credit_card_account, amount: -500, kind: \"funds_movement\")\n\n    income_statement = IncomeStatement.new(@family)\n    totals = income_statement.totals\n\n    # NOW WORKING: Excludes transfers correctly after refactoring\n    assert_equal 4, totals.transactions_count # Only original 4 transactions\n    assert_equal Money.new(1000, @family.currency), totals.income_money\n    assert_equal Money.new(900, @family.currency), totals.expense_money\n  end\n\n  test \"includes loan payments as expenses in income statement\" do\n    # Create a loan payment transaction\n    loan_payment = create_transaction(account: @checking_account, amount: 1000, category: nil, kind: \"loan_payment\")\n\n    income_statement = IncomeStatement.new(@family)\n    totals = income_statement.totals\n\n    # CONTINUES TO WORK: Includes loan payments as expenses (loan_payment not in exclusion list)\n    assert_equal 5, totals.transactions_count\n    assert_equal Money.new(1000, @family.currency), totals.income_money\n    assert_equal Money.new(1900, @family.currency), totals.expense_money # 900 + 1000\n  end\n\n  test \"excludes one-time transactions from income statement calculations\" do\n    # Create a one-time transaction\n    one_time_transaction = create_transaction(account: @checking_account, amount: 250, category: @groceries_category, kind: \"one_time\")\n\n    income_statement = IncomeStatement.new(@family)\n    totals = income_statement.totals\n\n    # NOW WORKING: Excludes one-time transactions correctly after refactoring\n    assert_equal 4, totals.transactions_count # Only original 4 transactions\n    assert_equal Money.new(1000, @family.currency), totals.income_money\n    assert_equal Money.new(900, @family.currency), totals.expense_money\n  end\n\n  test \"excludes payment transactions from income statement calculations\" do\n    # Create a payment transaction (credit card payment)\n    payment_transaction = create_transaction(account: @checking_account, amount: 300, category: nil, kind: \"cc_payment\")\n\n    income_statement = IncomeStatement.new(@family)\n    totals = income_statement.totals\n\n    # NOW WORKING: Excludes payment transactions correctly after refactoring\n    assert_equal 4, totals.transactions_count # Only original 4 transactions\n    assert_equal Money.new(1000, @family.currency), totals.income_money\n    assert_equal Money.new(900, @family.currency), totals.expense_money\n  end\n\n  test \"excludes excluded transactions from income statement calculations\" do\n    # Create an excluded transaction\n    excluded_transaction_entry = create_transaction(account: @checking_account, amount: 250, category: @groceries_category)\n    excluded_transaction_entry.update!(excluded: true)\n\n    income_statement = IncomeStatement.new(@family)\n    totals = income_statement.totals\n\n    # Should exclude excluded transactions\n    assert_equal 4, totals.transactions_count # Only original 4 transactions\n    assert_equal Money.new(1000, @family.currency), totals.income_money\n    assert_equal Money.new(900, @family.currency), totals.expense_money\n  end\n\n  # NEW TESTS: Interval-Based Calculations\n  test \"different intervals return different statistical results with multi-period data\" do\n    # Clear existing transactions\n    Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all\n\n    # Create transactions across multiple weeks to test interval behavior\n    # Week 1: 100, 200 (total: 300, median: 150)\n    create_transaction(account: @checking_account, amount: 100, category: @groceries_category, date: 3.weeks.ago)\n    create_transaction(account: @checking_account, amount: 200, category: @groceries_category, date: 3.weeks.ago + 1.day)\n\n    # Week 2: 400, 600 (total: 1000, median: 500)\n    create_transaction(account: @checking_account, amount: 400, category: @groceries_category, date: 2.weeks.ago)\n    create_transaction(account: @checking_account, amount: 600, category: @groceries_category, date: 2.weeks.ago + 1.day)\n\n    # Week 3: 800 (total: 800, median: 800)\n    create_transaction(account: @checking_account, amount: 800, category: @groceries_category, date: 1.week.ago)\n\n    income_statement = IncomeStatement.new(@family)\n\n    month_median = income_statement.median_expense(interval: \"month\")\n    week_median = income_statement.median_expense(interval: \"week\")\n\n    # CRITICAL TEST: Different intervals should return different results\n    # Month interval: median of monthly totals (if all in same month) vs individual transactions\n    # Week interval: median of weekly totals [300, 1000, 800] = 800 vs individual transactions [100,200,400,600,800] = 400\n    refute_equal month_median, week_median, \"Different intervals should return different statistical results when data spans multiple time periods\"\n\n    # Both should still be numeric\n    assert month_median.is_a?(Numeric)\n    assert week_median.is_a?(Numeric)\n    assert month_median > 0\n    assert week_median > 0\n  end\n\n  # NEW TESTS: Edge Cases\n  test \"handles empty dataset gracefully\" do\n    # Create a truly empty family\n    empty_family = Family.create!(name: \"Empty Test Family\", currency: \"USD\")\n    income_statement = IncomeStatement.new(empty_family)\n\n    # Should return 0 for statistical measures\n    assert_equal 0, income_statement.median_expense(interval: \"month\")\n    assert_equal 0, income_statement.median_income(interval: \"month\")\n    assert_equal 0, income_statement.avg_expense(interval: \"month\")\n  end\n\n  test \"handles category not found gracefully\" do\n    nonexistent_category = Category.new(id: 99999, name: \"Nonexistent\")\n\n    income_statement = IncomeStatement.new(@family)\n\n    assert_equal 0, income_statement.median_expense(interval: \"month\", category: nonexistent_category)\n    assert_equal 0, income_statement.avg_expense(interval: \"month\", category: nonexistent_category)\n  end\n\n  test \"handles transactions without categories\" do\n    # Create transaction without category\n    create_transaction(account: @checking_account, amount: 150, category: nil)\n\n    income_statement = IncomeStatement.new(@family)\n    totals = income_statement.totals\n\n    # Should still include uncategorized transaction in totals\n    assert_equal 5, totals.transactions_count\n    assert_equal Money.new(1050, @family.currency), totals.expense_money # 900 + 150\n  end\nend\n"
  },
  {
    "path": "test/models/invite_code_test.rb",
    "content": "require \"test_helper\"\n\nclass InviteCodeTest < ActiveSupport::TestCase\n  test \"claim! destroys the invite token\" do\n    code = InviteCode.generate!\n\n    assert_difference \"InviteCode.count\", -1 do\n      InviteCode.claim! code\n    end\n  end\n\n  test \"claim! returns true if valid\" do\n    assert InviteCode.claim!(InviteCode.generate!)\n  end\n\n  test \"claim! is falsy if invalid\" do\n    assert_not InviteCode.claim!(\"invalid\")\n  end\n\n  test \"generate! creates a new invite and returns its token\" do\n    assert_difference \"InviteCode.count\", +1 do\n      assert_equal InviteCode.generate!, InviteCode.last.token\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/loan_test.rb",
    "content": "require \"test_helper\"\n\nclass LoanTest < ActiveSupport::TestCase\n  test \"calculates correct monthly payment for fixed rate loan\" do\n    loan_account = Account.create! \\\n      family: families(:dylan_family),\n      name: \"Mortgage Loan\",\n      balance: 500000,\n      currency: \"USD\",\n      accountable: Loan.create!(\n        interest_rate: 3.5,\n        term_months: 360,\n        rate_type: \"fixed\"\n      )\n\n    assert_equal 2245, loan_account.loan.monthly_payment.amount\n  end\nend\n"
  },
  {
    "path": "test/models/market_data_importer_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass MarketDataImporterTest < ActiveSupport::TestCase\n  include ProviderTestHelper\n\n  SNAPSHOT_START_DATE = MarketDataImporter::SNAPSHOT_DAYS.days.ago.to_date\n  PROVIDER_BUFFER     = 5.days\n\n  setup do\n    Security::Price.delete_all\n    ExchangeRate.delete_all\n    Trade.delete_all\n    Holding.delete_all\n    Security.delete_all\n\n    @provider = mock(\"provider\")\n    Provider::Registry.any_instance\n                      .stubs(:get_provider)\n                      .with(:synth)\n                      .returns(@provider)\n  end\n\n  test \"syncs required exchange rates\" do\n    family = Family.create!(name: \"Smith\", currency: \"USD\")\n    family.accounts.create!(name: \"Chequing\",\n                            currency: \"CAD\",\n                            balance: 100,\n                            accountable: Depository.new)\n\n    # Seed stale rate so only the next missing day is fetched\n    ExchangeRate.create!(from_currency: \"CAD\",\n                         to_currency: \"USD\",\n                         date: SNAPSHOT_START_DATE,\n                         rate: 2.0)\n\n    expected_start_date = (SNAPSHOT_START_DATE + 1.day) - PROVIDER_BUFFER\n    end_date            = Date.current.in_time_zone(\"America/New_York\").to_date\n\n    @provider.expects(:fetch_exchange_rates)\n             .with(from: \"CAD\",\n                   to: \"USD\",\n                   start_date: expected_start_date,\n                   end_date: end_date)\n             .returns(provider_success_response([\n               OpenStruct.new(from: \"CAD\", to: \"USD\", date: SNAPSHOT_START_DATE, rate: 1.5)\n             ]))\n\n    before = ExchangeRate.count\n    MarketDataImporter.new(mode: :snapshot).import_exchange_rates\n    after  = ExchangeRate.count\n\n    assert_operator after, :>, before, \"Should insert at least one new exchange-rate row\"\n  end\n\n  test \"syncs security prices\" do\n    security = Security.create!(ticker: \"AAPL\", exchange_operating_mic: \"XNAS\")\n\n    expected_start_date = SNAPSHOT_START_DATE - PROVIDER_BUFFER\n    end_date            = Date.current.in_time_zone(\"America/New_York\").to_date\n\n    @provider.expects(:fetch_security_prices)\n             .with(symbol: security.ticker,\n                   exchange_operating_mic: security.exchange_operating_mic,\n                   start_date: expected_start_date,\n                   end_date: end_date)\n             .returns(provider_success_response([\n               OpenStruct.new(security: security,\n                              date: SNAPSHOT_START_DATE,\n                              price: 100,\n                              currency: \"USD\")\n             ]))\n\n    @provider.stubs(:fetch_security_info)\n             .with(symbol: \"AAPL\", exchange_operating_mic: \"XNAS\")\n             .returns(provider_success_response(OpenStruct.new(name: \"Apple\", logo_url: \"logo\")))\n\n    # Ignore exchange rate calls for this test\n    @provider.stubs(:fetch_exchange_rates).returns(provider_success_response([]))\n\n    MarketDataImporter.new(mode: :snapshot).import_security_prices\n\n    assert_equal 1, Security::Price.where(security: security, date: SNAPSHOT_START_DATE).count\n  end\nend\n"
  },
  {
    "path": "test/models/mobile_device_test.rb",
    "content": "require \"test_helper\"\n\nclass MobileDeviceTest < ActiveSupport::TestCase\n  # test \"the truth\" do\n  #   assert true\n  # end\nend\n"
  },
  {
    "path": "test/models/period_test.rb",
    "content": "require \"test_helper\"\n\nclass PeriodTest < ActiveSupport::TestCase\n  test \"raises validation error when start_date or end_date is missing\" do\n    error = assert_raises(ActiveModel::ValidationError) do\n      Period.new(start_date: nil, end_date: nil)\n    end\n\n    assert_includes error.message, \"Start date can't be blank\"\n    assert_includes error.message, \"End date can't be blank\"\n  end\n\n  test \"raises validation error when start_date is not before end_date\" do\n    error = assert_raises(ActiveModel::ValidationError) do\n      Period.new(start_date: Date.current, end_date: Date.current - 1.day)\n    end\n\n    assert_includes error.message, \"Start date must be before end date\"\n  end\n\n  test \"can create custom period\" do\n    period = Period.new(start_date: Date.current - 15.days, end_date: Date.current)\n    assert_equal \"Custom Period\", period.label\n  end\n\n  test \"from_key returns period for valid key\" do\n    period = Period.from_key(\"last_30_days\")\n    assert_equal 30.days.ago.to_date, period.start_date\n    assert_equal Date.current, period.end_date\n  end\n\n  test \"from_key with invalid key and no fallback raises error\" do\n    error = assert_raises(Period::InvalidKeyError) do\n      Period.from_key(\"invalid_key\")\n    end\n  end\n\n  test \"label returns correct label for known period\" do\n    period = Period.from_key(\"last_30_days\")\n    assert_equal \"Last 30 Days\", period.label\n  end\n\n  test \"label returns Custom Period for unknown period\" do\n    period = Period.new(start_date: Date.current - 15.days, end_date: Date.current)\n    assert_equal \"Custom Period\", period.label\n  end\n\n  test \"comparison_label returns correct label for known period\" do\n    period = Period.from_key(\"last_30_days\")\n    assert_equal \"vs. last month\", period.comparison_label\n  end\n\n  test \"comparison_label returns date range for unknown period\" do\n    start_date = Date.current - 15.days\n    end_date = Date.current\n    period = Period.new(start_date: start_date, end_date: end_date)\n    expected = \"#{start_date.strftime(\"%b %d, %Y\")} to #{end_date.strftime(\"%b %d, %Y\")}\"\n    assert_equal expected, period.comparison_label\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_account/importer_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidAccount::ImporterTest < ActiveSupport::TestCase\n  setup do\n    @plaid_account = plaid_accounts(:one)\n    @mock_account_snapshot = mock\n  end\n\n  test \"imports account data\" do\n    account_data = OpenStruct.new(\n      account_id: \"acc_1\",\n      name: \"Test Account\",\n      mask: \"1234\",\n    )\n\n    transactions_data = OpenStruct.new(\n      added: [],\n      modified: [],\n      removed: [],\n    )\n\n    investments_data = OpenStruct.new(\n      holdings: [],\n      transactions: [],\n      securities: [],\n    )\n\n    liabilities_data = OpenStruct.new(\n      credit: [],\n      mortgage: [],\n      student: [],\n    )\n\n    @mock_account_snapshot.expects(:account_data).returns(account_data).at_least_once\n    @mock_account_snapshot.expects(:transactions_data).returns(transactions_data).at_least_once\n    @mock_account_snapshot.expects(:investments_data).returns(investments_data).at_least_once\n    @mock_account_snapshot.expects(:liabilities_data).returns(liabilities_data).at_least_once\n\n    @plaid_account.expects(:upsert_plaid_snapshot!).with(account_data)\n    @plaid_account.expects(:upsert_plaid_transactions_snapshot!).with(transactions_data)\n    @plaid_account.expects(:upsert_plaid_investments_snapshot!).with(investments_data)\n    @plaid_account.expects(:upsert_plaid_liabilities_snapshot!).with(liabilities_data)\n\n    PlaidAccount::Importer.new(@plaid_account, account_snapshot: @mock_account_snapshot).import\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_account/investments/balance_calculator_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidAccount::Investments::BalanceCalculatorTest < ActiveSupport::TestCase\n  setup do\n    @plaid_account = plaid_accounts(:one)\n\n    @plaid_account.update!(\n      plaid_type: \"investment\",\n      current_balance: 4000,\n      available_balance: 2000 # We ignore this since we have current_balance + holdings\n    )\n  end\n\n  test \"calculates total balance from cash and positions\" do\n    brokerage_cash_security_id = \"plaid_brokerage_cash\" # Plaid's brokerage cash security\n    cash_equivalent_security_id = \"plaid_cash_equivalent\" # Cash equivalent security (i.e. money market fund)\n    aapl_security_id = \"plaid_aapl_security\" # Regular stock security\n\n    test_investments = {\n      transactions: [], # Irrelevant for balance calcs, leave empty\n      holdings: [\n        # $1,000 in brokerage cash\n        {\n          security_id: brokerage_cash_security_id,\n          cost_basis: 1000,\n          institution_price: 1,\n          institution_value: 1000,\n          quantity: 1000\n        },\n        # $1,000 in money market funds\n        {\n          security_id: cash_equivalent_security_id,\n          cost_basis: 1000,\n          institution_price: 1,\n          institution_value: 1000,\n          quantity: 1000\n        },\n        # $2,000 worth of AAPL stock\n        {\n          security_id: aapl_security_id,\n          cost_basis: 2000,\n          institution_price: 200,\n          institution_value: 2000,\n          quantity: 10\n        }\n      ],\n      securities: [\n        {\n          security_id: brokerage_cash_security_id,\n          ticker_symbol: \"CUR:USD\",\n          is_cash_equivalent: true,\n          type: \"cash\"\n        },\n        {\n          security_id: cash_equivalent_security_id,\n          ticker_symbol: \"VMFXX\", # Vanguard Money Market Reserves\n          is_cash_equivalent: true,\n          type: \"mutual fund\"\n        },\n        {\n          security_id: aapl_security_id,\n          ticker_symbol: \"AAPL\",\n          is_cash_equivalent: false,\n          type: \"equity\",\n          market_identifier_code: \"XNAS\"\n        }\n      ]\n    }\n\n    @plaid_account.update!(raw_investments_payload: test_investments)\n\n    security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)\n    balance_calculator = PlaidAccount::Investments::BalanceCalculator.new(@plaid_account, security_resolver: security_resolver)\n\n    # We set this equal to `current_balance`\n    assert_equal 4000, balance_calculator.balance\n\n    # This is the sum of \"non-brokerage-cash-holdings\".  In the above test case, this means\n    # we're summing up $2,000 of AAPL + $1,000 Vanguard MM for $3,000 in holdings value.\n    # We back this $3,000 from the $4,000 total to get $1,000 in cash balance.\n    assert_equal 1000, balance_calculator.cash_balance\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_account/investments/holdings_processor_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidAccount::Investments::HoldingsProcessorTest < ActiveSupport::TestCase\n  setup do\n    @plaid_account = plaid_accounts(:one)\n    @security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)\n  end\n\n  test \"creates holding records from Plaid holdings snapshot\" do\n    test_investments_payload = {\n      securities: [], # mocked\n      holdings: [\n        {\n          \"security_id\" => \"123\",\n          \"quantity\" => 100,\n          \"institution_price\" => 100,\n          \"iso_currency_code\" => \"USD\",\n          \"institution_price_as_of\" => 1.day.ago.to_date\n        },\n        {\n          \"security_id\" => \"456\",\n          \"quantity\" => 200,\n          \"institution_price\" => 200,\n          \"iso_currency_code\" => \"USD\"\n        }\n      ],\n      transactions: [] # not relevant for test\n    }\n\n    @plaid_account.update!(raw_investments_payload: test_investments_payload)\n\n    @security_resolver.expects(:resolve)\n                      .with(plaid_security_id: \"123\")\n                      .returns(\n                        OpenStruct.new(\n                          security: securities(:aapl),\n                          cash_equivalent?: false,\n                          brokerage_cash?: false\n                        )\n                      )\n\n    @security_resolver.expects(:resolve)\n                      .with(plaid_security_id: \"456\")\n                      .returns(\n                        OpenStruct.new(\n                          security: securities(:aapl),\n                          cash_equivalent?: false,\n                          brokerage_cash?: false\n                        )\n                      )\n\n    processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)\n\n    assert_difference \"Holding.count\", 2 do\n      processor.process\n    end\n\n    holdings = Holding.where(account: @plaid_account.account).order(:date)\n\n    assert_equal 100, holdings.first.qty\n    assert_equal 100, holdings.first.price\n    assert_equal \"USD\", holdings.first.currency\n    assert_equal securities(:aapl), holdings.first.security\n    assert_equal 1.day.ago.to_date, holdings.first.date\n\n    assert_equal 200, holdings.second.qty\n    assert_equal 200, holdings.second.price\n    assert_equal \"USD\", holdings.second.currency\n    assert_equal securities(:aapl), holdings.second.security\n    assert_equal Date.current, holdings.second.date\n  end\n\n  # When Plaid provides holdings data, it includes an \"institution_price_as_of\" date\n  # which represents when the holdings were last updated. Any holdings in our database\n  # after this date are now stale and should be deleted, as the Plaid data is the\n  # authoritative source of truth for the current holdings.\n  test \"deletes stale holdings per security based on institution price date\" do\n    account = @plaid_account.account\n\n    # Create a third security for testing\n    third_security = Security.create!(ticker: \"GOOGL\", name: \"Google\", exchange_operating_mic: \"XNAS\", country_code: \"US\")\n\n    # Scenario 3: AAPL has a stale holding that should be deleted\n    stale_aapl_holding = account.holdings.create!(\n      security: securities(:aapl),\n      date: Date.current,\n      qty: 80,\n      price: 180,\n      amount: 14400,\n      currency: \"USD\"\n    )\n\n    # Plaid returns 3 holdings with different scenarios\n    test_investments_payload = {\n      securities: [],\n      holdings: [\n        # Scenario 1: Current date holding (no deletions needed)\n        {\n          \"security_id\" => \"current\",\n          \"quantity\" => 50,\n          \"institution_price\" => 50,\n          \"iso_currency_code\" => \"USD\",\n          \"institution_price_as_of\" => Date.current\n        },\n        # Scenario 2: Yesterday's holding with no future holdings\n        {\n          \"security_id\" => \"clean\",\n          \"quantity\" => 75,\n          \"institution_price\" => 75,\n          \"iso_currency_code\" => \"USD\",\n          \"institution_price_as_of\" => 1.day.ago.to_date\n        },\n        # Scenario 3: Yesterday's holding with stale future holding\n        {\n          \"security_id\" => \"stale\",\n          \"quantity\" => 100,\n          \"institution_price\" => 100,\n          \"iso_currency_code\" => \"USD\",\n          \"institution_price_as_of\" => 1.day.ago.to_date\n        }\n      ],\n      transactions: []\n    }\n\n    @plaid_account.update!(raw_investments_payload: test_investments_payload)\n\n    # Mock security resolver for all three securities\n    @security_resolver.expects(:resolve)\n                      .with(plaid_security_id: \"current\")\n                      .returns(OpenStruct.new(security: securities(:msft), cash_equivalent?: false, brokerage_cash?: false))\n\n    @security_resolver.expects(:resolve)\n                      .with(plaid_security_id: \"clean\")\n                      .returns(OpenStruct.new(security: third_security, cash_equivalent?: false, brokerage_cash?: false))\n\n    @security_resolver.expects(:resolve)\n                      .with(plaid_security_id: \"stale\")\n                      .returns(OpenStruct.new(security: securities(:aapl), cash_equivalent?: false, brokerage_cash?: false))\n\n    processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)\n    processor.process\n\n    # Should have created 3 new holdings\n    assert_equal 3, account.holdings.count\n\n    # Scenario 3: Should have deleted the stale AAPL holding\n    assert_not account.holdings.exists?(stale_aapl_holding.id)\n\n    # Should have the correct holdings from Plaid\n    assert account.holdings.exists?(security: securities(:msft), date: Date.current, qty: 50)\n    assert account.holdings.exists?(security: third_security, date: 1.day.ago.to_date, qty: 75)\n    assert account.holdings.exists?(security: securities(:aapl), date: 1.day.ago.to_date, qty: 100)\n  end\n\n  test \"continues processing other holdings when security resolution fails\" do\n    test_investments_payload = {\n      securities: [],\n      holdings: [\n        {\n          \"security_id\" => \"fail\",\n          \"quantity\" => 100,\n          \"institution_price\" => 100,\n          \"iso_currency_code\" => \"USD\"\n        },\n        {\n          \"security_id\" => \"success\",\n          \"quantity\" => 200,\n          \"institution_price\" => 200,\n          \"iso_currency_code\" => \"USD\"\n        }\n      ],\n      transactions: []\n    }\n\n    @plaid_account.update!(raw_investments_payload: test_investments_payload)\n\n    # First security fails to resolve\n    @security_resolver.expects(:resolve)\n                      .with(plaid_security_id: \"fail\")\n                      .returns(OpenStruct.new(security: nil))\n\n    # Second security succeeds\n    @security_resolver.expects(:resolve)\n                      .with(plaid_security_id: \"success\")\n                      .returns(OpenStruct.new(security: securities(:aapl)))\n\n    processor = PlaidAccount::Investments::HoldingsProcessor.new(@plaid_account, security_resolver: @security_resolver)\n\n    # Should create only 1 holding (the successful one)\n    assert_difference \"Holding.count\", 1 do\n      processor.process\n    end\n\n    # Should have created the successful holding\n    assert @plaid_account.account.holdings.exists?(security: securities(:aapl), qty: 200)\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_account/investments/security_resolver_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidAccount::Investments::SecurityResolverTest < ActiveSupport::TestCase\n  setup do\n    @upstream_resolver = mock(\"Security::Resolver\")\n    @plaid_account = plaid_accounts(:one)\n    @resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)\n  end\n\n  test \"handles missing plaid security\" do\n    missing_id = \"missing_security_id\"\n\n    # Ensure there are *no* securities that reference the missing ID\n    @plaid_account.update!(raw_investments_payload: {\n      securities: [\n        {\n          \"security_id\" => \"some_other_id\",\n          \"ticker_symbol\" => \"FOO\",\n          \"type\" => \"equity\",\n          \"market_identifier_code\" => \"XNAS\"\n        }\n      ]\n    })\n\n    Security::Resolver.expects(:new).never\n    Sentry.stubs(:capture_exception)\n\n    response = @resolver.resolve(plaid_security_id: missing_id)\n\n    assert_nil response.security\n    refute response.cash_equivalent?\n    refute response.brokerage_cash?\n  end\n\n  test \"identifies brokerage cash plaid securities\" do\n    brokerage_cash_id = \"brokerage_cash_security_id\"\n\n    @plaid_account.update!(raw_investments_payload: {\n      securities: [\n        {\n          \"security_id\" => brokerage_cash_id,\n          \"ticker_symbol\" => \"CUR:USD\", # Plaid brokerage cash ticker\n          \"type\" => \"cash\",\n          \"is_cash_equivalent\" => true\n        }\n      ]\n    })\n\n    Security::Resolver.expects(:new).never\n\n    response = @resolver.resolve(plaid_security_id: brokerage_cash_id)\n\n    assert_nil response.security\n    assert response.cash_equivalent?\n    assert response.brokerage_cash?\n  end\n\n  test \"identifies cash equivalent plaid securities\" do\n    mmf_security_id = \"money_market_security_id\"\n\n    @plaid_account.update!(raw_investments_payload: {\n      securities: [\n        {\n          \"security_id\" => mmf_security_id,\n          \"ticker_symbol\" => \"VMFXX\", # Vanguard Federal Money Market Fund\n          \"type\" => \"mutual fund\",\n          \"is_cash_equivalent\" => true,\n          \"market_identifier_code\" => \"XNAS\"\n        }\n      ]\n    })\n\n    resolved_security = Security.create!(ticker: \"VMFXX\", exchange_operating_mic: \"XNAS\")\n\n    Security::Resolver.expects(:new)\n                     .with(\"VMFXX\", exchange_operating_mic: \"XNAS\")\n                     .returns(@upstream_resolver)\n    @upstream_resolver.expects(:resolve).returns(resolved_security)\n\n    response = @resolver.resolve(plaid_security_id: mmf_security_id)\n\n    assert_equal resolved_security, response.security\n    assert response.cash_equivalent?\n    refute response.brokerage_cash?\n  end\n\n  test \"resolves normal plaid securities\" do\n    security_id = \"regular_security_id\"\n\n    @plaid_account.update!(raw_investments_payload: {\n      securities: [\n        {\n          \"security_id\" => security_id,\n          \"ticker_symbol\" => \"IVV\",\n          \"type\" => \"etf\",\n          \"is_cash_equivalent\" => false,\n          \"market_identifier_code\" => \"XNAS\"\n        }\n      ]\n    })\n\n    resolved_security = Security.create!(ticker: \"IVV\", exchange_operating_mic: \"XNAS\")\n\n    Security::Resolver.expects(:new)\n                     .with(\"IVV\", exchange_operating_mic: \"XNAS\")\n                     .returns(@upstream_resolver)\n    @upstream_resolver.expects(:resolve).returns(resolved_security)\n\n    response = @resolver.resolve(plaid_security_id: security_id)\n\n    assert_equal resolved_security, response.security\n    refute response.cash_equivalent? # Normal securities are not cash equivalent\n    refute response.brokerage_cash?\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_account/investments/transactions_processor_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidAccount::Investments::TransactionsProcessorTest < ActiveSupport::TestCase\n  setup do\n    @plaid_account = plaid_accounts(:one)\n    @security_resolver = PlaidAccount::Investments::SecurityResolver.new(@plaid_account)\n  end\n\n  test \"creates regular trade entries\" do\n    test_investments_payload = {\n      transactions: [\n        {\n          \"transaction_id\" => \"123\",\n          \"security_id\" => \"123\",\n          \"type\" => \"buy\",\n          \"quantity\" => 1, # Positive, so \"buy 1 share\"\n          \"price\" => 100,\n          \"amount\" => 100,\n          \"iso_currency_code\" => \"USD\",\n          \"date\" => Date.current,\n          \"name\" => \"Buy 1 share of AAPL\"\n        }\n      ]\n    }\n\n    @plaid_account.update!(raw_investments_payload: test_investments_payload)\n\n    @security_resolver.stubs(:resolve).returns(OpenStruct.new(\n      security: securities(:aapl)\n    ))\n\n    processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)\n\n    assert_difference [ \"Entry.count\", \"Trade.count\" ], 1 do\n      processor.process\n    end\n\n    entry = Entry.order(created_at: :desc).first\n\n    assert_equal 100, entry.amount\n    assert_equal \"USD\", entry.currency\n    assert_equal Date.current, entry.date\n    assert_equal \"Buy 1 share of AAPL\", entry.name\n  end\n\n  test \"creates cash transactions\" do\n    test_investments_payload = {\n      transactions: [\n        {\n          \"transaction_id\" => \"123\",\n          \"type\" => \"cash\",\n          \"subtype\" => \"withdrawal\",\n          \"amount\" => 100, # Positive, so moving money OUT of the account\n          \"iso_currency_code\" => \"USD\",\n          \"date\" => Date.current,\n          \"name\" => \"Withdrawal\"\n        }\n      ]\n    }\n\n    @plaid_account.update!(raw_investments_payload: test_investments_payload)\n\n    @security_resolver.expects(:resolve).never # Cash transactions don't have a security\n\n    processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)\n\n    assert_difference [ \"Entry.count\", \"Transaction.count\" ], 1 do\n      processor.process\n    end\n\n    entry = Entry.order(created_at: :desc).first\n\n    assert_equal 100, entry.amount\n    assert_equal \"USD\", entry.currency\n    assert_equal Date.current, entry.date\n    assert_equal \"Withdrawal\", entry.name\n  end\n\n  test \"creates fee transactions\" do\n    test_investments_payload = {\n      transactions: [\n        {\n          \"transaction_id\" => \"123\",\n          \"type\" => \"fee\",\n          \"subtype\" => \"miscellaneous fee\",\n          \"amount\" => 10.25,\n          \"iso_currency_code\" => \"USD\",\n          \"date\" => Date.current,\n          \"name\" => \"Miscellaneous fee\"\n        }\n      ]\n    }\n\n    @plaid_account.update!(raw_investments_payload: test_investments_payload)\n\n    @security_resolver.expects(:resolve).never # Cash transactions don't have a security\n\n    processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)\n\n    assert_difference [ \"Entry.count\", \"Transaction.count\" ], 1 do\n      processor.process\n    end\n\n    entry = Entry.order(created_at: :desc).first\n\n    assert_equal 10.25, entry.amount\n    assert_equal \"USD\", entry.currency\n    assert_equal Date.current, entry.date\n    assert_equal \"Miscellaneous fee\", entry.name\n  end\n\n  test \"handles bad plaid quantity signage data\" do\n    test_investments_payload = {\n      transactions: [\n        {\n          \"transaction_id\" => \"123\",\n          \"type\" => \"sell\", # Correct type\n          \"subtype\" => \"sell\", # Correct subtype\n          \"quantity\" => 1, # ***Incorrect signage***, this should be negative\n          \"price\" => 100, # Correct price\n          \"amount\" => -100, # Correct amount\n          \"iso_currency_code\" => \"USD\",\n          \"date\" => Date.current,\n          \"name\" => \"Sell 1 share of AAPL\"\n        }\n      ]\n    }\n\n    @plaid_account.update!(raw_investments_payload: test_investments_payload)\n\n    @security_resolver.expects(:resolve).returns(OpenStruct.new(\n      security: securities(:aapl)\n    ))\n\n    processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)\n\n    assert_difference [ \"Entry.count\", \"Trade.count\" ], 1 do\n      processor.process\n    end\n\n    entry = Entry.order(created_at: :desc).first\n\n    assert_equal -100, entry.amount\n    assert_equal \"USD\", entry.currency\n    assert_equal Date.current, entry.date\n    assert_equal \"Sell 1 share of AAPL\", entry.name\n\n    assert_equal -1, entry.trade.qty\n  end\n\n  test \"creates transfer transactions as cash transactions\" do\n    test_investments_payload = {\n      transactions: [\n        {\n          \"investment_transaction_id\" => \"123\",\n          \"type\" => \"transfer\",\n          \"amount\" => -100.0,\n          \"iso_currency_code\" => \"USD\",\n          \"date\" => Date.current,\n          \"name\" => \"Bank Transfer\"\n        }\n      ]\n    }\n\n    @plaid_account.update!(raw_investments_payload: test_investments_payload)\n\n    @security_resolver.expects(:resolve).never\n\n    processor = PlaidAccount::Investments::TransactionsProcessor.new(@plaid_account, security_resolver: @security_resolver)\n\n    assert_difference [ \"Entry.count\", \"Transaction.count\" ], 1 do\n      processor.process\n    end\n\n    entry = Entry.order(created_at: :desc).first\n\n    assert_equal -100.0, entry.amount\n    assert_equal \"USD\", entry.currency\n    assert_equal Date.current, entry.date\n    assert_equal \"Bank Transfer\", entry.name\n    assert_instance_of Transaction, entry.entryable\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_account/liabilities/credit_processor_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidAccount::Liabilities::CreditProcessorTest < ActiveSupport::TestCase\n  setup do\n    @plaid_account = plaid_accounts(:one)\n    @plaid_account.update!(\n      plaid_type: \"credit\",\n      plaid_subtype: \"credit_card\"\n    )\n\n    @plaid_account.account.update!(\n      accountable: CreditCard.new,\n    )\n  end\n\n  test \"updates credit card minimum payment and APR from Plaid data\" do\n    @plaid_account.update!(raw_liabilities_payload: {\n      credit: {\n        minimum_payment_amount: 100,\n        aprs: [ { apr_percentage: 15.0 } ]\n      }\n    })\n\n    processor = PlaidAccount::Liabilities::CreditProcessor.new(@plaid_account)\n    processor.process\n\n    assert_equal 100, @plaid_account.account.credit_card.minimum_payment\n    assert_equal 15.0, @plaid_account.account.credit_card.apr\n  end\n\n  test \"does nothing when liability data absent\" do\n    @plaid_account.update!(raw_liabilities_payload: {})\n    processor = PlaidAccount::Liabilities::CreditProcessor.new(@plaid_account)\n    processor.process\n\n    assert_nil @plaid_account.account.credit_card.minimum_payment\n    assert_nil @plaid_account.account.credit_card.apr\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_account/liabilities/mortgage_processor_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidAccount::Liabilities::MortgageProcessorTest < ActiveSupport::TestCase\n  setup do\n    @plaid_account = plaid_accounts(:one)\n    @plaid_account.update!(\n      plaid_type: \"loan\",\n      plaid_subtype: \"mortgage\"\n    )\n\n    @plaid_account.account.update!(accountable: Loan.new)\n  end\n\n  test \"updates loan interest rate and type from Plaid data\" do\n    @plaid_account.update!(raw_liabilities_payload: {\n      mortgage: {\n        interest_rate: {\n          type: \"fixed\",\n          percentage: 4.25\n        }\n      }\n    })\n\n    processor = PlaidAccount::Liabilities::MortgageProcessor.new(@plaid_account)\n    processor.process\n\n    loan = @plaid_account.account.loan\n\n    assert_equal \"fixed\", loan.rate_type\n    assert_equal 4.25, loan.interest_rate\n  end\n\n  test \"does nothing when mortgage data absent\" do\n    @plaid_account.update!(raw_liabilities_payload: {})\n\n    processor = PlaidAccount::Liabilities::MortgageProcessor.new(@plaid_account)\n    processor.process\n\n    loan = @plaid_account.account.loan\n\n    assert_nil loan.rate_type\n    assert_nil loan.interest_rate\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_account/liabilities/student_loan_processor_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidAccount::Liabilities::StudentLoanProcessorTest < ActiveSupport::TestCase\n  setup do\n    @plaid_account = plaid_accounts(:one)\n    @plaid_account.update!(\n      plaid_type: \"loan\",\n      plaid_subtype: \"student\"\n    )\n\n    # Change the underlying accountable to a Loan so the helper method `loan` is available\n    @plaid_account.account.update!(accountable: Loan.new)\n  end\n\n  test \"updates loan details including term months from Plaid data\" do\n    @plaid_account.update!(raw_liabilities_payload: {\n      student: {\n        interest_rate_percentage: 5.5,\n        origination_principal_amount: 20000,\n        origination_date: Date.new(2020, 1, 1),\n        expected_payoff_date: Date.new(2022, 1, 1)\n      }\n    })\n\n    processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)\n    processor.process\n\n    loan = @plaid_account.account.loan\n\n    assert_equal \"fixed\", loan.rate_type\n    assert_equal 5.5, loan.interest_rate\n    assert_equal 20000, loan.initial_balance\n    assert_equal 24, loan.term_months\n  end\n\n  test \"handles missing payoff dates gracefully\" do\n    @plaid_account.update!(raw_liabilities_payload: {\n      student: {\n        interest_rate_percentage: 4.8,\n        origination_principal_amount: 15000,\n        origination_date: Date.new(2021, 6, 1)\n        # expected_payoff_date omitted\n      }\n    })\n\n    processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)\n    processor.process\n\n    loan = @plaid_account.account.loan\n\n    assert_nil loan.term_months\n    assert_equal 4.8, loan.interest_rate\n    assert_equal 15000, loan.initial_balance\n  end\n\n  test \"does nothing when loan data absent\" do\n    @plaid_account.update!(raw_liabilities_payload: {})\n\n    processor = PlaidAccount::Liabilities::StudentLoanProcessor.new(@plaid_account)\n    processor.process\n\n    loan = @plaid_account.account.loan\n\n    assert_nil loan.interest_rate\n    assert_nil loan.initial_balance\n    assert_nil loan.term_months\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_account/processor_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidAccount::ProcessorTest < ActiveSupport::TestCase\n  setup do\n    @plaid_account = plaid_accounts(:one)\n  end\n\n  test \"processes new account and assigns attributes\" do\n    Account.destroy_all # Clear out internal accounts so we start fresh\n\n    expect_default_subprocessor_calls\n\n    @plaid_account.update!(\n      plaid_id: \"test_plaid_id\",\n      plaid_type: \"depository\",\n      plaid_subtype: \"checking\",\n      current_balance: 1000,\n      available_balance: 1000,\n      currency: \"USD\",\n      name: \"Test Plaid Account\",\n      mask: \"1234\"\n    )\n\n    assert_difference \"Account.count\" do\n      PlaidAccount::Processor.new(@plaid_account).process\n    end\n\n    @plaid_account.reload\n\n    account = Account.order(created_at: :desc).first\n    assert_equal \"Test Plaid Account\", account.name\n    assert_equal @plaid_account.id, account.plaid_account_id\n    assert_equal \"checking\", account.subtype\n    assert_equal 1000, account.balance\n    assert_equal 1000, account.cash_balance\n    assert_equal \"USD\", account.currency\n    assert_equal \"Depository\", account.accountable_type\n    assert_equal \"checking\", account.subtype\n  end\n\n  test \"processing is idempotent with updates and enrichments\" do\n    expect_default_subprocessor_calls\n\n    assert_equal \"Plaid Depository Account\", @plaid_account.account.name\n    assert_equal \"checking\", @plaid_account.account.subtype\n\n    @plaid_account.account.update!(\n      name: \"User updated name\",\n      subtype: \"savings\",\n      balance: 2000 # User cannot override balance.  This will be overridden by the processor on next processing\n    )\n\n    @plaid_account.account.lock_attr!(:name)\n    @plaid_account.account.lock_attr!(:subtype)\n    @plaid_account.account.lock_attr!(:balance) # Even if balance somehow becomes locked, Plaid ignores it and overrides it\n\n    assert_no_difference \"Account.count\" do\n      PlaidAccount::Processor.new(@plaid_account).process\n    end\n\n    @plaid_account.reload\n\n    assert_equal \"User updated name\", @plaid_account.account.name\n    assert_equal \"savings\", @plaid_account.account.subtype\n    assert_equal @plaid_account.current_balance, @plaid_account.account.balance # Overriden by processor\n  end\n\n  test \"account processing failure halts further processing\" do\n    Account.any_instance.stubs(:save!).raises(StandardError.new(\"Test error\"))\n\n    PlaidAccount::Transactions::Processor.any_instance.expects(:process).never\n    PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).never\n    PlaidAccount::Investments::HoldingsProcessor.any_instance.expects(:process).never\n\n    expect_no_investment_balance_calculator_calls\n    expect_no_liability_processor_calls\n\n    assert_raises(StandardError) do\n      PlaidAccount::Processor.new(@plaid_account).process\n    end\n  end\n\n  test \"product processing failure reports exception and continues processing\" do\n    PlaidAccount::Transactions::Processor.any_instance.stubs(:process).raises(StandardError.new(\"Test error\"))\n\n    # Subsequent product processors still run\n    expect_investment_product_processor_calls\n\n    assert_nothing_raised do\n      PlaidAccount::Processor.new(@plaid_account).process\n    end\n  end\n\n  test \"calculates balance using BalanceCalculator for investment accounts\" do\n    @plaid_account.update!(plaid_type: \"investment\")\n\n    # Balance is called twice: once for account.balance and once for set_current_balance\n    PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).returns(1000).twice\n    PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:cash_balance).returns(1000).once\n\n    PlaidAccount::Processor.new(@plaid_account).process\n\n    # Verify that the balance was set correctly\n    account = @plaid_account.account\n    assert_equal 1000, account.balance\n    assert_equal 1000, account.cash_balance\n\n    # Verify current balance anchor was created with correct value\n    current_anchor = account.valuations.current_anchor.first\n    assert_not_nil current_anchor\n    assert_equal 1000, current_anchor.entry.amount\n  end\n\n  test \"processes credit liability data\" do\n    expect_investment_product_processor_calls\n    expect_no_investment_balance_calculator_calls\n    expect_depository_product_processor_calls\n\n    @plaid_account.update!(plaid_type: \"credit\", plaid_subtype: \"credit card\")\n\n    PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).once\n    PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).never\n    PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).never\n\n    PlaidAccount::Processor.new(@plaid_account).process\n  end\n\n  test \"processes mortgage liability data\" do\n    expect_investment_product_processor_calls\n    expect_no_investment_balance_calculator_calls\n    expect_depository_product_processor_calls\n\n    @plaid_account.update!(plaid_type: \"loan\", plaid_subtype: \"mortgage\")\n\n    PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).never\n    PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).once\n    PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).never\n\n    PlaidAccount::Processor.new(@plaid_account).process\n  end\n\n  test \"processes student loan liability data\" do\n    expect_investment_product_processor_calls\n    expect_no_investment_balance_calculator_calls\n    expect_depository_product_processor_calls\n\n    @plaid_account.update!(plaid_type: \"loan\", plaid_subtype: \"student\")\n\n    PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).never\n    PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).never\n    PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).once\n\n    PlaidAccount::Processor.new(@plaid_account).process\n  end\n\n  test \"creates current balance anchor when processing account\" do\n    expect_default_subprocessor_calls\n\n    # Clear out accounts to start fresh\n    Account.destroy_all\n\n    @plaid_account.update!(\n      plaid_id: \"test_plaid_id\",\n      plaid_type: \"depository\",\n      plaid_subtype: \"checking\",\n      current_balance: 1500,\n      available_balance: 1500,\n      currency: \"USD\",\n      name: \"Test Account with Anchor\",\n      mask: \"1234\"\n    )\n\n    assert_difference \"Account.count\", 1 do\n      assert_difference \"Entry.count\", 1 do\n        assert_difference \"Valuation.count\", 1 do\n          PlaidAccount::Processor.new(@plaid_account).process\n        end\n      end\n    end\n\n    account = Account.order(created_at: :desc).first\n    assert_equal 1500, account.balance\n\n    # Verify current balance anchor was created\n    current_anchor = account.valuations.current_anchor.first\n    assert_not_nil current_anchor\n    assert_equal \"current_anchor\", current_anchor.kind\n    assert_equal 1500, current_anchor.entry.amount\n    assert_equal Date.current, current_anchor.entry.date\n    assert_equal \"Current balance\", current_anchor.entry.name\n  end\n\n  test \"updates existing current balance anchor when reprocessing\" do\n    # First process creates the account and anchor\n    expect_default_subprocessor_calls\n    PlaidAccount::Processor.new(@plaid_account).process\n\n    account = @plaid_account.account\n    original_anchor = account.valuations.current_anchor.first\n    assert_not_nil original_anchor\n    original_anchor_id = original_anchor.id\n    original_entry_id = original_anchor.entry.id\n    original_balance = original_anchor.entry.amount\n\n    # Update the plaid account balance\n    @plaid_account.update!(current_balance: 2500)\n\n    # Expect subprocessor calls again for the second processing\n    expect_default_subprocessor_calls\n\n    # Reprocess should update the existing anchor\n    assert_no_difference \"Valuation.count\" do\n      assert_no_difference \"Entry.count\" do\n        PlaidAccount::Processor.new(@plaid_account).process\n      end\n    end\n\n    # Verify the anchor was updated\n    original_anchor.reload\n    assert_equal original_anchor_id, original_anchor.id\n    assert_equal original_entry_id, original_anchor.entry.id\n    assert_equal 2500, original_anchor.entry.amount\n    assert_not_equal original_balance, original_anchor.entry.amount\n  end\n\n  private\n    def expect_investment_product_processor_calls\n      PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).once\n      PlaidAccount::Investments::HoldingsProcessor.any_instance.expects(:process).once\n    end\n\n    def expect_depository_product_processor_calls\n      PlaidAccount::Transactions::Processor.any_instance.expects(:process).once\n    end\n\n    def expect_no_investment_balance_calculator_calls\n      PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).never\n      PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:cash_balance).never\n    end\n\n    def expect_no_liability_processor_calls\n      PlaidAccount::Liabilities::CreditProcessor.any_instance.expects(:process).never\n      PlaidAccount::Liabilities::MortgageProcessor.any_instance.expects(:process).never\n      PlaidAccount::Liabilities::StudentLoanProcessor.any_instance.expects(:process).never\n    end\n\n    def expect_default_subprocessor_calls\n      expect_depository_product_processor_calls\n      expect_investment_product_processor_calls\n      expect_no_investment_balance_calculator_calls\n      expect_no_liability_processor_calls\n    end\nend\n"
  },
  {
    "path": "test/models/plaid_account/transactions/category_matcher_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidAccount::Transactions::CategoryMatcherTest < ActiveSupport::TestCase\n  setup do\n    @family = families(:empty)\n\n    # User income categories\n    @income = @family.categories.create!(name: \"Income\", classification: \"income\")\n    @dividend_income = @family.categories.create!(name: \"Dividend Income\", parent: @income, classification: \"income\")\n    @interest_income = @family.categories.create!(name: \"Interest Income\", parent: @income, classification: \"income\")\n\n    # User expense categories\n    @loan_payments = @family.categories.create!(name: \"Loan Payments\")\n    @fees = @family.categories.create!(name: \"Fees\")\n    @entertainment = @family.categories.create!(name: \"Entertainment\")\n\n    @food_and_drink = @family.categories.create!(name: \"Food & Drink\")\n    @groceries = @family.categories.create!(name: \"Groceries\", parent: @food_and_drink)\n    @restaurant = @family.categories.create!(name: \"Restaurant\", parent: @food_and_drink)\n\n    @shopping = @family.categories.create!(name: \"Shopping\")\n    @clothing = @family.categories.create!(name: \"Clothing\", parent: @shopping)\n\n    @home = @family.categories.create!(name: \"Home\")\n    @medical = @family.categories.create!(name: \"Medical\")\n    @personal_care = @family.categories.create!(name: \"Personal Care\")\n    @transportation = @family.categories.create!(name: \"Transportation\")\n    @trips = @family.categories.create!(name: \"Trips\")\n\n    @services = @family.categories.create!(name: \"Services\")\n    @car = @family.categories.create!(name: \"Car\", parent: @services)\n\n    @giving = @family.categories.create!(name: \"Giving\")\n\n    @matcher = PlaidAccount::Transactions::CategoryMatcher.new(@family.categories)\n  end\n\n  test \"matches expense categories\" do\n    assert_equal @loan_payments, @matcher.match(\"loan_payments_car_payment\")\n    assert_equal @loan_payments, @matcher.match(\"loan_payments_credit_card_payment\")\n    assert_equal @loan_payments, @matcher.match(\"loan_payments_personal_loan_payment\")\n    assert_equal @loan_payments, @matcher.match(\"loan_payments_mortgage_payment\")\n    assert_equal @loan_payments, @matcher.match(\"loan_payments_student_loan_payment\")\n    assert_equal @loan_payments, @matcher.match(\"loan_payments_other_payment\")\n    assert_equal @fees, @matcher.match(\"bank_fees_atm_fees\")\n    assert_equal @fees, @matcher.match(\"bank_fees_foreign_transaction_fees\")\n    assert_equal @fees, @matcher.match(\"bank_fees_insufficient_funds\")\n    assert_equal @fees, @matcher.match(\"bank_fees_interest_charge\")\n    assert_equal @fees, @matcher.match(\"bank_fees_overdraft_fees\")\n    assert_equal @fees, @matcher.match(\"bank_fees_other_bank_fees\")\n    assert_equal @entertainment, @matcher.match(\"entertainment_casinos_and_gambling\")\n    assert_equal @entertainment, @matcher.match(\"entertainment_music_and_audio\")\n    assert_equal @entertainment, @matcher.match(\"entertainment_sporting_events_amusement_parks_and_museums\")\n    assert_equal @entertainment, @matcher.match(\"entertainment_tv_and_movies\")\n    assert_equal @entertainment, @matcher.match(\"entertainment_video_games\")\n    assert_equal @entertainment, @matcher.match(\"entertainment_other_entertainment\")\n    assert_equal @food_and_drink, @matcher.match(\"food_and_drink_beer_wine_and_liquor\")\n    assert_equal @food_and_drink, @matcher.match(\"food_and_drink_coffee\")\n    assert_equal @food_and_drink, @matcher.match(\"food_and_drink_fast_food\")\n    assert_equal @groceries, @matcher.match(\"food_and_drink_groceries\")\n    assert_equal @restaurant, @matcher.match(\"food_and_drink_restaurant\")\n    assert_equal @food_and_drink, @matcher.match(\"food_and_drink_vending_machines\")\n    assert_equal @food_and_drink, @matcher.match(\"food_and_drink_other_food_and_drink\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_bookstores_and_newsstands\")\n    assert_equal @clothing, @matcher.match(\"general_merchandise_clothing_and_accessories\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_convenience_stores\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_department_stores\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_discount_stores\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_electronics\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_gifts_and_novelties\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_office_supplies\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_online_marketplaces\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_pet_supplies\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_sporting_goods\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_superstores\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_tobacco_and_vape\")\n    assert_equal @shopping, @matcher.match(\"general_merchandise_other_general_merchandise\")\n    assert_equal @home, @matcher.match(\"home_improvement_furniture\")\n    assert_equal @home, @matcher.match(\"home_improvement_hardware\")\n    assert_equal @home, @matcher.match(\"home_improvement_repair_and_maintenance\")\n    assert_equal @home, @matcher.match(\"home_improvement_security\")\n    assert_equal @home, @matcher.match(\"home_improvement_other_home_improvement\")\n    assert_equal @medical, @matcher.match(\"medical_dental_care\")\n    assert_equal @medical, @matcher.match(\"medical_eye_care\")\n    assert_equal @medical, @matcher.match(\"medical_nursing_care\")\n    assert_equal @medical, @matcher.match(\"medical_pharmacies_and_supplements\")\n    assert_equal @medical, @matcher.match(\"medical_primary_care\")\n    assert_equal @medical, @matcher.match(\"medical_veterinary_services\")\n    assert_equal @medical, @matcher.match(\"medical_other_medical\")\n    assert_equal @personal_care, @matcher.match(\"personal_care_gyms_and_fitness_centers\")\n    assert_equal @personal_care, @matcher.match(\"personal_care_hair_and_beauty\")\n    assert_equal @personal_care, @matcher.match(\"personal_care_laundry_and_dry_cleaning\")\n    assert_equal @personal_care, @matcher.match(\"personal_care_other_personal_care\")\n    assert_equal @services, @matcher.match(\"general_services_accounting_and_financial_planning\")\n    assert_equal @car, @matcher.match(\"general_services_automotive\")\n    assert_equal @services, @matcher.match(\"general_services_childcare\")\n    assert_equal @services, @matcher.match(\"general_services_consulting_and_legal\")\n    assert_equal @services, @matcher.match(\"general_services_education\")\n    assert_equal @services, @matcher.match(\"general_services_insurance\")\n    assert_equal @services, @matcher.match(\"general_services_postage_and_shipping\")\n    assert_equal @services, @matcher.match(\"general_services_storage\")\n    assert_equal @services, @matcher.match(\"general_services_other_general_services\")\n    assert_equal @giving, @matcher.match(\"government_and_non_profit_donations\")\n    assert_nil @matcher.match(\"government_and_non_profit_government_departments_and_agencies\")\n    assert_nil @matcher.match(\"government_and_non_profit_tax_payment\")\n    assert_nil @matcher.match(\"government_and_non_profit_other_government_and_non_profit\")\n    assert_equal @transportation, @matcher.match(\"transportation_bikes_and_scooters\")\n    assert_equal @transportation, @matcher.match(\"transportation_gas\")\n    assert_equal @transportation, @matcher.match(\"transportation_parking\")\n    assert_equal @transportation, @matcher.match(\"transportation_public_transit\")\n    assert_equal @transportation, @matcher.match(\"transportation_taxis_and_ride_shares\")\n    assert_equal @transportation, @matcher.match(\"transportation_tolls\")\n    assert_equal @transportation, @matcher.match(\"transportation_other_transportation\")\n    assert_equal @trips, @matcher.match(\"travel_flights\")\n    assert_equal @trips, @matcher.match(\"travel_lodging\")\n    assert_equal @trips, @matcher.match(\"travel_rental_cars\")\n    assert_equal @trips, @matcher.match(\"travel_other_travel\")\n    assert_equal @home, @matcher.match(\"rent_and_utilities_gas_and_electricity\")\n    assert_equal @home, @matcher.match(\"rent_and_utilities_internet_and_cable\")\n    assert_equal @home, @matcher.match(\"rent_and_utilities_rent\")\n    assert_equal @home, @matcher.match(\"rent_and_utilities_sewage_and_waste_management\")\n    assert_equal @home, @matcher.match(\"rent_and_utilities_telephone\")\n    assert_equal @home, @matcher.match(\"rent_and_utilities_water\")\n    assert_equal @home, @matcher.match(\"rent_and_utilities_other_utilities\")\n  end\n\n  test \"matches income categories\" do\n    assert_equal @dividend_income, @matcher.match(\"income_dividends\")\n    assert_equal @interest_income, @matcher.match(\"income_interest_earned\")\n    assert_equal @income, @matcher.match(\"income_tax_refund\")\n    assert_equal @income, @matcher.match(\"income_retirement_pension\")\n    assert_equal @income, @matcher.match(\"income_unemployment\")\n    assert_equal @income, @matcher.match(\"income_wages\")\n    assert_equal @income, @matcher.match(\"income_other_income\")\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_account/transactions/processor_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidAccount::Transactions::ProcessorTest < ActiveSupport::TestCase\n  setup do\n    @plaid_account = plaid_accounts(:one)\n  end\n\n  test \"processes added and modified plaid transactions\" do\n    added_transactions = [ { \"transaction_id\" => \"123\" } ]\n    modified_transactions = [ { \"transaction_id\" => \"456\" } ]\n\n    @plaid_account.update!(raw_transactions_payload: {\n      added: added_transactions,\n      modified: modified_transactions,\n      removed: []\n    })\n\n    mock_processor = mock(\"PlaidEntry::Processor\")\n    category_matcher_mock = mock(\"PlaidAccount::Transactions::CategoryMatcher\")\n\n    PlaidAccount::Transactions::CategoryMatcher.stubs(:new).returns(category_matcher_mock)\n    PlaidEntry::Processor.expects(:new)\n                         .with(added_transactions.first, plaid_account: @plaid_account, category_matcher: category_matcher_mock)\n                         .returns(mock_processor)\n                         .once\n\n    PlaidEntry::Processor.expects(:new)\n                         .with(modified_transactions.first, plaid_account: @plaid_account, category_matcher: category_matcher_mock)\n                         .returns(mock_processor)\n                         .once\n\n    mock_processor.expects(:process).twice\n\n    processor = PlaidAccount::Transactions::Processor.new(@plaid_account)\n    processor.process\n  end\n\n  test \"removes transactions no longer in plaid\" do\n    destroyable_transaction_id = \"destroy_me\"\n    @plaid_account.account.entries.create!(\n      plaid_id: destroyable_transaction_id,\n      date: Date.current,\n      amount: 100,\n      name: \"Destroy me\",\n      currency: \"USD\",\n      entryable: Transaction.new\n    )\n\n    @plaid_account.update!(raw_transactions_payload: {\n      added: [],\n      modified: [],\n      removed: [ { \"transaction_id\" => destroyable_transaction_id } ]\n    })\n\n    processor = PlaidAccount::Transactions::Processor.new(@plaid_account)\n\n    assert_difference [ \"Entry.count\", \"Transaction.count\" ], -1 do\n      processor.process\n    end\n\n    assert_nil Entry.find_by(plaid_id: destroyable_transaction_id)\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_account/type_mappable_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidAccount::TypeMappableTest < ActiveSupport::TestCase\n  setup do\n    class MockProcessor\n      include PlaidAccount::TypeMappable\n    end\n\n    @mock_processor = MockProcessor.new\n  end\n\n  test \"maps types to accountables\" do\n    assert_instance_of Depository, @mock_processor.map_accountable(\"depository\")\n    assert_instance_of Investment, @mock_processor.map_accountable(\"investment\")\n    assert_instance_of CreditCard, @mock_processor.map_accountable(\"credit\")\n    assert_instance_of Loan, @mock_processor.map_accountable(\"loan\")\n    assert_instance_of OtherAsset, @mock_processor.map_accountable(\"other\")\n  end\n\n  test \"maps subtypes\" do\n    assert_equal \"checking\", @mock_processor.map_subtype(\"depository\", \"checking\")\n    assert_equal \"roth_ira\", @mock_processor.map_subtype(\"investment\", \"roth\")\n  end\n\n  test \"raises on invalid types\" do\n    assert_raises PlaidAccount::TypeMappable::UnknownAccountTypeError do\n      @mock_processor.map_accountable(\"unknown\")\n    end\n  end\n\n  test \"handles nil subtypes\" do\n    assert_equal \"other\", @mock_processor.map_subtype(\"depository\", nil)\n    assert_equal \"other\", @mock_processor.map_subtype(\"depository\", \"unknown\")\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_entry/processor_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidEntry::ProcessorTest < ActiveSupport::TestCase\n  setup do\n    @plaid_account = plaid_accounts(:one)\n    @category_matcher = mock(\"PlaidAccount::Transactions::CategoryMatcher\")\n  end\n\n  test \"creates new entry transaction\" do\n    plaid_transaction = {\n      \"transaction_id\" => \"123\",\n      \"merchant_name\" => \"Amazon\", # this is used for merchant and entry name\n      \"amount\" => 100,\n      \"date\" => Date.current,\n      \"iso_currency_code\" => \"USD\",\n      \"personal_finance_category\" => {\n        \"detailed\" => \"Food\"\n      },\n      \"merchant_entity_id\" => \"123\"\n    }\n\n    @category_matcher.expects(:match).with(\"Food\").returns(categories(:food_and_drink))\n\n    processor = PlaidEntry::Processor.new(\n      plaid_transaction,\n      plaid_account: @plaid_account,\n      category_matcher: @category_matcher\n    )\n\n    assert_difference [ \"Entry.count\", \"Transaction.count\", \"ProviderMerchant.count\" ], 1 do\n      processor.process\n    end\n\n    entry = Entry.order(created_at: :desc).first\n\n    assert_equal 100, entry.amount\n    assert_equal \"USD\", entry.currency\n    assert_equal Date.current, entry.date\n    assert_equal \"Amazon\", entry.name\n    assert_equal categories(:food_and_drink).id, entry.transaction.category_id\n\n    provider_merchant = ProviderMerchant.order(created_at: :desc).first\n\n    assert_equal \"Amazon\", provider_merchant.name\n  end\n\n  test \"updates existing entry transaction\" do\n    existing_plaid_id = \"existing_plaid_id\"\n\n    plaid_transaction = {\n      \"transaction_id\" => existing_plaid_id,\n      \"merchant_name\" => \"Amazon\", # this is used for merchant and entry name\n      \"amount\" => 200, # Changed amount will be updated\n      \"date\" => 1.day.ago.to_date, # Changed date will be updated\n      \"iso_currency_code\" => \"USD\",\n      \"personal_finance_category\" => {\n        \"detailed\" => \"Food\"\n      }\n    }\n\n    @category_matcher.expects(:match).with(\"Food\").returns(categories(:food_and_drink))\n\n    # Create an existing entry\n    @plaid_account.account.entries.create!(\n      plaid_id: existing_plaid_id,\n      amount: 100,\n      currency: \"USD\",\n      date: Date.current,\n      name: \"Amazon\",\n      entryable: Transaction.new\n    )\n\n    processor = PlaidEntry::Processor.new(\n      plaid_transaction,\n      plaid_account: @plaid_account,\n      category_matcher: @category_matcher\n    )\n\n    assert_no_difference [ \"Entry.count\", \"Transaction.count\", \"ProviderMerchant.count\" ] do\n      processor.process\n    end\n\n    entry = Entry.order(created_at: :desc).first\n\n    assert_equal 200, entry.amount\n    assert_equal \"USD\", entry.currency\n    assert_equal 1.day.ago.to_date, entry.date\n    assert_equal \"Amazon\", entry.name\n    assert_equal categories(:food_and_drink).id, entry.transaction.category_id\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_item/accounts_snapshot_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidItem::AccountsSnapshotTest < ActiveSupport::TestCase\n  setup do\n    @plaid_item = plaid_items(:one)\n    @plaid_item.plaid_accounts.destroy_all # Clean slate\n\n    @plaid_provider = mock\n    @snapshot = PlaidItem::AccountsSnapshot.new(@plaid_item, plaid_provider: @plaid_provider)\n  end\n\n  test \"fetches accounts\" do\n    @plaid_provider.expects(:get_item_accounts).with(@plaid_item.access_token).returns(\n      OpenStruct.new(accounts: [])\n    )\n    @snapshot.accounts\n  end\n\n  test \"fetches transactions data if item supports transactions and any accounts present\" do\n    @plaid_item.update!(available_products: [ \"transactions\" ], billed_products: [])\n\n    @snapshot.expects(:accounts).returns([\n      OpenStruct.new(\n        account_id: \"123\",\n        type: \"depository\"\n      )\n    ]).at_least_once\n\n    @plaid_provider.expects(:get_transactions).with(@plaid_item.access_token, next_cursor: nil).returns(\n      OpenStruct.new(\n        added: [],\n        modified: [],\n        removed: [],\n        cursor: \"test_cursor_1\"\n      )\n    ).once\n    @plaid_provider.expects(:get_item_investments).never\n    @plaid_provider.expects(:get_item_liabilities).never\n\n    @snapshot.get_account_data(\"123\")\n  end\n\n  test \"does not fetch transactions if no accounts\" do\n    @plaid_item.update!(available_products: [ \"transactions\" ], billed_products: [])\n\n    @snapshot.expects(:accounts).returns([]).at_least_once\n\n    @plaid_provider.expects(:get_transactions).never\n    @plaid_provider.expects(:get_item_investments).never\n    @plaid_provider.expects(:get_item_liabilities).never\n\n    @snapshot.get_account_data(\"123\")\n  end\n\n  test \"updates next_cursor when fetching transactions\" do\n    @plaid_item.update!(available_products: [ \"transactions\" ], billed_products: [], next_cursor: \"test_cursor_1\")\n\n    @snapshot.expects(:accounts).returns([\n      OpenStruct.new(\n        account_id: \"123\",\n        type: \"depository\"\n      )\n    ]).at_least_once\n\n    @plaid_provider.expects(:get_transactions).with(@plaid_item.access_token, next_cursor: \"test_cursor_1\").returns(\n      OpenStruct.new(\n        added: [],\n        modified: [],\n        removed: [],\n        cursor: \"test_cursor_2\"\n      )\n    ).once\n\n    @plaid_provider.expects(:get_item_investments).never\n    @plaid_provider.expects(:get_item_liabilities).never\n\n    @snapshot.get_account_data(\"123\")\n  end\n\n  test \"fetches investments data if item supports investments and investment accounts present\" do\n    @plaid_item.update!(available_products: [ \"investments\" ], billed_products: [])\n\n    @snapshot.expects(:accounts).returns([\n      OpenStruct.new(\n        account_id: \"123\",\n        type: \"investment\"\n      )\n    ]).at_least_once\n\n    @plaid_provider.expects(:get_transactions).never\n    @plaid_provider.expects(:get_item_investments).with(@plaid_item.access_token).once\n    @plaid_provider.expects(:get_item_liabilities).never\n\n    @snapshot.get_account_data(\"123\")\n  end\n\n  test \"does not fetch investments if no investment accounts\" do\n    @plaid_item.update!(available_products: [ \"investments\" ], billed_products: [])\n\n    @snapshot.expects(:accounts).returns([]).at_least_once\n\n    @plaid_provider.expects(:get_transactions).never\n    @plaid_provider.expects(:get_item_investments).never\n    @plaid_provider.expects(:get_item_liabilities).never\n\n    @snapshot.get_account_data(\"123\")\n  end\n\n  test \"fetches liabilities data if item supports liabilities and liabilities accounts present\" do\n    @plaid_item.update!(available_products: [ \"liabilities\" ], billed_products: [])\n\n    @snapshot.expects(:accounts).returns([\n      OpenStruct.new(\n        account_id: \"123\",\n        type: \"loan\",\n        subtype: \"student\"\n      )\n    ]).at_least_once\n\n    @plaid_provider.expects(:get_transactions).never\n    @plaid_provider.expects(:get_item_investments).never\n    @plaid_provider.expects(:get_item_liabilities).with(@plaid_item.access_token).once\n\n    @snapshot.get_account_data(\"123\")\n  end\n\n  test \"does not fetch liabilities if no liabilities accounts\" do\n    @plaid_item.update!(available_products: [ \"liabilities\" ], billed_products: [])\n\n    @snapshot.expects(:accounts).returns([]).at_least_once\n\n    @plaid_provider.expects(:get_transactions).never\n    @plaid_provider.expects(:get_item_investments).never\n    @plaid_provider.expects(:get_item_liabilities).never\n\n    @snapshot.get_account_data(\"123\")\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_item/importer_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass PlaidItem::ImporterTest < ActiveSupport::TestCase\n  setup do\n    @mock_provider = mock(\"Provider::Plaid\")\n    @plaid_item = plaid_items(:one)\n    @importer = PlaidItem::Importer.new(@plaid_item, plaid_provider: @mock_provider)\n  end\n\n  test \"imports item metadata\" do\n    item_data = OpenStruct.new(\n      item_id: \"item_1\",\n      available_products: [ \"transactions\", \"investments\", \"liabilities\" ],\n      billed_products: [],\n      institution_id: \"ins_1\",\n      institution_name: \"First Platypus Bank\",\n    )\n\n    @mock_provider.expects(:get_item).with(@plaid_item.access_token).returns(\n      OpenStruct.new(item: item_data)\n    )\n\n    institution_data = OpenStruct.new(\n      institution_id: \"ins_1\",\n      institution_name: \"First Platypus Bank\",\n    )\n\n    @mock_provider.expects(:get_institution).with(\"ins_1\").returns(\n      OpenStruct.new(institution: institution_data)\n    )\n\n    PlaidItem::AccountsSnapshot.any_instance.expects(:accounts).returns([\n      OpenStruct.new(\n        account_id: \"acc_1\",\n        type: \"depository\",\n      )\n    ]).at_least_once\n\n    PlaidItem::AccountsSnapshot.any_instance.expects(:transactions_cursor).returns(\"test_cursor_1\")\n\n    PlaidItem::AccountsSnapshot.any_instance.expects(:get_account_data).with(\"acc_1\").once\n\n    PlaidAccount::Importer.any_instance.expects(:import).once\n\n    @plaid_item.expects(:update!).with(next_cursor: \"test_cursor_1\")\n    @plaid_item.expects(:upsert_plaid_snapshot!).with(item_data)\n    @plaid_item.expects(:upsert_plaid_institution_snapshot!).with(institution_data)\n\n    @importer.import\n  end\nend\n"
  },
  {
    "path": "test/models/plaid_item_test.rb",
    "content": "require \"test_helper\"\n\nclass PlaidItemTest < ActiveSupport::TestCase\n  include SyncableInterfaceTest\n\n  setup do\n    @plaid_item = @syncable = plaid_items(:one)\n    @plaid_provider = mock\n    Provider::Registry.stubs(:plaid_provider_for_region).returns(@plaid_provider)\n  end\n\n  test \"removes plaid item when destroyed\" do\n    @plaid_provider.expects(:remove_item).with(@plaid_item.access_token).once\n\n    assert_difference \"PlaidItem.count\", -1 do\n      @plaid_item.destroy\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/provider/openai_test.rb",
    "content": "require \"test_helper\"\n\nclass Provider::OpenaiTest < ActiveSupport::TestCase\n  include LLMInterfaceTest\n\n  setup do\n    @subject = @openai = Provider::Openai.new(ENV.fetch(\"OPENAI_ACCESS_TOKEN\", \"test-openai-token\"))\n    @subject_model = \"gpt-4.1\"\n  end\n\n  test \"openai errors are automatically raised\" do\n    VCR.use_cassette(\"openai/chat/error\") do\n      response = @openai.chat_response(\"Test\", model: \"invalid-model-that-will-trigger-api-error\")\n\n      assert_not response.success?\n      assert_kind_of Provider::Openai::Error, response.error\n    end\n  end\n\n  test \"auto categorizes transactions by various attributes\" do\n    VCR.use_cassette(\"openai/auto_categorize\") do\n      input_transactions = [\n        { id: \"1\", name: \"McDonalds\", amount: 20, classification: \"expense\", merchant: \"McDonalds\", hint: \"Fast Food\" },\n        { id: \"2\", name: \"Amazon purchase\", amount: 100, classification: \"expense\", merchant: \"Amazon\" },\n        { id: \"3\", name: \"Netflix subscription\", amount: 10, classification: \"expense\", merchant: \"Netflix\", hint: \"Subscriptions\" },\n        { id: \"4\", name: \"paycheck\", amount: 3000, classification: \"income\" },\n        { id: \"5\", name: \"Italian dinner with friends\", amount: 100, classification: \"expense\" },\n        { id: \"6\", name: \"1212XXXBCaaa charge\", amount: 2.99, classification: \"expense\" }\n      ]\n\n      response = @subject.auto_categorize(\n        transactions: input_transactions,\n        user_categories: [\n          { id: \"shopping_id\", name: \"Shopping\", is_subcategory: false, parent_id: nil, classification: \"expense\" },\n          { id: \"subscriptions_id\", name: \"Subscriptions\", is_subcategory: true, parent_id: nil, classification: \"expense\" },\n          { id: \"restaurants_id\", name: \"Restaurants\", is_subcategory: false, parent_id: nil, classification: \"expense\" },\n          { id: \"fast_food_id\", name: \"Fast Food\", is_subcategory: true, parent_id: \"restaurants_id\", classification: \"expense\" },\n          { id: \"income_id\", name: \"Income\", is_subcategory: false, parent_id: nil, classification: \"income\" }\n        ]\n      )\n\n      assert response.success?\n      assert_equal input_transactions.size, response.data.size\n\n      txn1 = response.data.find { |c| c.transaction_id == \"1\" }\n      txn2 = response.data.find { |c| c.transaction_id == \"2\" }\n      txn3 = response.data.find { |c| c.transaction_id == \"3\" }\n      txn4 = response.data.find { |c| c.transaction_id == \"4\" }\n      txn5 = response.data.find { |c| c.transaction_id == \"5\" }\n      txn6 = response.data.find { |c| c.transaction_id == \"6\" }\n\n      assert_equal \"Fast Food\", txn1.category_name\n      assert_equal \"Shopping\", txn2.category_name\n      assert_equal \"Subscriptions\", txn3.category_name\n      assert_equal \"Income\", txn4.category_name\n      assert_equal \"Restaurants\", txn5.category_name\n      assert_nil txn6.category_name\n    end\n  end\n\n  test \"auto detects merchants\" do\n    VCR.use_cassette(\"openai/auto_detect_merchants\") do\n      input_transactions = [\n        { id: \"1\", name: \"McDonalds\", amount: 20, classification: \"expense\" },\n        { id: \"2\", name: \"local pub\", amount: 20, classification: \"expense\" },\n        { id: \"3\", name: \"WMT purchases\", amount: 20, classification: \"expense\" },\n        { id: \"4\", name: \"amzn 123 abc\", amount: 20, classification: \"expense\" },\n        { id: \"5\", name: \"chaseX1231\", amount: 2000, classification: \"income\" },\n        { id: \"6\", name: \"check deposit 022\", amount: 200, classification: \"income\" },\n        { id: \"7\", name: \"shooters bar and grill\", amount: 200, classification: \"expense\" },\n        { id: \"8\", name: \"Microsoft Office subscription\", amount: 200, classification: \"expense\" }\n      ]\n\n      response = @subject.auto_detect_merchants(\n        transactions: input_transactions,\n        user_merchants: [ { name: \"Shooters\" } ]\n      )\n\n      assert response.success?\n      assert_equal input_transactions.size, response.data.size\n\n      txn1 = response.data.find { |c| c.transaction_id == \"1\" }\n      txn2 = response.data.find { |c| c.transaction_id == \"2\" }\n      txn3 = response.data.find { |c| c.transaction_id == \"3\" }\n      txn4 = response.data.find { |c| c.transaction_id == \"4\" }\n      txn5 = response.data.find { |c| c.transaction_id == \"5\" }\n      txn6 = response.data.find { |c| c.transaction_id == \"6\" }\n      txn7 = response.data.find { |c| c.transaction_id == \"7\" }\n      txn8 = response.data.find { |c| c.transaction_id == \"8\" }\n\n      assert_equal \"McDonald's\", txn1.business_name\n      assert_equal \"mcdonalds.com\", txn1.business_url\n\n      assert_nil txn2.business_name\n      assert_nil txn2.business_url\n\n      assert_equal \"Walmart\", txn3.business_name\n      assert_equal \"walmart.com\", txn3.business_url\n\n      assert_equal \"Amazon\", txn4.business_name\n      assert_equal \"amazon.com\", txn4.business_url\n\n      assert_nil txn5.business_name\n      assert_nil txn5.business_url\n\n      assert_nil txn6.business_name\n      assert_nil txn6.business_url\n\n      assert_equal \"Shooters\", txn7.business_name\n      assert_nil txn7.business_url\n\n      assert_equal \"Microsoft\", txn8.business_name\n      assert_equal \"microsoft.com\", txn8.business_url\n    end\n  end\n\n  test \"basic chat response\" do\n    VCR.use_cassette(\"openai/chat/basic_response\") do\n      response = @subject.chat_response(\n        \"This is a chat test.  If it's working, respond with a single word: Yes\",\n        model: @subject_model\n      )\n\n      assert response.success?\n      assert_equal 1, response.data.messages.size\n      assert_includes response.data.messages.first.output_text, \"Yes\"\n    end\n  end\n\n  test \"streams basic chat response\" do\n    VCR.use_cassette(\"openai/chat/basic_streaming_response\") do\n      collected_chunks = []\n\n      mock_streamer = proc do |chunk|\n        collected_chunks << chunk\n      end\n\n      response = @subject.chat_response(\n        \"This is a chat test.  If it's working, respond with a single word: Yes\",\n        model: @subject_model,\n        streamer: mock_streamer\n      )\n\n      text_chunks = collected_chunks.select { |chunk| chunk.type == \"output_text\" }\n      response_chunks = collected_chunks.select { |chunk| chunk.type == \"response\" }\n\n      assert_equal 1, text_chunks.size\n      assert_equal 1, response_chunks.size\n      assert_equal \"Yes\", text_chunks.first.data\n      assert_equal \"Yes\", response_chunks.first.data.messages.first.output_text\n      assert_equal response_chunks.first.data, response.data\n    end\n  end\n\n  test \"chat response with function calls\" do\n    VCR.use_cassette(\"openai/chat/function_calls\") do\n      prompt = \"What is my net worth?\"\n\n      functions = [\n        {\n          name: \"get_net_worth\",\n          description: \"Gets a user's net worth\",\n          params_schema: { type: \"object\", properties: {}, required: [], additionalProperties: false },\n          strict: true\n        }\n      ]\n\n      first_response = @subject.chat_response(\n        prompt,\n        model: @subject_model,\n        instructions: \"Use the tools available to you to answer the user's question.\",\n        functions: functions\n      )\n\n      assert first_response.success?\n\n      function_request = first_response.data.function_requests.first\n\n      assert function_request.present?\n\n      second_response = @subject.chat_response(\n        prompt,\n        model: @subject_model,\n        function_results: [ {\n          call_id: function_request.call_id,\n          output: { amount: 10000, currency: \"USD\" }.to_json\n        } ],\n        previous_response_id: first_response.data.id\n      )\n\n      assert second_response.success?\n      assert_equal 1, second_response.data.messages.size\n      assert_includes second_response.data.messages.first.output_text, \"$10,000\"\n    end\n  end\n\n  test \"streams chat response with function calls\" do\n    VCR.use_cassette(\"openai/chat/streaming_function_calls\") do\n      collected_chunks = []\n\n      mock_streamer = proc do |chunk|\n        collected_chunks << chunk\n      end\n\n      prompt = \"What is my net worth?\"\n\n      functions = [\n        {\n          name: \"get_net_worth\",\n          description: \"Gets a user's net worth\",\n          params_schema: { type: \"object\", properties: {}, required: [], additionalProperties: false },\n          strict: true\n        }\n      ]\n\n      # Call #1: First streaming call, will return a function request\n      @subject.chat_response(\n        prompt,\n        model: @subject_model,\n        instructions: \"Use the tools available to you to answer the user's question.\",\n        functions: functions,\n        streamer: mock_streamer\n      )\n\n      text_chunks = collected_chunks.select { |chunk| chunk.type == \"output_text\" }\n      response_chunks = collected_chunks.select { |chunk| chunk.type == \"response\" }\n\n      assert_equal 0, text_chunks.size\n      assert_equal 1, response_chunks.size\n\n      first_response = response_chunks.first.data\n      function_request = first_response.function_requests.first\n\n      # Reset collected chunks for the second call\n      collected_chunks = []\n\n      # Call #2: Second streaming call, will return a function result\n      @subject.chat_response(\n        prompt,\n        model: @subject_model,\n        function_results: [\n          {\n            call_id: function_request.call_id,\n            output: { amount: 10000, currency: \"USD\" }\n          }\n        ],\n        previous_response_id: first_response.id,\n        streamer: mock_streamer\n      )\n\n      text_chunks = collected_chunks.select { |chunk| chunk.type == \"output_text\" }\n      response_chunks = collected_chunks.select { |chunk| chunk.type == \"response\" }\n\n      assert text_chunks.size >= 1\n      assert_equal 1, response_chunks.size\n\n      assert_includes response_chunks.first.data.messages.first.output_text, \"$10,000\"\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/provider/plaid_test.rb",
    "content": "require \"test_helper\"\n\nclass Provider::PlaidTest < ActiveSupport::TestCase\n  setup do\n    # Do not change, this is whitelisted in the Plaid Dashboard for local dev\n    @redirect_url = \"http://localhost:3000/accounts\"\n\n    # A specialization of Plaid client with sandbox-only extensions\n    @plaid = Provider::PlaidSandbox.new\n  end\n\n  test \"gets link token\" do\n    VCR.use_cassette(\"plaid/link_token\") do\n      link_token = @plaid.get_link_token(\n        user_id: \"test-user-id\",\n        webhooks_url: \"https://example.com/webhooks\",\n        redirect_url: @redirect_url\n      )\n\n      assert_match /link-sandbox-.*/, link_token.link_token\n    end\n  end\n\n  test \"exchanges public token\" do\n    VCR.use_cassette(\"plaid/exchange_public_token\") do\n      public_token = @plaid.create_public_token\n      exchange_response = @plaid.exchange_public_token(public_token)\n\n      assert_match /access-sandbox-.*/, exchange_response.access_token\n    end\n  end\n\n  test \"gets item\" do\n    VCR.use_cassette(\"plaid/get_item\") do\n      access_token = get_access_token\n      item = @plaid.get_item(access_token).item\n\n      assert_equal \"ins_109508\", item.institution_id\n      assert_equal \"First Platypus Bank\", item.institution_name\n    end\n  end\n\n  test \"gets item accounts\" do\n    VCR.use_cassette(\"plaid/get_item_accounts\") do\n      access_token = get_access_token\n      accounts_response = @plaid.get_item_accounts(access_token)\n\n      assert_equal 4, accounts_response.accounts.size\n    end\n  end\n\n  test \"gets item investments\" do\n    VCR.use_cassette(\"plaid/get_item_investments\") do\n      access_token = get_access_token\n      investments_response = @plaid.get_item_investments(access_token)\n\n      assert_equal 3, investments_response.holdings.size\n      assert_equal 4, investments_response.transactions.size\n    end\n  end\n\n  test \"gets item liabilities\" do\n    VCR.use_cassette(\"plaid/get_item_liabilities\") do\n      access_token = get_access_token\n      liabilities_response = @plaid.get_item_liabilities(access_token)\n\n      assert liabilities_response.credit.count > 0\n      assert liabilities_response.student.count > 0\n    end\n  end\n\n  private\n    def get_access_token\n      VCR.use_cassette(\"plaid/access_token\") do\n        public_token = @plaid.create_public_token\n        exchange_response = @plaid.exchange_public_token(public_token)\n        exchange_response.access_token\n      end\n    end\nend\n"
  },
  {
    "path": "test/models/provider/registry_test.rb",
    "content": "require \"test_helper\"\n\nclass Provider::RegistryTest < ActiveSupport::TestCase\n  test \"synth configured with ENV\" do\n    Setting.stubs(:synth_api_key).returns(nil)\n\n    with_env_overrides SYNTH_API_KEY: \"123\" do\n      assert_instance_of Provider::Synth, Provider::Registry.get_provider(:synth)\n    end\n  end\n\n  test \"synth configured with Setting\" do\n    Setting.stubs(:synth_api_key).returns(\"123\")\n\n    with_env_overrides SYNTH_API_KEY: nil do\n      assert_instance_of Provider::Synth, Provider::Registry.get_provider(:synth)\n    end\n  end\n\n  test \"synth not configured\" do\n    Setting.stubs(:synth_api_key).returns(nil)\n\n    with_env_overrides SYNTH_API_KEY: nil do\n      assert_nil Provider::Registry.get_provider(:synth)\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/provider/stripe/subscription_event_processor_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass Provider::Stripe::SubscriptionEventProcessorTest < ActiveSupport::TestCase\n  test \"handles subscription event\" do\n    test_customer_id = \"test-customer-id\"\n    test_subscription_id = \"test-subscription-id\"\n\n    mock_event = JSON.parse({\n      type: \"customer.subscription.created\",\n      data: {\n        object: {\n          id: test_subscription_id,\n          status: \"active\",\n          customer: test_customer_id,\n          items: {\n            data: [\n              {\n                current_period_end: 1.month.from_now.to_i,\n                plan: {\n                  interval: \"month\",\n                  amount: 900,\n                  currency: \"usd\"\n                }\n              }\n            ]\n          }\n        }\n      }\n    }.to_json, object_class: OpenStruct)\n\n    family = Family.create!(\n      name: \"Test Subscribed Family\",\n      stripe_customer_id: test_customer_id\n    )\n\n    family.start_subscription!(test_subscription_id)\n\n    processor = Provider::Stripe::SubscriptionEventProcessor.new(mock_event)\n\n    assert_equal \"active\", family.subscription.status\n    assert_equal test_subscription_id, family.subscription.stripe_id\n    assert_nil family.subscription.amount\n    assert_nil family.subscription.currency\n    assert_nil family.subscription.current_period_ends_at\n\n    processor.process\n\n    family.reload\n\n    assert_equal \"active\", family.subscription.status\n    assert_equal test_subscription_id, family.subscription.stripe_id\n    assert_equal 9, family.subscription.amount\n    assert_equal \"USD\", family.subscription.currency\n    assert family.subscription.current_period_ends_at > 20.days.from_now\n  end\nend\n"
  },
  {
    "path": "test/models/provider/stripe_test.rb",
    "content": "require \"test_helper\"\n\nclass Provider::StripeTest < ActiveSupport::TestCase\n  setup do\n    @stripe = Provider::Stripe.new(\n      secret_key: ENV[\"STRIPE_SECRET_KEY\"] || \"foo\",\n      webhook_secret: ENV[\"STRIPE_WEBHOOK_SECRET\"] || \"bar\"\n    )\n  end\n\n  test \"creates checkout session\" do\n    test_email = \"test@example.com\"\n\n    test_success_url = \"http://localhost:3000/subscription/success?session_id={CHECKOUT_SESSION_ID}\"\n    test_cancel_url = \"http://localhost:3000/subscription/upgrade\"\n\n    VCR.use_cassette(\"stripe/create_checkout_session\") do\n      session = @stripe.create_checkout_session(\n        plan: \"monthly\",\n        family_id: 1,\n        family_email: test_email,\n        success_url: test_success_url,\n        cancel_url: test_cancel_url\n      )\n\n      assert_match /https:\\/\\/checkout.stripe.com\\/c\\/pay\\/cs_test_.*/, session.url\n      assert_match /cus_.*/, session.customer_id\n    end\n  end\n\n  # To re-run VCR for this test:\n  # 1. Complete a checkout session locally in the UI\n  # 2. Find the session ID, replace below\n  # 3. Re-run VCR, make sure ENV vars are in test environment\n  test \"validates checkout session and returns subscription ID\" do\n    test_session_id = \"cs_test_b1RD8r6DAkSA8vrQ3grBC2QVgR5zUJ7QQFuVHZkcKoSYaEOQgCMPMOCOM5\" # must exist in test Dashboard\n\n    VCR.use_cassette(\"stripe/checkout_session\") do\n      result = @stripe.get_checkout_result(test_session_id)\n\n      assert result.success?\n      assert_match /sub_.*/, result.subscription_id\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/provider/synth_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass Provider::SynthTest < ActiveSupport::TestCase\n  include ExchangeRateProviderInterfaceTest, SecurityProviderInterfaceTest\n\n  setup do\n    @subject = @synth = Provider::Synth.new(ENV[\"SYNTH_API_KEY\"])\n  end\n\n  test \"health check\" do\n    VCR.use_cassette(\"synth/health\") do\n      assert @synth.healthy?\n    end\n  end\n\n  test \"usage info\" do\n    VCR.use_cassette(\"synth/usage\") do\n      usage = @synth.usage.data\n      assert usage.used.present?\n      assert usage.limit.present?\n      assert usage.utilization.present?\n      assert usage.plan.present?\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/provider_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass TestProvider < Provider\n  TestError = Class.new(StandardError)\n\n  def initialize(client)\n    @client = client\n  end\n\n  def fetch_data\n    with_provider_response do\n      @client.get(\"/test\")\n    end\n  end\n\n  def fetch_data_with_error_transformer\n    with_provider_response(error_transformer: ->(error) { TestError.new(error.message) }) do\n      @client.get(\"/test\")\n    end\n  end\nend\n\nclass ProviderTest < ActiveSupport::TestCase\n  setup do\n    @client = mock\n    @provider = TestProvider.new(@client)\n  end\n\n  test \"returns success response with data\" do\n    @client.expects(:get).with(\"/test\").returns({ some: \"data\" })\n\n    response = @provider.fetch_data\n\n    assert response.success?\n    assert_equal({ some: \"data\" }, response.data)\n  end\n\n  test \"returns failed response with error\" do\n    @client.expects(:get).with(\"/test\").raises(StandardError.new(\"some error\"))\n\n    response = @provider.fetch_data\n\n    assert_not response.success?\n    assert_equal(\"some error\", response.error.message)\n  end\n\n  test \"provider can transform error\" do\n    @client.expects(:get).with(\"/test\").raises(StandardError.new(\"some error\"))\n\n    response = @provider.fetch_data_with_error_transformer\n\n    assert_not response.success?\n    assert_equal(\"some error\", response.error.message)\n    assert_instance_of TestProvider::TestError, response.error\n  end\nend\n"
  },
  {
    "path": "test/models/rule/action_test.rb",
    "content": "require \"test_helper\"\n\nclass Rule::ActionTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @family = families(:dylan_family)\n    @transaction_rule = rules(:one)\n    @account = @family.accounts.create!(name: \"Rule test\", balance: 1000, currency: \"USD\", accountable: Depository.new)\n\n    @grocery_category = @family.categories.create!(name: \"Grocery\")\n    @whole_foods_merchant = @family.merchants.create!(name: \"Whole Foods\", type: \"FamilyMerchant\")\n\n    # Some sample transactions to work with\n    @txn1 = create_transaction(date: Date.current, account: @account, amount: 100, name: \"Rule test transaction1\", merchant: @whole_foods_merchant).transaction\n    @txn2 = create_transaction(date: Date.current, account: @account, amount: -200, name: \"Rule test transaction2\").transaction\n    @txn3 = create_transaction(date: 1.day.ago.to_date, account: @account, amount: 50, name: \"Rule test transaction3\").transaction\n\n    @rule_scope = @account.transactions\n  end\n\n  test \"set_transaction_category\" do\n    # Does not modify transactions that are locked (user edited them)\n    @txn1.lock_attr!(:category_id)\n\n    action = Rule::Action.new(\n      rule: @transaction_rule,\n      action_type: \"set_transaction_category\",\n      value: @grocery_category.id\n    )\n\n    action.apply(@rule_scope)\n\n    assert_nil @txn1.reload.category\n\n    [ @txn2, @txn3 ].each do |transaction|\n      assert_equal @grocery_category.id, transaction.reload.category_id\n    end\n  end\n\n  test \"set_transaction_tags\" do\n    tag = @family.tags.create!(name: \"Rule test tag\")\n\n    # Does not modify transactions that are locked (user edited them)\n    @txn1.lock_attr!(:tag_ids)\n\n    action = Rule::Action.new(\n      rule: @transaction_rule,\n      action_type: \"set_transaction_tags\",\n      value: tag.id\n    )\n\n    action.apply(@rule_scope)\n\n    assert_equal [], @txn1.reload.tags\n\n    [ @txn2, @txn3 ].each do |transaction|\n      assert_equal [ tag ], transaction.reload.tags\n    end\n  end\n\n  test \"set_transaction_merchant\" do\n    merchant = @family.merchants.create!(name: \"Rule test merchant\")\n\n    # Does not modify transactions that are locked (user edited them)\n    @txn1.lock_attr!(:merchant_id)\n\n    action = Rule::Action.new(\n      rule: @transaction_rule,\n      action_type: \"set_transaction_merchant\",\n      value: merchant.id\n    )\n\n    action.apply(@rule_scope)\n\n    assert_not_equal merchant.id, @txn1.reload.merchant_id\n\n    [ @txn2, @txn3 ].each do |transaction|\n      assert_equal merchant.id, transaction.reload.merchant_id\n    end\n  end\n\n  test \"set_transaction_name\" do\n    new_name = \"Renamed Transaction\"\n\n    # Does not modify transactions that are locked (user edited them)\n    @txn1.lock_attr!(:name)\n\n    action = Rule::Action.new(\n      rule: @transaction_rule,\n      action_type: \"set_transaction_name\",\n      value: new_name\n    )\n\n    action.apply(@rule_scope)\n\n    assert_not_equal new_name, @txn1.reload.entry.name\n\n    [ @txn2, @txn3 ].each do |transaction|\n      assert_equal new_name, transaction.reload.entry.name\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/rule/condition_test.rb",
    "content": "require \"test_helper\"\n\nclass Rule::ConditionTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @family = families(:empty)\n    @transaction_rule = rules(:one)\n    @account = @family.accounts.create!(name: \"Rule test\", balance: 1000, currency: \"USD\", accountable: Depository.new)\n\n    @grocery_category = @family.categories.create!(name: \"Grocery\")\n    @whole_foods_merchant = @family.merchants.create!(name: \"Whole Foods\", type: \"FamilyMerchant\")\n\n    # Some sample transactions to work with\n    create_transaction(date: Date.current, account: @account, amount: 100, name: \"Rule test transaction1\", merchant: @whole_foods_merchant)\n    create_transaction(date: Date.current, account: @account, amount: -200, name: \"Rule test transaction2\")\n    create_transaction(date: 1.day.ago.to_date, account: @account, amount: 50, name: \"Rule test transaction3\")\n    create_transaction(date: 1.year.ago.to_date, account: @account, amount: 10, name: \"Rule test transaction4\", merchant: @whole_foods_merchant)\n    create_transaction(date: 1.year.ago.to_date, account: @account, amount: 1000, name: \"Rule test transaction5\")\n\n    @rule_scope = @account.transactions\n  end\n\n  test \"applies transaction_name condition\" do\n    scope = @rule_scope\n\n    condition = Rule::Condition.new(\n      rule: @transaction_rule,\n      condition_type: \"transaction_name\",\n      operator: \"=\",\n      value: \"Rule test transaction1\"\n    )\n\n    scope = condition.prepare(scope)\n\n    assert_equal 5, scope.count\n\n    filtered = condition.apply(scope)\n\n    assert_equal 1, filtered.count\n  end\n\n  test \"applies transaction_amount condition using absolute values\" do\n    scope = @rule_scope\n\n    condition = Rule::Condition.new(\n      rule: @transaction_rule,\n      condition_type: \"transaction_amount\",\n      operator: \">\",\n      value: \"50\"\n    )\n\n    scope = condition.prepare(scope)\n\n    filtered = condition.apply(scope)\n    assert_equal 3, filtered.count\n  end\n\n  test \"applies transaction_merchant condition\" do\n    scope = @rule_scope\n\n    condition = Rule::Condition.new(\n      rule: @transaction_rule,\n      condition_type: \"transaction_merchant\",\n      operator: \"=\",\n      value: @whole_foods_merchant.id\n    )\n\n    scope = condition.prepare(scope)\n\n    filtered = condition.apply(scope)\n    assert_equal 2, filtered.count\n  end\n\n  test \"applies compound and condition\" do\n    scope = @rule_scope\n\n    parent_condition = Rule::Condition.new(\n      rule: @transaction_rule,\n      condition_type: \"compound\",\n      operator: \"and\",\n      sub_conditions: [\n        Rule::Condition.new(\n          condition_type: \"transaction_merchant\",\n          operator: \"=\",\n          value: @whole_foods_merchant.id\n        ),\n        Rule::Condition.new(\n          condition_type: \"transaction_amount\",\n          operator: \"<\",\n          value: \"50\"\n        )\n      ]\n    )\n\n    scope = parent_condition.prepare(scope)\n\n    filtered = parent_condition.apply(scope)\n    assert_equal 1, filtered.count\n  end\n\n  test \"applies compound or condition\" do\n    scope = @rule_scope\n\n    parent_condition = Rule::Condition.new(\n      rule: @transaction_rule,\n      condition_type: \"compound\",\n      operator: \"or\",\n      sub_conditions: [\n        Rule::Condition.new(\n          condition_type: \"transaction_merchant\",\n          operator: \"=\",\n          value: @whole_foods_merchant.id\n        ),\n        Rule::Condition.new(\n          condition_type: \"transaction_amount\",\n          operator: \"<\",\n          value: \"50\"\n        )\n      ]\n    )\n\n    scope = parent_condition.prepare(scope)\n\n    filtered = parent_condition.apply(scope)\n    assert_equal 2, filtered.count\n  end\nend\n"
  },
  {
    "path": "test/models/rule_test.rb",
    "content": "require \"test_helper\"\n\nclass RuleTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @family = families(:empty)\n    @account = @family.accounts.create!(name: \"Rule test\", balance: 1000, currency: \"USD\", accountable: Depository.new)\n    @whole_foods_merchant = @family.merchants.create!(name: \"Whole Foods\", type: \"FamilyMerchant\")\n    @groceries_category = @family.categories.create!(name: \"Groceries\")\n  end\n\n  test \"basic rule\" do\n    transaction_entry = create_transaction(date: Date.current, account: @account, merchant: @whole_foods_merchant)\n\n    rule = Rule.create!(\n      family: @family,\n      resource_type: \"transaction\",\n      effective_date: 1.day.ago.to_date,\n      conditions: [ Rule::Condition.new(condition_type: \"transaction_merchant\", operator: \"=\", value: @whole_foods_merchant.id) ],\n      actions: [ Rule::Action.new(action_type: \"set_transaction_category\", value: @groceries_category.id) ]\n    )\n\n    rule.apply\n\n    transaction_entry.reload\n\n    assert_equal @groceries_category, transaction_entry.transaction.category\n  end\n\n  test \"compound rule\" do\n    transaction_entry1 = create_transaction(date: Date.current, amount: 50, account: @account, merchant: @whole_foods_merchant)\n    transaction_entry2 = create_transaction(date: Date.current, amount: 100, account: @account, merchant: @whole_foods_merchant)\n\n    # Assign \"Groceries\" to transactions with a merchant of \"Whole Foods\" and an amount greater than $60\n    rule = Rule.create!(\n      family: @family,\n      resource_type: \"transaction\",\n      effective_date: 1.day.ago.to_date,\n      conditions: [\n        Rule::Condition.new(condition_type: \"compound\", operator: \"and\", sub_conditions: [\n          Rule::Condition.new(condition_type: \"transaction_merchant\", operator: \"=\", value: @whole_foods_merchant.id),\n          Rule::Condition.new(condition_type: \"transaction_amount\", operator: \">\", value: 60)\n        ])\n      ],\n      actions: [ Rule::Action.new(action_type: \"set_transaction_category\", value: @groceries_category.id) ]\n    )\n\n    rule.apply\n\n    transaction_entry1.reload\n    transaction_entry2.reload\n\n    assert_nil transaction_entry1.transaction.category\n    assert_equal @groceries_category, transaction_entry2.transaction.category\n  end\n\n  # Artificial limitation put in place to prevent users from creating overly complex rules\n  # Rules should be shallow and wide\n  test \"no nested compound conditions\" do\n    rule = Rule.new(\n      family: @family,\n      resource_type: \"transaction\",\n      actions: [ Rule::Action.new(action_type: \"set_transaction_category\", value: @groceries_category.id) ],\n      conditions: [\n        Rule::Condition.new(condition_type: \"compound\", operator: \"and\", sub_conditions: [\n          Rule::Condition.new(condition_type: \"compound\", operator: \"and\", sub_conditions: [\n            Rule::Condition.new(condition_type: \"transaction_name\", operator: \"=\", value: \"Starbucks\")\n          ])\n        ])\n      ]\n    )\n\n    assert_not rule.valid?\n    assert_equal [ \"Compound conditions cannot be nested\" ], rule.errors.full_messages\n  end\nend\n"
  },
  {
    "path": "test/models/security/health_checker_test.rb",
    "content": "require \"test_helper\"\n\nclass Security::HealthCheckerTest < ActiveSupport::TestCase\n  include ProviderTestHelper\n\n  setup do\n    # Clean slate\n    Holding.destroy_all\n    Trade.destroy_all\n    Security::Price.delete_all\n    Security.delete_all\n\n    @provider = mock\n    Security.stubs(:provider).returns(@provider)\n\n    # Brand new, no health check has been run yet\n    @new_security = Security.create!(\n      ticker: \"NEW\",\n      offline: false,\n      last_health_check_at: nil\n    )\n\n    # New security, offline\n    # This will be checked, but unless it gets a price, we keep it offline\n    @new_offline_security = Security.create!(\n      ticker: \"NEW_OFFLINE\",\n      offline: true,\n      last_health_check_at: nil\n    )\n\n    # Online, recently checked, healthy\n    @healthy_security = Security.create!(\n      ticker: \"HEALTHY\",\n      offline: false,\n      last_health_check_at: 2.hours.ago\n    )\n\n    # Online, due for a health check\n    @due_for_check_security = Security.create!(\n      ticker: \"DUE\",\n      offline: false,\n      last_health_check_at: Security::HealthChecker::HEALTH_CHECK_INTERVAL.ago - 1.day\n    )\n\n    # Offline, recently checked (keep offline, don't check)\n    @offline_security = Security.create!(\n      ticker: \"OFFLINE\",\n      offline: true,\n      last_health_check_at: 20.days.ago\n    )\n\n    # Currently offline, but has had no health check and actually has prices (needs to convert to \"online\")\n    @offline_never_checked_with_prices = Security.create!(\n      ticker: \"OFFLINE_NEVER_CHECKED\",\n      offline: true,\n      last_health_check_at: nil\n    )\n  end\n\n  test \"any security without a health check runs\" do\n    to_check = Security.where(last_health_check_at: nil).or(Security.where(last_health_check_at: ..Security::HealthChecker::HEALTH_CHECK_INTERVAL.ago))\n    Security::HealthChecker.any_instance.expects(:run_check).times(to_check.count)\n    Security::HealthChecker.check_all\n  end\n\n  test \"offline security with no health check that fails stays offline\" do\n    hc = Security::HealthChecker.new(@new_offline_security)\n\n    @provider.expects(:fetch_security_price)\n      .with(\n        symbol: @new_offline_security.ticker,\n        exchange_operating_mic: @new_offline_security.exchange_operating_mic,\n        date: Date.current\n      )\n      .returns(\n        provider_error_response(StandardError.new(\"No prices found\"))\n      )\n      .once\n\n    hc.run_check\n\n    assert_equal 1, @new_offline_security.failed_fetch_count\n    assert @new_offline_security.offline?\n  end\n\n  test \"after enough consecutive health check failures, security goes offline and prices are deleted\" do\n    # Create one test price\n    Security::Price.create!(\n      security: @due_for_check_security,\n      date: Date.current,\n      price: 100,\n      currency: \"USD\"\n    )\n\n    hc = Security::HealthChecker.new(@due_for_check_security)\n\n    @provider.expects(:fetch_security_price)\n      .with(\n        symbol: @due_for_check_security.ticker,\n        exchange_operating_mic: @due_for_check_security.exchange_operating_mic,\n        date: Date.current\n      )\n      .returns(provider_error_response(StandardError.new(\"No prices found\")))\n      .times(Security::HealthChecker::MAX_CONSECUTIVE_FAILURES + 1)\n\n    Security::HealthChecker::MAX_CONSECUTIVE_FAILURES.times do\n      hc.run_check\n    end\n\n    refute @due_for_check_security.offline?\n    assert_equal 1, @due_for_check_security.prices.count\n\n    # We've now exceeded the max consecutive failures, so the security should be marked offline\n    hc.run_check\n    assert @due_for_check_security.offline?\n    assert_equal 0, @due_for_check_security.prices.count\n  end\n\n  test \"failure incrementor increases for each health check failure\" do\n    hc = Security::HealthChecker.new(@due_for_check_security)\n\n    @provider.expects(:fetch_security_price)\n      .with(\n        symbol: @due_for_check_security.ticker,\n        exchange_operating_mic: @due_for_check_security.exchange_operating_mic,\n        date: Date.current\n      )\n      .returns(provider_error_response(StandardError.new(\"No prices found\")))\n      .twice\n\n    hc.run_check\n    assert_equal 1, @due_for_check_security.failed_fetch_count\n\n    hc.run_check\n    assert_equal 2, @due_for_check_security.failed_fetch_count\n  end\n\n  test \"failure incrementor resets to 0 when health check succeeds\" do\n    hc = Security::HealthChecker.new(@offline_never_checked_with_prices)\n\n    @provider.expects(:fetch_security_price)\n      .with(\n        symbol: @offline_never_checked_with_prices.ticker,\n        exchange_operating_mic: @offline_never_checked_with_prices.exchange_operating_mic,\n        date: Date.current\n      )\n      .returns(provider_success_response(OpenStruct.new(price: 100, date: Date.current, currency: \"USD\")))\n      .once\n\n    assert @offline_never_checked_with_prices.offline?\n\n    hc.run_check\n\n    refute @offline_never_checked_with_prices.offline?\n    assert_equal 0, @offline_never_checked_with_prices.failed_fetch_count\n    assert_nil @offline_never_checked_with_prices.failed_fetch_at\n  end\nend\n"
  },
  {
    "path": "test/models/security/price/importer_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass Security::Price::ImporterTest < ActiveSupport::TestCase\n  include ProviderTestHelper\n\n  setup do\n    @provider = mock\n    @security = Security.create!(ticker: \"AAPL\")\n  end\n\n  test \"syncs missing prices from provider\" do\n    Security::Price.delete_all\n\n    provider_response = provider_success_response([\n      OpenStruct.new(security: @security, date: 2.days.ago.to_date, price: 150, currency: \"USD\"),\n      OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 155, currency: \"USD\"),\n      OpenStruct.new(security: @security, date: Date.current, price: 160, currency: \"USD\")\n    ])\n\n    @provider.expects(:fetch_security_prices)\n             .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,\n                   start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current)\n             .returns(provider_response)\n\n    Security::Price::Importer.new(\n      security: @security,\n      security_provider: @provider,\n      start_date: 2.days.ago.to_date,\n      end_date: Date.current\n    ).import_provider_prices\n\n    db_prices = Security::Price.where(security: @security, date: 2.days.ago.to_date..Date.current).order(:date)\n\n    assert_equal 3, db_prices.count\n    assert_equal [ 150, 155, 160 ], db_prices.map(&:price)\n  end\n\n  test \"syncs diff when some prices already exist\" do\n    Security::Price.delete_all\n\n    # Pre-populate DB with first two days\n    Security::Price.create!(security: @security, date: 3.days.ago.to_date, price: 140, currency: \"USD\")\n    Security::Price.create!(security: @security, date: 2.days.ago.to_date, price: 145, currency: \"USD\")\n\n    provider_response = provider_success_response([\n      OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 150, currency: \"USD\")\n    ])\n\n    @provider.expects(:fetch_security_prices)\n             .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,\n                   start_date: get_provider_fetch_start_date(1.day.ago.to_date), end_date: Date.current)\n             .returns(provider_response)\n\n    Security::Price::Importer.new(\n      security: @security,\n      security_provider: @provider,\n      start_date: 3.days.ago.to_date,\n      end_date: Date.current\n    ).import_provider_prices\n\n    db_prices = Security::Price.where(security: @security).order(:date)\n    assert_equal 4, db_prices.count\n    assert_equal [ 140, 145, 150, 150 ], db_prices.map(&:price)\n  end\n\n  test \"no provider calls when all prices exist\" do\n    Security::Price.delete_all\n\n    (3.days.ago.to_date..Date.current).each_with_index do |date, idx|\n      Security::Price.create!(security: @security, date:, price: 100 + idx, currency: \"USD\")\n    end\n\n    @provider.expects(:fetch_security_prices).never\n\n    Security::Price::Importer.new(\n      security: @security,\n      security_provider: @provider,\n      start_date: 3.days.ago.to_date,\n      end_date: Date.current\n    ).import_provider_prices\n  end\n\n  test \"full upsert if clear_cache is true\" do\n    Security::Price.delete_all\n\n    # Seed DB with stale prices\n    (2.days.ago.to_date..Date.current).each do |date|\n      Security::Price.create!(security: @security, date:, price: 100, currency: \"USD\")\n    end\n\n    provider_response = provider_success_response([\n      OpenStruct.new(security: @security, date: 2.days.ago.to_date, price: 150, currency: \"USD\"),\n      OpenStruct.new(security: @security, date: 1.day.ago.to_date, price: 155, currency: \"USD\"),\n      OpenStruct.new(security: @security, date: Date.current,        price: 160, currency: \"USD\")\n    ])\n\n    @provider.expects(:fetch_security_prices)\n             .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,\n                   start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current)\n             .returns(provider_response)\n\n    Security::Price::Importer.new(\n      security: @security,\n      security_provider: @provider,\n      start_date: 2.days.ago.to_date,\n      end_date: Date.current,\n      clear_cache: true\n    ).import_provider_prices\n\n    db_prices = Security::Price.where(security: @security).order(:date)\n    assert_equal [ 150, 155, 160 ], db_prices.map(&:price)\n  end\n\n  test \"clamps end_date to today when future date is provided\" do\n    Security::Price.delete_all\n\n    future_date = Date.current + 3.days\n\n    provider_response = provider_success_response([\n      OpenStruct.new(security: @security, date: Date.current, price: 165, currency: \"USD\")\n    ])\n\n    @provider.expects(:fetch_security_prices)\n             .with(symbol: @security.ticker, exchange_operating_mic: @security.exchange_operating_mic,\n                   start_date: get_provider_fetch_start_date(Date.current), end_date: Date.current)\n             .returns(provider_response)\n\n    Security::Price::Importer.new(\n      security: @security,\n      security_provider: @provider,\n      start_date: Date.current,\n      end_date: future_date\n    ).import_provider_prices\n\n    assert_equal 1, Security::Price.count\n  end\n\n  private\n    def get_provider_fetch_start_date(start_date)\n      start_date - 5.days\n    end\nend\n"
  },
  {
    "path": "test/models/security/price_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass Security::PriceTest < ActiveSupport::TestCase\n  include ProviderTestHelper\n\n  setup do\n    @provider = mock\n    Security.stubs(:provider).returns(@provider)\n\n    @security = securities(:aapl)\n  end\n\n  test \"finds single security price in DB\" do\n    @provider.expects(:fetch_security_price).never\n    price = security_prices(:one)\n\n    assert_equal price, @security.find_or_fetch_price(date: price.date)\n  end\n\n  test \"caches prices from provider to DB\" do\n    price_date = 10.days.ago.to_date\n\n    expected_price = Security::Price.new(\n      security: @security,\n      date: price_date,\n      price: 314.34,\n      currency: \"USD\"\n    )\n\n    expect_provider_price(security: @security, price: expected_price, date: price_date)\n\n    assert_difference \"Security::Price.count\", 1 do\n      fetched_price = @security.find_or_fetch_price(date: price_date, cache: true)\n      assert_equal expected_price.price, fetched_price.price\n    end\n  end\n\n  test \"returns nil if no price found in DB or from provider\" do\n    security = securities(:aapl)\n    Security::Price.delete_all # Clear any existing prices\n\n    with_provider_response = provider_error_response(StandardError.new(\"Test error\"))\n\n    @provider.expects(:fetch_security_price)\n             .with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic, date: Date.current)\n             .returns(with_provider_response)\n\n    assert_not @security.find_or_fetch_price(date: Date.current)\n  end\n\n  private\n    def expect_provider_price(security:, price:, date:)\n      @provider.expects(:fetch_security_price)\n               .with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic, date: date)\n               .returns(provider_success_response(price))\n    end\n\n    def expect_provider_prices(security:, prices:, start_date:, end_date:)\n      @provider.expects(:fetch_security_prices)\n               .with(security, start_date: start_date, end_date: end_date)\n               .returns(provider_success_response(prices))\n    end\nend\n"
  },
  {
    "path": "test/models/security/resolver_test.rb",
    "content": "require \"test_helper\"\n\nclass Security::ResolverTest < ActiveSupport::TestCase\n  setup do\n    @provider = mock\n    Security.stubs(:provider).returns(@provider)\n  end\n\n  test \"resolves DB security\" do\n    # Given an existing security in the DB that exactly matches the lookup params\n    db_security = Security.create!(ticker: \"TSLA\", exchange_operating_mic: \"XNAS\", country_code: \"US\")\n\n    # The resolver should return the DB record and never hit the provider\n    Security.expects(:search_provider).never\n\n    resolved = Security::Resolver.new(\"TSLA\", exchange_operating_mic: \"XNAS\", country_code: \"US\").resolve\n\n    assert_equal db_security, resolved\n  end\n\n  test \"resolves exact provider match\" do\n    # Provider returns multiple results, one of which exactly matches symbol + exchange (and country)\n    exact_match = Security.new(ticker: \"NVDA\", exchange_operating_mic: \"XNAS\", country_code: \"US\")\n    near_miss   = Security.new(ticker: \"NVDA\", exchange_operating_mic: \"XNYS\", country_code: \"US\")\n\n    Security.expects(:search_provider)\n            .with(\"NVDA\", exchange_operating_mic: \"XNAS\", country_code: \"US\")\n            .returns([ near_miss, exact_match ])\n\n    assert_difference \"Security.count\", 1 do\n      resolved = Security::Resolver.new(\"NVDA\", exchange_operating_mic: \"XNAS\", country_code: \"US\").resolve\n\n      assert resolved.persisted?\n      assert_equal \"NVDA\", resolved.ticker\n      assert_equal \"XNAS\", resolved.exchange_operating_mic\n      assert_equal \"US\",   resolved.country_code\n      refute resolved.offline, \"Exact provider matches should not be marked offline\"\n    end\n  end\n\n  test \"resolves close provider match\" do\n    # No exact match – resolver should choose the most relevant close match based on exchange + country ranking\n    preferred = Security.new(ticker: \"TEST1\", exchange_operating_mic: \"XNAS\", country_code: \"US\")\n    other     = Security.new(ticker: \"TEST2\", exchange_operating_mic: \"XNYS\", country_code: \"GB\")\n\n    # Return in reverse-priority order to prove the sorter works\n    Security.expects(:search_provider)\n            .with(\"TEST\", exchange_operating_mic: \"XNAS\")\n            .returns([ other, preferred ])\n\n    assert_difference \"Security.count\", 1 do\n      resolved = Security::Resolver.new(\"TEST\", exchange_operating_mic: \"XNAS\").resolve\n\n      assert resolved.persisted?\n      assert_equal \"TEST1\", resolved.ticker\n      assert_equal \"XNAS\",  resolved.exchange_operating_mic\n      assert_equal \"US\",    resolved.country_code\n      refute resolved.offline, \"Provider matches should not be marked offline\"\n    end\n  end\n\n  test \"resolves offline security\" do\n    Security.expects(:search_provider).returns([])\n\n    assert_difference \"Security.count\", 1 do\n      resolved = Security::Resolver.new(\"FOO\").resolve\n\n      assert resolved.persisted?, \"Offline security should be saved\"\n      assert_equal \"FOO\", resolved.ticker\n      assert resolved.offline, \"Offline securities should be flagged offline\"\n    end\n  end\n\n  test \"returns nil when symbol blank\" do\n    assert_raises(ArgumentError) { Security::Resolver.new(nil).resolve }\n    assert_raises(ArgumentError) { Security::Resolver.new(\"\").resolve }\n  end\nend\n"
  },
  {
    "path": "test/models/security_test.rb",
    "content": "require \"test_helper\"\n\nclass SecurityTest < ActiveSupport::TestCase\n  # Below has 3 example scenarios:\n  # 1. Original ticker\n  # 2. Duplicate ticker on a different exchange (different market price)\n  # 3. \"Offline\" version of the same ticker (for users not connected to a provider)\n  test \"can have duplicate tickers if exchange is different\" do\n    original = Security.create!(ticker: \"TEST\", exchange_operating_mic: \"XNAS\")\n    duplicate = Security.create!(ticker: \"TEST\", exchange_operating_mic: \"CBOE\")\n    offline = Security.create!(ticker: \"TEST\", exchange_operating_mic: nil)\n\n    assert original.valid?\n    assert duplicate.valid?\n    assert offline.valid?\n  end\n\n  test \"cannot have duplicate tickers if exchange is the same\" do\n    original = Security.create!(ticker: \"TEST\", exchange_operating_mic: \"XNAS\")\n    duplicate = Security.new(ticker: \"TEST\", exchange_operating_mic: \"XNAS\")\n\n    assert_not duplicate.valid?\n    assert_equal [ \"has already been taken\" ], duplicate.errors[:ticker]\n  end\n\n  test \"cannot have duplicate tickers if exchange is nil\" do\n    original = Security.create!(ticker: \"TEST\", exchange_operating_mic: nil)\n    duplicate = Security.new(ticker: \"TEST\", exchange_operating_mic: nil)\n\n    assert_not duplicate.valid?\n    assert_equal [ \"has already been taken\" ], duplicate.errors[:ticker]\n  end\n\n  test \"casing is ignored when checking for duplicates\" do\n    original = Security.create!(ticker: \"TEST\", exchange_operating_mic: \"XNAS\")\n    duplicate = Security.new(ticker: \"tEst\", exchange_operating_mic: \"xNaS\")\n\n    assert_not duplicate.valid?\n    assert_equal [ \"has already been taken\" ], duplicate.errors[:ticker]\n  end\nend\n"
  },
  {
    "path": "test/models/subscription_test.rb",
    "content": "require \"test_helper\"\n\nclass SubscriptionTest < ActiveSupport::TestCase\n  setup do\n    @family = Family.create!(name: \"Test Family\")\n  end\n\n  test \"can create subscription without stripe details if trial\" do\n    subscription = Subscription.new(\n      family: @family,\n      status: :trialing,\n    )\n\n    assert_not subscription.valid?\n\n    subscription.trial_ends_at = 14.days.from_now\n\n    assert subscription.valid?\n  end\n\n  test \"stripe details required for all statuses except trial\" do\n    subscription = Subscription.new(\n      family: @family,\n      status: :active,\n    )\n\n    assert_not subscription.valid?\n\n    subscription.stripe_id = \"test-stripe-id\"\n\n    assert subscription.valid?\n  end\nend\n"
  },
  {
    "path": "test/models/sync_test.rb",
    "content": "require \"test_helper\"\n\nclass SyncTest < ActiveSupport::TestCase\n  include ActiveJob::TestHelper\n\n  test \"does not run if not in a valid state\" do\n    syncable = accounts(:depository)\n    sync = Sync.create!(syncable: syncable, status: :completed)\n\n    syncable.expects(:perform_sync).never\n\n    sync.perform\n\n    assert_equal \"completed\", sync.status\n  end\n\n  test \"runs successful sync\" do\n    syncable = accounts(:depository)\n    sync = Sync.create!(syncable: syncable)\n\n    syncable.expects(:perform_sync).with(sync).once\n\n    assert_equal \"pending\", sync.status\n\n    sync.perform\n\n    assert sync.completed_at < Time.now\n    assert_equal \"completed\", sync.status\n  end\n\n  test \"handles sync errors\" do\n    syncable = accounts(:depository)\n    sync = Sync.create!(syncable: syncable)\n\n    syncable.expects(:perform_sync).with(sync).raises(StandardError.new(\"test sync error\"))\n\n    assert_equal \"pending\", sync.status\n\n    sync.perform\n\n    assert sync.failed_at < Time.now\n    assert_equal \"failed\", sync.status\n    assert_equal \"test sync error\", sync.error\n  end\n\n  test \"can run nested syncs that alert the parent when complete\" do\n    family = families(:dylan_family)\n    plaid_item = plaid_items(:one)\n    account = accounts(:connected)\n\n    family_sync = Sync.create!(syncable: family)\n    plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync)\n    account_sync = Sync.create!(syncable: account, parent: plaid_item_sync)\n\n    assert_equal \"pending\", family_sync.status\n    assert_equal \"pending\", plaid_item_sync.status\n    assert_equal \"pending\", account_sync.status\n\n    family.expects(:perform_sync).with(family_sync).once\n\n    family_sync.perform\n\n    assert_equal \"syncing\", family_sync.reload.status\n\n    plaid_item.expects(:perform_sync).with(plaid_item_sync).once\n\n    plaid_item_sync.perform\n\n    assert_equal \"syncing\", family_sync.reload.status\n    assert_equal \"syncing\", plaid_item_sync.reload.status\n\n    account.expects(:perform_sync).with(account_sync).once\n\n    # Since these are accessed through `parent`, they won't necessarily be the same\n    # instance we configured above\n    Account.any_instance.expects(:perform_post_sync).once\n    Account.any_instance.expects(:broadcast_sync_complete).once\n    PlaidItem.any_instance.expects(:perform_post_sync).once\n    PlaidItem.any_instance.expects(:broadcast_sync_complete).once\n    Family.any_instance.expects(:perform_post_sync).once\n    Family.any_instance.expects(:broadcast_sync_complete).once\n\n    account_sync.perform\n\n    assert_equal \"completed\", plaid_item_sync.reload.status\n    assert_equal \"completed\", account_sync.reload.status\n    assert_equal \"completed\", family_sync.reload.status\n  end\n\n  test \"failures propagate up the chain\" do\n    family = families(:dylan_family)\n    plaid_item = plaid_items(:one)\n    account = accounts(:connected)\n\n    family_sync = Sync.create!(syncable: family)\n    plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync)\n    account_sync = Sync.create!(syncable: account, parent: plaid_item_sync)\n\n    assert_equal \"pending\", family_sync.status\n    assert_equal \"pending\", plaid_item_sync.status\n    assert_equal \"pending\", account_sync.status\n\n    family.expects(:perform_sync).with(family_sync).once\n\n    family_sync.perform\n\n    assert_equal \"syncing\", family_sync.reload.status\n\n    plaid_item.expects(:perform_sync).with(plaid_item_sync).once\n\n    plaid_item_sync.perform\n\n    assert_equal \"syncing\", family_sync.reload.status\n    assert_equal \"syncing\", plaid_item_sync.reload.status\n\n    # This error should \"bubble up\" to the PlaidItem and Family sync results\n    account.expects(:perform_sync).with(account_sync).raises(StandardError.new(\"test account sync error\"))\n\n    # Since these are accessed through `parent`, they won't necessarily be the same\n    # instance we configured above\n    Account.any_instance.expects(:perform_post_sync).once\n    PlaidItem.any_instance.expects(:perform_post_sync).once\n    Family.any_instance.expects(:perform_post_sync).once\n\n    Account.any_instance.expects(:broadcast_sync_complete).once\n    PlaidItem.any_instance.expects(:broadcast_sync_complete).once\n    Family.any_instance.expects(:broadcast_sync_complete).once\n\n    account_sync.perform\n\n    assert_equal \"failed\", plaid_item_sync.reload.status\n    assert_equal \"failed\", account_sync.reload.status\n    assert_equal \"failed\", family_sync.reload.status\n  end\n\n  test \"parent failure should not change status if child succeeds\" do\n    family = families(:dylan_family)\n    plaid_item = plaid_items(:one)\n    account = accounts(:connected)\n\n    family_sync = Sync.create!(syncable: family)\n    plaid_item_sync = Sync.create!(syncable: plaid_item, parent: family_sync)\n    account_sync = Sync.create!(syncable: account, parent: plaid_item_sync)\n\n    assert_equal \"pending\", family_sync.status\n    assert_equal \"pending\", plaid_item_sync.status\n    assert_equal \"pending\", account_sync.status\n\n    family.expects(:perform_sync).with(family_sync).raises(StandardError.new(\"test family sync error\"))\n\n    family_sync.perform\n\n    assert_equal \"failed\", family_sync.reload.status\n\n    plaid_item.expects(:perform_sync).with(plaid_item_sync).raises(StandardError.new(\"test plaid item sync error\"))\n\n    plaid_item_sync.perform\n\n    assert_equal \"failed\", family_sync.reload.status\n    assert_equal \"failed\", plaid_item_sync.reload.status\n\n    # Leaf level sync succeeds, but shouldn't change the status of the already-failed parent syncs\n    account.expects(:perform_sync).with(account_sync).once\n\n    # Since these are accessed through `parent`, they won't necessarily be the same\n    # instance we configured above\n    Account.any_instance.expects(:perform_post_sync).once\n    PlaidItem.any_instance.expects(:perform_post_sync).once\n    Family.any_instance.expects(:perform_post_sync).once\n\n    Account.any_instance.expects(:broadcast_sync_complete).once\n    PlaidItem.any_instance.expects(:broadcast_sync_complete).once\n    Family.any_instance.expects(:broadcast_sync_complete).once\n\n    account_sync.perform\n\n    assert_equal \"failed\", plaid_item_sync.reload.status\n    assert_equal \"failed\", family_sync.reload.status\n    assert_equal \"completed\", account_sync.reload.status\n  end\n\n  test \"clean marks stale incomplete rows\" do\n    stale_pending = Sync.create!(\n      syncable: accounts(:depository),\n      status: :pending,\n      created_at: 25.hours.ago\n    )\n\n    stale_syncing = Sync.create!(\n      syncable: accounts(:depository),\n      status: :syncing,\n      created_at: 25.hours.ago,\n      pending_at: 24.hours.ago,\n      syncing_at: 23.hours.ago\n    )\n\n    Sync.clean\n\n    assert_equal \"stale\", stale_pending.reload.status\n    assert_equal \"stale\", stale_syncing.reload.status\n  end\n\n  test \"expand_window_if_needed widens start and end dates on a pending sync\" do\n    initial_start = 1.day.ago.to_date\n    initial_end   = 1.day.ago.to_date\n\n    sync = Sync.create!(\n      syncable: accounts(:depository),\n      window_start_date: initial_start,\n      window_end_date: initial_end\n    )\n\n    new_start = 5.days.ago.to_date\n    new_end   = Date.current\n\n    sync.expand_window_if_needed(new_start, new_end)\n    sync.reload\n\n    assert_equal new_start, sync.window_start_date\n    assert_equal new_end,   sync.window_end_date\n  end\nend\n"
  },
  {
    "path": "test/models/tag_test.rb",
    "content": "require \"test_helper\"\n\nclass TagTest < ActiveSupport::TestCase\n  test \"replace and destroy\" do\n    old_tag = tags(:one)\n    new_tag = tags(:two)\n\n    assert_difference \"Tag.count\", -1 do\n      old_tag.replace_and_destroy!(new_tag)\n    end\n\n    old_tag.transactions.each do |txn|\n      txn.reload\n      assert_includes txn.tags, new_tag\n      assert_not_includes txn.tags, old_tag\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/trade_import_test.rb",
    "content": "require \"test_helper\"\nrequire \"ostruct\"\n\nclass TradeImportTest < ActiveSupport::TestCase\n  include ActiveJob::TestHelper, ImportInterfaceTest\n\n  setup do\n    @subject = @import = imports(:trade)\n    @provider = mock\n    Security.stubs(:provider).returns(@provider)\n  end\n\n  test \"imports trades and accounts\" do\n    aapl_resolver = mock\n    googl_resolver = mock\n\n    Security::Resolver.expects(:new)\n                      .with(\"AAPL\", exchange_operating_mic: nil)\n                      .returns(aapl_resolver)\n                      .once\n\n    Security::Resolver.expects(:new)\n                      .with(\"GOOGL\", exchange_operating_mic: \"XNAS\")\n                      .returns(googl_resolver)\n                      .once\n\n    aapl = securities(:aapl)\n    googl = Security.create!(ticker: \"GOOGL\", exchange_operating_mic: \"XNAS\")\n\n    aapl_resolver.stubs(:resolve).returns(aapl)\n    googl_resolver.stubs(:resolve).returns(googl)\n\n    import = <<~CSV\n      date,ticker,qty,price,currency,account,name,exchange_operating_mic\n      01/01/2024,AAPL,10,150.00,USD,TestAccount1,Apple Purchase,\n      01/02/2024,GOOGL,5,2500.00,USD,TestAccount1,Google Purchase,XNAS\n    CSV\n\n    @import.update!(\n      account: accounts(:depository),\n      raw_file_str: import,\n      date_col_label: \"date\",\n      ticker_col_label: \"ticker\",\n      qty_col_label: \"qty\",\n      price_col_label: \"price\",\n      exchange_operating_mic_col_label: \"exchange_operating_mic\",\n      date_format: \"%m/%d/%Y\",\n      signage_convention: \"inflows_positive\"\n    )\n\n    @import.generate_rows_from_csv\n\n    @import.mappings.create! key: \"TestAccount1\", create_when_empty: true, type: \"Import::AccountMapping\"\n\n    @import.reload\n\n    assert_difference -> { Entry.count } => 2,\n                      -> { Trade.count } => 2,\n                      -> { Account.count } => 1 do\n      @import.publish\n    end\n\n    assert_equal \"complete\", @import.status\n  end\nend\n"
  },
  {
    "path": "test/models/trade_test.rb",
    "content": "require \"test_helper\"\n\nclass TradeTest < ActiveSupport::TestCase\n  test \"build_name generates buy trade name\" do\n    name = Trade.build_name(\"buy\", 10, \"AAPL\")\n    assert_equal \"Buy 10.0 shares of AAPL\", name\n  end\n\n  test \"build_name generates sell trade name\" do\n    name = Trade.build_name(\"sell\", 5, \"MSFT\")\n    assert_equal \"Sell 5.0 shares of MSFT\", name\n  end\n\n  test \"build_name handles absolute value for negative quantities\" do\n    name = Trade.build_name(\"sell\", -5, \"GOOGL\")\n    assert_equal \"Sell 5.0 shares of GOOGL\", name\n  end\n\n  test \"build_name handles decimal quantities\" do\n    name = Trade.build_name(\"buy\", 0.25, \"BTC\")\n    assert_equal \"Buy 0.25 shares of BTC\", name\n  end\nend\n"
  },
  {
    "path": "test/models/transaction/search_test.rb",
    "content": "require \"test_helper\"\n\nclass Transaction::SearchTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @family = families(:dylan_family)\n    @checking_account = accounts(:depository)\n    @credit_card_account = accounts(:credit_card)\n    @loan_account = accounts(:loan)\n\n    # Clean up existing entries/transactions from fixtures to ensure test isolation\n    @family.accounts.each { |account| account.entries.delete_all }\n  end\n\n  test \"search filters by transaction types using kind enum\" do\n    # Create different types of transactions using the helper method\n    standard_entry = create_transaction(\n      account: @checking_account,\n      amount: 100,\n      category: categories(:food_and_drink),\n      kind: \"standard\"\n    )\n\n    transfer_entry = create_transaction(\n      account: @checking_account,\n      amount: 200,\n      kind: \"funds_movement\"\n    )\n\n    payment_entry = create_transaction(\n      account: @credit_card_account,\n      amount: -300,\n      kind: \"cc_payment\"\n    )\n\n    loan_payment_entry = create_transaction(\n      account: @loan_account,\n      amount: 400,\n      kind: \"loan_payment\"\n    )\n\n    one_time_entry = create_transaction(\n      account: @checking_account,\n      amount: 500,\n      kind: \"one_time\"\n    )\n\n    # Test transfer type filter (includes loan_payment)\n    transfer_results = Transaction::Search.new(@family, filters: { types: [ \"transfer\" ] }).transactions_scope\n    transfer_ids = transfer_results.pluck(:id)\n\n    assert_includes transfer_ids, transfer_entry.entryable.id\n    assert_includes transfer_ids, payment_entry.entryable.id\n    assert_includes transfer_ids, loan_payment_entry.entryable.id\n    assert_not_includes transfer_ids, one_time_entry.entryable.id\n    assert_not_includes transfer_ids, standard_entry.entryable.id\n\n    # Test expense type filter (excludes transfer kinds but includes one_time)\n    expense_results = Transaction::Search.new(@family, filters: { types: [ \"expense\" ] }).transactions_scope\n    expense_ids = expense_results.pluck(:id)\n\n    assert_includes expense_ids, standard_entry.entryable.id\n    assert_includes expense_ids, one_time_entry.entryable.id\n    assert_not_includes expense_ids, loan_payment_entry.entryable.id\n    assert_not_includes expense_ids, transfer_entry.entryable.id\n    assert_not_includes expense_ids, payment_entry.entryable.id\n\n    # Test income type filter\n    income_entry = create_transaction(\n      account: @checking_account,\n      amount: -600,\n      kind: \"standard\"\n    )\n\n    income_results = Transaction::Search.new(@family, filters: { types: [ \"income\" ] }).transactions_scope\n    income_ids = income_results.pluck(:id)\n\n    assert_includes income_ids, income_entry.entryable.id\n    assert_not_includes income_ids, standard_entry.entryable.id\n    assert_not_includes income_ids, loan_payment_entry.entryable.id\n    assert_not_includes income_ids, transfer_entry.entryable.id\n\n    # Test combined expense and income filter (excludes transfer kinds but includes one_time)\n    non_transfer_results = Transaction::Search.new(@family, filters: { types: [ \"expense\", \"income\" ] }).transactions_scope\n    non_transfer_ids = non_transfer_results.pluck(:id)\n\n    assert_includes non_transfer_ids, standard_entry.entryable.id\n    assert_includes non_transfer_ids, income_entry.entryable.id\n    assert_includes non_transfer_ids, one_time_entry.entryable.id\n    assert_not_includes non_transfer_ids, loan_payment_entry.entryable.id\n    assert_not_includes non_transfer_ids, transfer_entry.entryable.id\n    assert_not_includes non_transfer_ids, payment_entry.entryable.id\n  end\n\n  test \"search category filter handles uncategorized transactions correctly with kind filtering\" do\n    # Create uncategorized transactions of different kinds\n    uncategorized_standard = create_transaction(\n      account: @checking_account,\n      amount: 100,\n      kind: \"standard\"\n    )\n\n    uncategorized_transfer = create_transaction(\n      account: @checking_account,\n      amount: 200,\n      kind: \"funds_movement\"\n    )\n\n    uncategorized_loan_payment = create_transaction(\n      account: @loan_account,\n      amount: 300,\n      kind: \"loan_payment\"\n    )\n\n    # Search for uncategorized transactions\n    uncategorized_results = Transaction::Search.new(@family, filters: { categories: [ \"Uncategorized\" ] }).transactions_scope\n    uncategorized_ids = uncategorized_results.pluck(:id)\n\n    # Should include standard uncategorized transactions\n    assert_includes uncategorized_ids, uncategorized_standard.entryable.id\n    # Should include loan_payment since it's treated specially in category logic\n    assert_includes uncategorized_ids, uncategorized_loan_payment.entryable.id\n\n    # Should exclude transfer transactions even if uncategorized\n    assert_not_includes uncategorized_ids, uncategorized_transfer.entryable.id\n  end\n\n  test \"new family-based API works correctly\" do\n    # Create transactions for testing\n    transaction1 = create_transaction(\n      account: @checking_account,\n      amount: 100,\n      category: categories(:food_and_drink),\n      kind: \"standard\"\n    )\n\n    transaction2 = create_transaction(\n      account: @checking_account,\n      amount: 200,\n      kind: \"funds_movement\"\n    )\n\n    # Test new family-based API\n    search = Transaction::Search.new(@family, filters: { types: [ \"expense\" ] })\n    results = search.transactions_scope\n    result_ids = results.pluck(:id)\n\n    # Should include expense transactions\n    assert_includes result_ids, transaction1.entryable.id\n    # Should exclude transfer transactions\n    assert_not_includes result_ids, transaction2.entryable.id\n\n    # Test that the relation builds from family.transactions correctly\n    assert_equal @family.transactions.joins(entry: :account).where(\n      \"entries.amount >= 0 AND NOT (transactions.kind IN ('funds_movement', 'cc_payment', 'loan_payment'))\"\n    ).count, results.count\n  end\n\n  test \"family-based API requires family parameter\" do\n    assert_raises(NoMethodError) do\n      search = Transaction::Search.new({ types: [ \"expense\" ] })\n      search.transactions_scope  # This will fail when trying to call .transactions on a Hash\n    end\n  end\n\n  # Totals method tests (lifted from Transaction::TotalsTest)\n\n  test \"totals computes basic expense and income totals\" do\n    # Create expense transaction\n    expense_entry = create_transaction(\n      account: @checking_account,\n      amount: 100,\n      category: categories(:food_and_drink),\n      kind: \"standard\"\n    )\n\n    # Create income transaction\n    income_entry = create_transaction(\n      account: @checking_account,\n      amount: -200,\n      kind: \"standard\"\n    )\n\n    search = Transaction::Search.new(@family)\n    totals = search.totals\n\n    assert_equal 2, totals.count\n    assert_equal Money.new(100, \"USD\"), totals.expense_money # $100\n    assert_equal Money.new(200, \"USD\"), totals.income_money  # $200\n  end\n\n  test \"totals handles multi-currency transactions with exchange rates\" do\n    # Create EUR transaction\n    eur_entry = create_transaction(\n      account: @checking_account,\n      amount: 100,\n      currency: \"EUR\",\n      kind: \"standard\"\n    )\n\n    # Create exchange rate EUR -> USD\n    ExchangeRate.create!(\n      from_currency: \"EUR\",\n      to_currency: \"USD\",\n      rate: 1.1,\n      date: eur_entry.date\n    )\n\n    # Create USD transaction\n    usd_entry = create_transaction(\n      account: @checking_account,\n      amount: 50,\n      currency: \"USD\",\n      kind: \"standard\"\n    )\n\n    search = Transaction::Search.new(@family)\n    totals = search.totals\n\n    assert_equal 2, totals.count\n    # EUR 100 * 1.1 + USD 50 = 110 + 50 = 160\n    assert_equal Money.new(160, \"USD\"), totals.expense_money\n    assert_equal Money.new(0, \"USD\"), totals.income_money\n  end\n\n  test \"totals handles missing exchange rates gracefully\" do\n    # Create EUR transaction without exchange rate\n    eur_entry = create_transaction(\n      account: @checking_account,\n      amount: 100,\n      currency: \"EUR\",\n      kind: \"standard\"\n    )\n\n    search = Transaction::Search.new(@family)\n    totals = search.totals\n\n    assert_equal 1, totals.count\n    # Should use rate of 1 when exchange rate is missing\n    assert_equal Money.new(100, \"USD\"), totals.expense_money # EUR 100 * 1\n    assert_equal Money.new(0, \"USD\"), totals.income_money\n  end\n\n  test \"totals respects category filters\" do\n    # Create transactions in different categories\n    food_entry = create_transaction(\n      account: @checking_account,\n      amount: 100,\n      category: categories(:food_and_drink),\n      kind: \"standard\"\n    )\n\n    other_entry = create_transaction(\n      account: @checking_account,\n      amount: 50,\n      category: categories(:income),\n      kind: \"standard\"\n    )\n\n    # Filter by food category only\n    search = Transaction::Search.new(@family, filters: { categories: [ \"Food & Drink\" ] })\n    totals = search.totals\n\n    assert_equal 1, totals.count\n    assert_equal Money.new(100, \"USD\"), totals.expense_money # Only food transaction\n    assert_equal Money.new(0, \"USD\"), totals.income_money\n  end\n\n  test \"totals respects type filters\" do\n    # Create expense and income transactions\n    expense_entry = create_transaction(\n      account: @checking_account,\n      amount: 100,\n      kind: \"standard\"\n    )\n\n    income_entry = create_transaction(\n      account: @checking_account,\n      amount: -200,\n      kind: \"standard\"\n    )\n\n    # Filter by expense type only\n    search = Transaction::Search.new(@family, filters: { types: [ \"expense\" ] })\n    totals = search.totals\n\n    assert_equal 1, totals.count\n    assert_equal Money.new(100, \"USD\"), totals.expense_money\n    assert_equal Money.new(0, \"USD\"), totals.income_money\n  end\n\n  test \"totals handles empty results\" do\n    search = Transaction::Search.new(@family)\n    totals = search.totals\n\n    assert_equal 0, totals.count\n    assert_equal Money.new(0, \"USD\"), totals.expense_money\n    assert_equal Money.new(0, \"USD\"), totals.income_money\n  end\nend\n"
  },
  {
    "path": "test/models/transaction_import_test.rb",
    "content": "require \"test_helper\"\n\nclass TransactionImportTest < ActiveSupport::TestCase\n  include ActiveJob::TestHelper, ImportInterfaceTest\n\n  setup do\n    @subject = @import = imports(:transaction)\n  end\n\n  test \"uploaded? if raw_file_str is present\" do\n    @import.expects(:raw_file_str).returns(\"test\").once\n    assert @import.uploaded?\n  end\n\n  test \"configured? if uploaded and rows are generated\" do\n    @import.expects(:uploaded?).returns(true).once\n    assert @import.configured?\n  end\n\n  test \"cleaned? if rows are generated and valid\" do\n    @import.expects(:configured?).returns(true).once\n    assert @import.cleaned?\n  end\n\n  test \"publishable? if cleaned and mappings are valid\" do\n    @import.expects(:cleaned?).returns(true).once\n    assert @import.publishable?\n  end\n\n  test \"imports transactions, categories, tags, and accounts\" do\n    import = <<~CSV\n      date,name,amount,category,tags,account,notes\n      01/01/2024,Txn1,100,TestCategory1,TestTag1,TestAccount1,notes1\n      01/02/2024,Txn2,200,TestCategory2,TestTag1|TestTag2,TestAccount2,notes2\n      01/03/2024,Txn3,300,,,,notes3\n    CSV\n\n    @import.update!(\n      raw_file_str: import,\n      date_col_label: \"date\",\n      amount_col_label: \"amount\",\n      date_format: \"%m/%d/%Y\"\n    )\n\n    @import.generate_rows_from_csv\n\n    @import.mappings.create! key: \"TestCategory1\", create_when_empty: true, type: \"Import::CategoryMapping\"\n    @import.mappings.create! key: \"TestCategory2\", mappable: categories(:food_and_drink), type: \"Import::CategoryMapping\"\n    @import.mappings.create! key: \"\", create_when_empty: false, mappable: nil, type: \"Import::CategoryMapping\" # Leaves uncategorized\n\n    @import.mappings.create! key: \"TestTag1\", create_when_empty: true, type: \"Import::TagMapping\"\n    @import.mappings.create! key: \"TestTag2\", mappable: tags(:one), type: \"Import::TagMapping\"\n    @import.mappings.create! key: \"\", create_when_empty: false, mappable: nil, type: \"Import::TagMapping\" # Leaves untagged\n\n    @import.mappings.create! key: \"TestAccount1\", create_when_empty: true, type: \"Import::AccountMapping\"\n    @import.mappings.create! key: \"TestAccount2\", mappable: accounts(:depository), type: \"Import::AccountMapping\"\n    @import.mappings.create! key: \"\", mappable: accounts(:depository), type: \"Import::AccountMapping\"\n\n    @import.reload\n\n    assert_difference -> { Entry.count } => 3,\n                      -> { Transaction.count } => 3,\n                      -> { Tag.count } => 1,\n                      -> { Category.count } => 1,\n                      -> { Account.count } => 1 do\n      @import.publish\n    end\n\n    assert_equal \"complete\", @import.status\n  end\n\n  test \"imports transactions with separate type column for signage convention\" do\n    import = <<~CSV\n      date,amount,amount_type\n      01/01/2024,100,debit\n      01/02/2024,200,credit\n      01/03/2024,300,debit\n    CSV\n\n    @import.update!(\n      account: accounts(:depository),\n      raw_file_str: import,\n      date_col_label: \"date\",\n      date_format: \"%m/%d/%Y\",\n      amount_col_label: \"amount\",\n      entity_type_col_label: \"amount_type\",\n      amount_type_inflow_value: \"debit\",\n      amount_type_strategy: \"custom_column\",\n      signage_convention: nil # Explicitly set to nil to prove this is not needed\n    )\n\n    @import.generate_rows_from_csv\n\n    @import.reload\n\n    assert_difference -> { Entry.count } => 3,\n                      -> { Transaction.count } => 3 do\n      @import.publish\n    end\n\n    assert_equal [ -100, 200, -300 ], @import.entries.map(&:amount)\n  end\nend\n"
  },
  {
    "path": "test/models/transfer/creator_test.rb",
    "content": "require \"test_helper\"\n\nclass Transfer::CreatorTest < ActiveSupport::TestCase\n  setup do\n    @family = families(:dylan_family)\n    @source_account = accounts(:depository)\n    @destination_account = accounts(:investment)\n    @date = Date.current\n    @amount = 100\n  end\n\n  test \"creates basic transfer\" do\n    creator = Transfer::Creator.new(\n      family: @family,\n      source_account_id: @source_account.id,\n      destination_account_id: @destination_account.id,\n      date: @date,\n      amount: @amount\n    )\n\n    transfer = creator.create\n\n    assert transfer.persisted?\n    assert_equal \"confirmed\", transfer.status\n    assert transfer.regular_transfer?\n    assert_equal \"transfer\", transfer.transfer_type\n\n    # Verify outflow transaction (from source account)\n    outflow = transfer.outflow_transaction\n    assert_equal \"funds_movement\", outflow.kind\n    assert_equal @amount, outflow.entry.amount\n    assert_equal @source_account.currency, outflow.entry.currency\n    assert_equal \"Transfer to #{@destination_account.name}\", outflow.entry.name\n\n    # Verify inflow transaction (to destination account)\n    inflow = transfer.inflow_transaction\n    assert_equal \"funds_movement\", inflow.kind\n    assert_equal(@amount * -1, inflow.entry.amount)\n    assert_equal @destination_account.currency, inflow.entry.currency\n    assert_equal \"Transfer from #{@source_account.name}\", inflow.entry.name\n  end\n\n  test \"creates multi-currency transfer\" do\n    # Use crypto account which has USD currency but different from source\n    crypto_account = accounts(:crypto)\n\n    creator = Transfer::Creator.new(\n      family: @family,\n      source_account_id: @source_account.id,\n      destination_account_id: crypto_account.id,\n      date: @date,\n      amount: @amount\n    )\n\n    transfer = creator.create\n\n    assert transfer.persisted?\n    assert transfer.regular_transfer?\n    assert_equal \"transfer\", transfer.transfer_type\n\n    # Verify outflow transaction\n    outflow = transfer.outflow_transaction\n    assert_equal \"funds_movement\", outflow.kind\n    assert_equal \"Transfer to #{crypto_account.name}\", outflow.entry.name\n\n    # Verify inflow transaction with currency handling\n    inflow = transfer.inflow_transaction\n    assert_equal \"funds_movement\", inflow.kind\n    assert_equal \"Transfer from #{@source_account.name}\", inflow.entry.name\n    assert_equal crypto_account.currency, inflow.entry.currency\n  end\n\n  test \"creates loan payment\" do\n    loan_account = accounts(:loan)\n\n    creator = Transfer::Creator.new(\n      family: @family,\n      source_account_id: @source_account.id,\n      destination_account_id: loan_account.id,\n      date: @date,\n      amount: @amount\n    )\n\n    transfer = creator.create\n\n    assert transfer.persisted?\n    assert transfer.loan_payment?\n    assert_equal \"loan_payment\", transfer.transfer_type\n\n    # Verify outflow transaction is marked as loan payment\n    outflow = transfer.outflow_transaction\n    assert_equal \"loan_payment\", outflow.kind\n    assert_equal \"Payment to #{loan_account.name}\", outflow.entry.name\n\n    # Verify inflow transaction\n    inflow = transfer.inflow_transaction\n    assert_equal \"funds_movement\", inflow.kind\n    assert_equal \"Payment from #{@source_account.name}\", inflow.entry.name\n  end\n\n  test \"creates credit card payment\" do\n    credit_card_account = accounts(:credit_card)\n\n    creator = Transfer::Creator.new(\n      family: @family,\n      source_account_id: @source_account.id,\n      destination_account_id: credit_card_account.id,\n      date: @date,\n      amount: @amount\n    )\n\n    transfer = creator.create\n\n    assert transfer.persisted?\n    assert transfer.liability_payment?\n    assert_equal \"liability_payment\", transfer.transfer_type\n\n    # Verify outflow transaction is marked as payment for liability\n    outflow = transfer.outflow_transaction\n    assert_equal \"cc_payment\", outflow.kind\n    assert_equal \"Payment to #{credit_card_account.name}\", outflow.entry.name\n\n    # Verify inflow transaction\n    inflow = transfer.inflow_transaction\n    assert_equal \"funds_movement\", inflow.kind\n    assert_equal \"Payment from #{@source_account.name}\", inflow.entry.name\n  end\n\n  test \"raises error when source account ID is invalid\" do\n    assert_raises(ActiveRecord::RecordNotFound) do\n      Transfer::Creator.new(\n        family: @family,\n        source_account_id: 99999,\n        destination_account_id: @destination_account.id,\n        date: @date,\n        amount: @amount\n      )\n    end\n  end\n\n  test \"raises error when destination account ID is invalid\" do\n    assert_raises(ActiveRecord::RecordNotFound) do\n      Transfer::Creator.new(\n        family: @family,\n        source_account_id: @source_account.id,\n        destination_account_id: 99999,\n        date: @date,\n        amount: @amount\n      )\n    end\n  end\n\n  test \"raises error when source account belongs to different family\" do\n    other_family = families(:empty)\n\n    assert_raises(ActiveRecord::RecordNotFound) do\n      Transfer::Creator.new(\n        family: other_family,\n        source_account_id: @source_account.id,\n        destination_account_id: @destination_account.id,\n        date: @date,\n        amount: @amount\n      )\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/transfer_test.rb",
    "content": "require \"test_helper\"\n\nclass TransferTest < ActiveSupport::TestCase\n  include EntriesTestHelper\n\n  setup do\n    @outflow = transactions(:transfer_out)\n    @inflow = transactions(:transfer_in)\n  end\n\n  test \"transfer destroyed if either transaction is destroyed\" do\n    assert_difference [ \"Transfer.count\", \"Transaction.count\", \"Entry.count\" ], -1 do\n      @outflow.entry.destroy\n    end\n  end\n\n  test \"transfer has different accounts, opposing amounts, and within 4 days of each other\" do\n    outflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:depository), amount: 500)\n    inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500)\n\n    assert_difference -> { Transfer.count } => 1 do\n      Transfer.create!(\n        inflow_transaction: inflow_entry.transaction,\n        outflow_transaction: outflow_entry.transaction,\n      )\n    end\n  end\n\n  test \"transfer cannot have 2 transactions from the same account\" do\n    outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)\n    inflow_entry = create_transaction(date: 1.day.ago.to_date, account: accounts(:depository), amount: -500)\n\n    transfer = Transfer.new(\n      inflow_transaction: inflow_entry.transaction,\n      outflow_transaction: outflow_entry.transaction,\n    )\n\n    assert_no_difference -> { Transfer.count } do\n      transfer.save\n    end\n\n    assert_equal \"Must be from different accounts\", transfer.errors.full_messages.first\n  end\n\n  test \"Transfer transactions must have opposite amounts\" do\n    outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)\n    inflow_entry = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -400)\n\n    transfer = Transfer.new(\n      inflow_transaction: inflow_entry.transaction,\n      outflow_transaction: outflow_entry.transaction,\n    )\n\n    assert_no_difference -> { Transfer.count } do\n      transfer.save\n    end\n\n    assert_equal \"Must have opposite amounts\", transfer.errors.full_messages.first\n  end\n\n  test \"transfer dates must be within 4 days of each other\" do\n    outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)\n    inflow_entry = create_transaction(date: 5.days.ago.to_date, account: accounts(:credit_card), amount: -500)\n\n    transfer = Transfer.new(\n      inflow_transaction: inflow_entry.transaction,\n      outflow_transaction: outflow_entry.transaction,\n    )\n\n    assert_no_difference -> { Transfer.count } do\n      transfer.save\n    end\n\n    assert_equal \"Must be within 4 days\", transfer.errors.full_messages.first\n  end\n\n  test \"transfer must be from the same family\" do\n    family1 = families(:empty)\n    family2 = families(:dylan_family)\n\n    family1_account = family1.accounts.create!(name: \"Family 1 Account\", balance: 5000, currency: \"USD\", accountable: Depository.new)\n    family2_account = family2.accounts.create!(name: \"Family 2 Account\", balance: 5000, currency: \"USD\", accountable: Depository.new)\n\n    outflow_txn = create_transaction(date: Date.current, account: family1_account, amount: 500)\n    inflow_txn = create_transaction(date: Date.current, account: family2_account, amount: -500)\n\n    transfer = Transfer.new(\n      inflow_transaction: inflow_txn.transaction,\n      outflow_transaction: outflow_txn.transaction,\n    )\n\n    assert transfer.invalid?\n    assert_equal \"Must be from same family\", transfer.errors.full_messages.first\n  end\n\n  test \"transaction can only belong to one transfer\" do\n    outflow_entry = create_transaction(date: Date.current, account: accounts(:depository), amount: 500)\n    inflow_entry1 = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500)\n    inflow_entry2 = create_transaction(date: Date.current, account: accounts(:credit_card), amount: -500)\n\n    Transfer.create!(inflow_transaction: inflow_entry1.transaction, outflow_transaction: outflow_entry.transaction)\n\n    assert_raises ActiveRecord::RecordInvalid do\n      Transfer.create!(inflow_transaction: inflow_entry2.transaction, outflow_transaction: outflow_entry.transaction)\n    end\n  end\nend\n"
  },
  {
    "path": "test/models/trend_test.rb",
    "content": "require \"test_helper\"\n\nclass TrendTest < ActiveSupport::TestCase\n  test \"handles money trend\" do\n    trend = Trend.new(current: Money.new(100), previous: Money.new(50))\n    assert_equal \"up\", trend.direction\n    assert_equal Money.new(50), trend.value\n    assert_equal 100.0, trend.percent\n  end\n\n  test \"up\" do\n    trend = Trend.new(current: 100, previous: 50)\n    assert_equal \"up\", trend.direction\n    assert_equal \"var(--color-success)\", trend.color\n  end\n\n  test \"down\" do\n    trend = Trend.new(current: 50, previous: 100)\n    assert_equal \"down\", trend.direction\n    assert_equal \"var(--color-destructive)\", trend.color\n  end\n\n  test \"flat\" do\n    trend1 = Trend.new(current: 100, previous: 100)\n    trend2 = Trend.new(current: 100, previous: nil)\n    assert_equal \"flat\", trend1.direction\n    assert_equal \"up\", trend2.direction\n    assert_equal \"var(--color-gray)\", trend1.color\n  end\n\n  test \"infinitely up\" do\n    trend = Trend.new(current: 100, previous: 0)\n    assert_equal \"up\", trend.direction\n  end\n\n  test \"infinitely down\" do\n    trend = Trend.new(current: 0, previous: 100)\n    assert_equal \"down\", trend.direction\n  end\nend\n"
  },
  {
    "path": "test/models/user_message_test.rb",
    "content": "require \"test_helper\"\n\nclass UserMessageTest < ActiveSupport::TestCase\n  setup do\n    @chat = chats(:one)\n  end\n\n  test \"requests assistant response after creation\" do\n    @chat.expects(:ask_assistant_later).once\n\n    message = UserMessage.create!(chat: @chat, content: \"Hello from user\", ai_model: \"gpt-4.1\")\n    message.update!(content: \"updated\")\n\n    streams = capture_turbo_stream_broadcasts(@chat)\n    assert_equal 2, streams.size\n    assert_equal \"append\", streams.first[\"action\"]\n    assert_equal \"messages\", streams.first[\"target\"]\n    assert_equal \"update\", streams.last[\"action\"]\n    assert_equal \"user_message_#{message.id}\", streams.last[\"target\"]\n  end\nend\n"
  },
  {
    "path": "test/models/user_test.rb",
    "content": "require \"test_helper\"\n\nclass UserTest < ActiveSupport::TestCase\n  def setup\n    @user = users(:family_admin)\n  end\n\n  test \"should be valid\" do\n    assert @user.valid?, @user.errors.full_messages.to_sentence\n  end\n\n  # email\n  test \"email must be present\" do\n    potential_user = User.new(\n      email: \"david@davidbowie.com\",\n      password_digest: BCrypt::Password.create(\"password\"),\n      first_name: \"David\",\n      last_name: \"Bowie\"\n    )\n    potential_user.email = \"     \"\n    assert_not potential_user.valid?\n  end\n\n  test \"has email address\" do\n    assert_equal \"bob@bobdylan.com\", @user.email\n  end\n\n  test \"can update email\" do\n    @user.update(email: \"new_email@example.com\")\n    assert_equal \"new_email@example.com\", @user.email\n  end\n\n  test \"email addresses must be unique\" do\n    duplicate_user = @user.dup\n    duplicate_user.email = @user.email.upcase\n    @user.save\n    assert_not duplicate_user.valid?\n  end\n\n  test \"email address is normalized\" do\n    @user.update!(email: \" UNIQUE-User@ExAMPle.CoM \")\n    assert_equal \"unique-user@example.com\", @user.reload.email\n  end\n\n  test \"display name\" do\n    user = User.new(email: \"user@example.com\")\n    assert_equal \"user@example.com\", user.display_name\n    user.first_name = \"Bob\"\n    assert_equal \"Bob\", user.display_name\n    user.last_name = \"Dylan\"\n    assert_equal \"Bob Dylan\", user.display_name\n  end\n\n  test \"initial\" do\n    user = User.new(email: \"user@example.com\")\n    assert_equal \"U\", user.initial\n    user.first_name = \"Bob\"\n    assert_equal \"B\", user.initial\n    user.first_name = nil\n    user.last_name = \"Dylan\"\n    assert_equal \"D\", user.initial\n  end\n\n  test \"names are normalized\" do\n    @user.update!(first_name: \"\", last_name: \"\")\n    assert_nil @user.first_name\n    assert_nil @user.last_name\n\n    @user.update!(first_name: \" Bob \", last_name: \" Dylan \")\n    assert_equal \"Bob\", @user.first_name\n    assert_equal \"Dylan\", @user.last_name\n  end\n\n  # MFA Tests\n  test \"setup_mfa! generates required fields\" do\n    user = users(:family_member)\n    user.setup_mfa!\n\n    assert user.otp_secret.present?\n    assert_not user.otp_required?\n    assert_empty user.otp_backup_codes\n  end\n\n  test \"enable_mfa! enables MFA and generates backup codes\" do\n    user = users(:family_member)\n    user.setup_mfa!\n    user.enable_mfa!\n\n    assert user.otp_required?\n    assert_equal 8, user.otp_backup_codes.length\n    assert user.otp_backup_codes.all? { |code| code.length == 8 }\n  end\n\n  test \"disable_mfa! removes all MFA data\" do\n    user = users(:family_member)\n    user.setup_mfa!\n    user.enable_mfa!\n    user.disable_mfa!\n\n    assert_nil user.otp_secret\n    assert_not user.otp_required?\n    assert_empty user.otp_backup_codes\n  end\n\n  test \"verify_otp? validates TOTP codes\" do\n    user = users(:family_member)\n    user.setup_mfa!\n\n    totp = ROTP::TOTP.new(user.otp_secret, issuer: \"Maybe\")\n    valid_code = totp.now\n\n    assert user.verify_otp?(valid_code)\n    assert_not user.verify_otp?(\"invalid\")\n    assert_not user.verify_otp?(\"123456\")\n  end\n\n  test \"verify_otp? accepts backup codes\" do\n    user = users(:family_member)\n    user.setup_mfa!\n    user.enable_mfa!\n\n    backup_code = user.otp_backup_codes.first\n    assert user.verify_otp?(backup_code)\n\n    # Backup code should be consumed\n    assert_not user.otp_backup_codes.include?(backup_code)\n    assert_equal 7, user.otp_backup_codes.length\n\n    # Used backup code should not work again\n    assert_not user.verify_otp?(backup_code)\n  end\n\n  test \"provisioning_uri generates correct URI\" do\n    user = users(:family_member)\n    user.setup_mfa!\n\n    assert_match %r{otpauth://totp/}, user.provisioning_uri\n    assert_match %r{secret=#{user.otp_secret}}, user.provisioning_uri\n    assert_match %r{issuer=Maybe}, user.provisioning_uri\n  end\nend\n"
  },
  {
    "path": "test/models/valuation/name_test.rb",
    "content": "require \"test_helper\"\n\nclass Valuation::NameTest < ActiveSupport::TestCase\n  # Opening anchor tests\n  test \"generates opening anchor name for Property\" do\n    name = Valuation::Name.new(\"opening_anchor\", \"Property\")\n    assert_equal \"Original purchase price\", name.to_s\n  end\n\n  test \"generates opening anchor name for Loan\" do\n    name = Valuation::Name.new(\"opening_anchor\", \"Loan\")\n    assert_equal \"Original principal\", name.to_s\n  end\n\n  test \"generates opening anchor name for Investment\" do\n    name = Valuation::Name.new(\"opening_anchor\", \"Investment\")\n    assert_equal \"Opening account value\", name.to_s\n  end\n\n  test \"generates opening anchor name for Vehicle\" do\n    name = Valuation::Name.new(\"opening_anchor\", \"Vehicle\")\n    assert_equal \"Original purchase price\", name.to_s\n  end\n\n  test \"generates opening anchor name for Crypto\" do\n    name = Valuation::Name.new(\"opening_anchor\", \"Crypto\")\n    assert_equal \"Opening account value\", name.to_s\n  end\n\n  test \"generates opening anchor name for OtherAsset\" do\n    name = Valuation::Name.new(\"opening_anchor\", \"OtherAsset\")\n    assert_equal \"Opening account value\", name.to_s\n  end\n\n  test \"generates opening anchor name for other account types\" do\n    name = Valuation::Name.new(\"opening_anchor\", \"Depository\")\n    assert_equal \"Opening balance\", name.to_s\n  end\n\n  # Current anchor tests\n  test \"generates current anchor name for Property\" do\n    name = Valuation::Name.new(\"current_anchor\", \"Property\")\n    assert_equal \"Current market value\", name.to_s\n  end\n\n  test \"generates current anchor name for Loan\" do\n    name = Valuation::Name.new(\"current_anchor\", \"Loan\")\n    assert_equal \"Current loan balance\", name.to_s\n  end\n\n  test \"generates current anchor name for Investment\" do\n    name = Valuation::Name.new(\"current_anchor\", \"Investment\")\n    assert_equal \"Current account value\", name.to_s\n  end\n\n  test \"generates current anchor name for Vehicle\" do\n    name = Valuation::Name.new(\"current_anchor\", \"Vehicle\")\n    assert_equal \"Current market value\", name.to_s\n  end\n\n  test \"generates current anchor name for Crypto\" do\n    name = Valuation::Name.new(\"current_anchor\", \"Crypto\")\n    assert_equal \"Current account value\", name.to_s\n  end\n\n  test \"generates current anchor name for OtherAsset\" do\n    name = Valuation::Name.new(\"current_anchor\", \"OtherAsset\")\n    assert_equal \"Current account value\", name.to_s\n  end\n\n  test \"generates current anchor name for other account types\" do\n    name = Valuation::Name.new(\"current_anchor\", \"Depository\")\n    assert_equal \"Current balance\", name.to_s\n  end\n\n  # Reconciliation tests\n  test \"generates recon name for Property\" do\n    name = Valuation::Name.new(\"reconciliation\", \"Property\")\n    assert_equal \"Manual value update\", name.to_s\n  end\n\n  test \"generates recon name for Investment\" do\n    name = Valuation::Name.new(\"reconciliation\", \"Investment\")\n    assert_equal \"Manual value update\", name.to_s\n  end\n\n  test \"generates recon name for Vehicle\" do\n    name = Valuation::Name.new(\"reconciliation\", \"Vehicle\")\n    assert_equal \"Manual value update\", name.to_s\n  end\n\n  test \"generates recon name for Crypto\" do\n    name = Valuation::Name.new(\"reconciliation\", \"Crypto\")\n    assert_equal \"Manual value update\", name.to_s\n  end\n\n  test \"generates recon name for OtherAsset\" do\n    name = Valuation::Name.new(\"reconciliation\", \"OtherAsset\")\n    assert_equal \"Manual value update\", name.to_s\n  end\n\n  test \"generates recon name for Loan\" do\n    name = Valuation::Name.new(\"reconciliation\", \"Loan\")\n    assert_equal \"Manual principal update\", name.to_s\n  end\n\n  test \"generates recon name for other account types\" do\n    name = Valuation::Name.new(\"reconciliation\", \"Depository\")\n    assert_equal \"Manual balance update\", name.to_s\n  end\nend\n"
  },
  {
    "path": "test/services/api_rate_limiter_test.rb",
    "content": "require \"test_helper\"\n\nclass ApiRateLimiterTest < ActiveSupport::TestCase\n  setup do\n    @user = users(:family_admin)\n    # Destroy any existing active API keys for this user\n    @user.api_keys.active.destroy_all\n\n    @api_key = ApiKey.create!(\n      user: @user,\n      name: \"Rate Limiter Test Key\",\n      scopes: [ \"read\" ],\n      display_key: \"rate_limiter_test_#{SecureRandom.hex(8)}\"\n    )\n    @rate_limiter = ApiRateLimiter.new(@api_key)\n\n    # Clear any existing rate limit data\n    Redis.new.del(\"api_rate_limit:#{@api_key.id}\")\n  end\n\n  teardown do\n    # Clean up Redis data after each test\n    Redis.new.del(\"api_rate_limit:#{@api_key.id}\")\n  end\n\n  test \"should have default rate limit\" do\n    assert_equal 100, @rate_limiter.rate_limit\n  end\n\n  test \"should start with zero request count\" do\n    assert_equal 0, @rate_limiter.current_count\n  end\n\n  test \"should not be rate limited initially\" do\n    assert_not @rate_limiter.rate_limit_exceeded?\n  end\n\n  test \"should increment request count\" do\n    assert_equal 0, @rate_limiter.current_count\n\n    @rate_limiter.increment_request_count!\n    assert_equal 1, @rate_limiter.current_count\n\n    @rate_limiter.increment_request_count!\n    assert_equal 2, @rate_limiter.current_count\n  end\n\n  test \"should be rate limited when exceeding limit\" do\n    # Simulate reaching the rate limit\n    100.times { @rate_limiter.increment_request_count! }\n\n    assert_equal 100, @rate_limiter.current_count\n    assert @rate_limiter.rate_limit_exceeded?\n  end\n\n  test \"should provide correct usage info\" do\n    5.times { @rate_limiter.increment_request_count! }\n\n    usage_info = @rate_limiter.usage_info\n\n    assert_equal 5, usage_info[:current_count]\n    assert_equal 100, usage_info[:rate_limit]\n    assert_equal 95, usage_info[:remaining]\n    assert_equal :standard, usage_info[:tier]\n    assert usage_info[:reset_time] > 0\n    assert usage_info[:reset_time] <= 3600\n  end\n\n  test \"should calculate remaining requests correctly\" do\n    10.times { @rate_limiter.increment_request_count! }\n\n    usage_info = @rate_limiter.usage_info\n    assert_equal 90, usage_info[:remaining]\n  end\n\n  test \"should have zero remaining when at limit\" do\n    100.times { @rate_limiter.increment_request_count! }\n\n    usage_info = @rate_limiter.usage_info\n    assert_equal 0, usage_info[:remaining]\n  end\n\n  test \"should have zero remaining when over limit\" do\n    105.times { @rate_limiter.increment_request_count! }\n\n    usage_info = @rate_limiter.usage_info\n    assert_equal 0, usage_info[:remaining]\n  end\n\n  test \"class method usage_for should work without incrementing\" do\n    5.times { @rate_limiter.increment_request_count! }\n\n    usage_info = ApiRateLimiter.usage_for(@api_key)\n    assert_equal 5, usage_info[:current_count]\n\n    # Should not increment when just checking usage\n    usage_info_again = ApiRateLimiter.usage_for(@api_key)\n    assert_equal 5, usage_info_again[:current_count]\n  end\n\n  test \"should handle multiple API keys separately\" do\n    # Create a different user for the second API key\n    other_user = users(:family_member)\n    other_api_key = ApiKey.create!(\n      user: other_user,\n      name: \"Other API Key\",\n      scopes: [ \"read_write\" ],\n      display_key: \"rate_limiter_other_#{SecureRandom.hex(8)}\"\n    )\n\n    other_rate_limiter = ApiRateLimiter.new(other_api_key)\n\n    @rate_limiter.increment_request_count!\n    other_rate_limiter.increment_request_count!\n    other_rate_limiter.increment_request_count!\n\n    assert_equal 1, @rate_limiter.current_count\n    assert_equal 2, other_rate_limiter.current_count\n  ensure\n    Redis.new.del(\"api_rate_limit:#{other_api_key.id}\")\n    other_api_key.destroy\n  end\n\n  test \"should calculate reset time correctly\" do\n    reset_time = @rate_limiter.reset_time\n\n    # Reset time should be within the current hour\n    assert reset_time > 0\n    assert reset_time <= 3600\n\n    # Should be roughly the time until the next hour\n    current_time = Time.current.to_i\n    next_window = ((current_time / 3600) + 1) * 3600\n    expected_reset = next_window - current_time\n\n    assert_in_delta expected_reset, reset_time, 1\n  end\nend\n"
  },
  {
    "path": "test/services/noop_api_rate_limiter_test.rb",
    "content": "require \"test_helper\"\n\nclass NoopApiRateLimiterTest < ActiveSupport::TestCase\n  setup do\n    @user = users(:family_admin)\n    # Clean up any existing API keys for this user to ensure tests start fresh\n    @user.api_keys.destroy_all\n\n    @api_key = ApiKey.create!(\n      user: @user,\n      name: \"Noop Rate Limiter Test Key\",\n      scopes: [ \"read\" ],\n      display_key: \"noop_rate_limiter_test_#{SecureRandom.hex(8)}\"\n    )\n    @rate_limiter = NoopApiRateLimiter.new(@api_key)\n  end\n\n  test \"should never be rate limited\" do\n    assert_not @rate_limiter.rate_limit_exceeded?\n  end\n\n  test \"should not increment request count\" do\n    @rate_limiter.increment_request_count!\n    assert_equal 0, @rate_limiter.current_count\n  end\n\n  test \"should always have zero request count\" do\n    assert_equal 0, @rate_limiter.current_count\n  end\n\n  test \"should have infinite rate limit\" do\n    assert_equal Float::INFINITY, @rate_limiter.rate_limit\n  end\n\n  test \"should have zero reset time\" do\n    assert_equal 0, @rate_limiter.reset_time\n  end\n\n  test \"should provide correct usage info\" do\n    usage_info = @rate_limiter.usage_info\n\n    assert_equal 0, usage_info[:current_count]\n    assert_equal Float::INFINITY, usage_info[:rate_limit]\n    assert_equal Float::INFINITY, usage_info[:remaining]\n    assert_equal 0, usage_info[:reset_time]\n    assert_equal :noop, usage_info[:tier]\n  end\n\n  test \"class method usage_for should work\" do\n    usage_info = NoopApiRateLimiter.usage_for(@api_key)\n\n    assert_equal 0, usage_info[:current_count]\n    assert_equal Float::INFINITY, usage_info[:rate_limit]\n    assert_equal Float::INFINITY, usage_info[:remaining]\n    assert_equal 0, usage_info[:reset_time]\n    assert_equal :noop, usage_info[:tier]\n  end\nend\n"
  },
  {
    "path": "test/support/balance_test_helper.rb",
    "content": "module BalanceTestHelper\n  def create_balance(account:, date:, balance:, cash_balance: nil, **attributes)\n    # If cash_balance is not provided, default to entire balance being cash\n    cash_balance ||= balance\n\n    # Calculate non-cash balance\n    non_cash_balance = balance - cash_balance\n\n    # Set default component values that will generate the desired end_balance\n    # flows_factor should be 1 for assets, -1 for liabilities\n    flows_factor = account.classification == \"liability\" ? -1 : 1\n\n    defaults = {\n      date: date,\n      balance: balance,\n      cash_balance: cash_balance,\n      currency: account.currency,\n      start_cash_balance: cash_balance,\n      start_non_cash_balance: non_cash_balance,\n      cash_inflows: 0,\n      cash_outflows: 0,\n      non_cash_inflows: 0,\n      non_cash_outflows: 0,\n      net_market_flows: 0,\n      cash_adjustments: 0,\n      non_cash_adjustments: 0,\n      flows_factor: flows_factor\n    }\n\n    account.balances.create!(defaults.merge(attributes))\n  end\n\n  def create_balance_with_flows(account:, date:, start_balance:, end_balance:,\n                                cash_portion: 1.0, cash_flow: 0, non_cash_flow: 0,\n                                market_flow: 0, **attributes)\n    # Calculate cash and non-cash portions\n    start_cash = start_balance * cash_portion\n    start_non_cash = start_balance * (1 - cash_portion)\n\n    # Calculate adjustments needed to reach end_balance\n    expected_end_cash = start_cash + cash_flow\n    expected_end_non_cash = start_non_cash + non_cash_flow + market_flow\n    expected_total = expected_end_cash + expected_end_non_cash\n\n    # Calculate adjustments if end_balance doesn't match expected\n    total_adjustment = end_balance - expected_total\n    cash_adjustment = cash_portion * total_adjustment\n    non_cash_adjustment = (1 - cash_portion) * total_adjustment\n\n    # flows_factor should be 1 for assets, -1 for liabilities\n    flows_factor = account.classification == \"liability\" ? -1 : 1\n\n    defaults = {\n      date: date,\n      balance: end_balance,\n      cash_balance: expected_end_cash + cash_adjustment,\n      currency: account.currency,\n      start_cash_balance: start_cash,\n      start_non_cash_balance: start_non_cash,\n      cash_inflows: cash_flow > 0 ? cash_flow : 0,\n      cash_outflows: cash_flow < 0 ? -cash_flow : 0,\n      non_cash_inflows: non_cash_flow > 0 ? non_cash_flow : 0,\n      non_cash_outflows: non_cash_flow < 0 ? -non_cash_flow : 0,\n      net_market_flows: market_flow,\n      cash_adjustments: cash_adjustment,\n      non_cash_adjustments: non_cash_adjustment,\n      flows_factor: flows_factor\n    }\n\n    account.balances.create!(defaults.merge(attributes))\n  end\nend\n"
  },
  {
    "path": "test/support/entries_test_helper.rb",
    "content": "module EntriesTestHelper\n  def create_transaction(attributes = {})\n    entry_attributes = attributes.except(:category, :tags, :merchant, :kind)\n    transaction_attributes = attributes.slice(:category, :tags, :merchant, :kind)\n\n    entry_defaults = {\n      account: accounts(:depository),\n      name: \"Transaction\",\n      date: Date.current,\n      currency: \"USD\",\n      amount: 100,\n      entryable: Transaction.new(transaction_attributes)\n    }\n\n    Entry.create! entry_defaults.merge(entry_attributes)\n  end\n\n  def create_valuation(attributes = {})\n    entry_attributes = attributes.except(:kind)\n    valuation_attributes = attributes.slice(:kind)\n\n    account = attributes[:account] || accounts(:depository)\n    amount = attributes[:amount] || 5000\n\n    entry_defaults = {\n      account: account,\n      name: \"Valuation\",\n      date: 1.day.ago.to_date,\n      currency: \"USD\",\n      amount: amount,\n      entryable: Valuation.new({ kind: \"reconciliation\" }.merge(valuation_attributes))\n    }\n\n    Entry.create! entry_defaults.merge(entry_attributes)\n  end\n\n  def create_trade(security, account:, qty:, date:, price: nil, currency: \"USD\")\n    trade_price = price || Security::Price.find_by!(security: security, date: date).price\n\n    trade = Trade.new \\\n      qty: qty,\n      security: security,\n      price: trade_price,\n      currency: currency\n\n    account.entries.create! \\\n      name: \"Trade\",\n      date: date,\n      amount: qty * trade_price,\n      currency: currency,\n      entryable: trade\n  end\n\n  def create_transfer(from_account:, to_account:, amount:, date: Date.current, currency: \"USD\")\n    outflow_transaction = Transaction.create!(kind: \"funds_movement\")\n    inflow_transaction = Transaction.create!(kind: \"funds_movement\")\n\n    transfer = Transfer.create!(\n      outflow_transaction: outflow_transaction,\n      inflow_transaction: inflow_transaction\n    )\n\n    # Create entries for both accounts\n    from_account.entries.create!(\n      name: \"Transfer to #{to_account.name}\",\n      date: date,\n      amount: -amount.abs,\n      currency: currency,\n      entryable: outflow_transaction\n    )\n\n    to_account.entries.create!(\n      name: \"Transfer from #{from_account.name}\",\n      date: date,\n      amount: amount.abs,\n      currency: currency,\n      entryable: inflow_transaction\n    )\n\n    transfer\n  end\nend\n"
  },
  {
    "path": "test/support/ledger_testing_helper.rb",
    "content": "module LedgerTestingHelper\n  def create_account_with_ledger(account:, entries: [], exchange_rates: [], security_prices: [], holdings: [])\n    # Clear all exchange rates and security prices to ensure clean test environment\n    ExchangeRate.destroy_all\n    Security::Price.destroy_all\n\n    # Create account with specified attributes\n    account_attrs = account.except(:type)\n    account_type = account[:type]\n\n    # Create the account\n    created_account = families(:empty).accounts.create!(\n      name: \"Test Account\",\n      accountable: account_type.new,\n      balance: account[:balance] || 0, # Doesn't matter, ledger derives this\n      cash_balance: account[:cash_balance] || 0, # Doesn't matter, ledger derives this\n      **account_attrs\n    )\n\n    # Set up exchange rates if provided\n    exchange_rates.each do |rate_data|\n      ExchangeRate.create!(\n        date: rate_data[:date],\n        from_currency: rate_data[:from],\n        to_currency: rate_data[:to],\n        rate: rate_data[:rate]\n      )\n    end\n\n    # Set up security prices if provided\n    security_prices.each do |price_data|\n      security = Security.find_or_create_by!(ticker: price_data[:ticker]) do |s|\n        s.name = price_data[:ticker]\n      end\n\n      Security::Price.create!(\n        security: security,\n        date: price_data[:date],\n        price: price_data[:price],\n        currency: created_account.currency\n      )\n    end\n\n    # Create entries in the order they were specified\n    entries.each do |entry_data|\n      case entry_data[:type]\n      when \"current_anchor\", \"opening_anchor\", \"reconciliation\"\n        # Create valuation entry\n        created_account.entries.create!(\n          name: \"Valuation\",\n          date: entry_data[:date],\n          amount: entry_data[:balance],\n          currency: entry_data[:currency] || created_account.currency,\n          entryable: Valuation.new(kind: entry_data[:type])\n        )\n      when \"transaction\"\n        # Use account currency if not specified\n        currency = entry_data[:currency] || created_account.currency\n\n        created_account.entries.create!(\n          name: \"Transaction\",\n          date: entry_data[:date],\n          amount: entry_data[:amount],\n          currency: currency,\n          entryable: Transaction.new\n        )\n      when \"trade\"\n        # Find or create security\n        security = Security.find_or_create_by!(ticker: entry_data[:ticker]) do |s|\n          s.name = entry_data[:ticker]\n        end\n\n        # Use account currency if not specified\n        currency = entry_data[:currency] || created_account.currency\n\n        trade = Trade.new(\n          qty: entry_data[:qty],\n          security: security,\n          price: entry_data[:price],\n          currency: currency\n        )\n\n        created_account.entries.create!(\n          name: \"Trade\",\n          date: entry_data[:date],\n          amount: entry_data[:qty] * entry_data[:price],\n          currency: currency,\n          entryable: trade\n        )\n      end\n    end\n\n    # Create holdings if provided\n    holdings.each do |holding_data|\n      # Find or create security\n      security = Security.find_or_create_by!(ticker: holding_data[:ticker]) do |s|\n        s.name = holding_data[:ticker]\n      end\n\n      Holding.create!(\n        account: created_account,\n        security: security,\n        date: holding_data[:date],\n        qty: holding_data[:qty],\n        price: holding_data[:price],\n        amount: holding_data[:amount],\n        currency: holding_data[:currency] || created_account.currency\n      )\n    end\n\n    created_account\n  end\n\n  def assert_calculated_ledger_balances(calculated_data:, expected_data:)\n    # Convert expected data to a hash for easier lookup\n    # Structure: [ { date:, legacy_balances: { balance:, cash_balance: }, balances: { start:, start_cash:, etc... }, flows: { ... }, adjustments: { ... } } ]\n    expected_hash = {}\n    expected_data.each do |data|\n      expected_hash[data[:date].to_date] = {\n        legacy_balances: data[:legacy_balances] || {},\n        balances: data[:balances] || {},\n        flows: data[:flows] || {},\n        adjustments: data[:adjustments] || {}\n      }\n    end\n\n    # Get all unique dates from all data sources\n    all_dates = (calculated_data.map(&:date) + expected_hash.keys).uniq.sort\n\n    # Check each date\n    all_dates.each do |date|\n      calculated_balance = calculated_data.find { |b| b.date == date }\n      expected = expected_hash[date]\n\n      if expected\n        assert calculated_balance, \"Expected balance for #{date} but none was calculated\"\n\n        # Always assert flows_factor is correct based on account classification\n        expected_flows_factor = calculated_balance.account.classification == \"asset\" ? 1 : -1\n        assert_equal expected_flows_factor, calculated_balance.flows_factor,\n          \"Flows factor mismatch for #{date}: expected #{expected_flows_factor} for #{calculated_balance.account.classification} account\"\n\n        legacy_balances = expected[:legacy_balances]\n        balances = expected[:balances]\n        flows = expected[:flows]\n        adjustments = expected[:adjustments]\n\n        # Legacy balance assertions\n        if legacy_balances.any?\n          assert_equal legacy_balances[:balance], calculated_balance.balance,\n            \"Balance mismatch for #{date}\"\n\n          assert_equal legacy_balances[:cash_balance], calculated_balance.cash_balance,\n            \"Cash balance mismatch for #{date}\"\n        end\n\n        # Balance assertions\n        if balances.any?\n          assert_equal balances[:start_cash], calculated_balance.start_cash_balance,\n            \"Start cash balance mismatch for #{date}\" if balances.key?(:start_cash)\n\n          assert_equal balances[:start_non_cash], calculated_balance.start_non_cash_balance,\n            \"Start non-cash balance mismatch for #{date}\" if balances.key?(:start_non_cash)\n\n          # Calculate end_cash_balance using the formula from the migration\n          if balances.key?(:end_cash)\n            # Determine flows_factor based on account classification\n            flows_factor = calculated_balance.account.classification == \"asset\" ? 1 : -1\n            expected_end_cash = calculated_balance.start_cash_balance +\n                               ((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +\n                               calculated_balance.cash_adjustments\n            assert_equal balances[:end_cash], expected_end_cash,\n              \"End cash balance mismatch for #{date}\"\n          end\n\n          # Calculate end_non_cash_balance using the formula from the migration\n          if balances.key?(:end_non_cash)\n            # Determine flows_factor based on account classification\n            flows_factor = calculated_balance.account.classification == \"asset\" ? 1 : -1\n            expected_end_non_cash = calculated_balance.start_non_cash_balance +\n                                   ((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +\n                                   calculated_balance.net_market_flows +\n                                   calculated_balance.non_cash_adjustments\n            assert_equal balances[:end_non_cash], expected_end_non_cash,\n              \"End non-cash balance mismatch for #{date}\"\n          end\n\n          # Calculate start_balance using the formula from the migration\n          if balances.key?(:start)\n            expected_start = calculated_balance.start_cash_balance + calculated_balance.start_non_cash_balance\n            assert_equal balances[:start], expected_start,\n              \"Start balance mismatch for #{date}\"\n          end\n\n          # Calculate end_balance using the formula from the migration since we're not persisting balances,\n          # and generated columns are not available until the record is persisted\n          if balances.key?(:end)\n            # Determine flows_factor based on account classification\n            flows_factor = calculated_balance.account.classification == \"asset\" ? 1 : -1\n            expected_end_cash_component = calculated_balance.start_cash_balance +\n                                         ((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +\n                                         calculated_balance.cash_adjustments\n            expected_end_non_cash_component = calculated_balance.start_non_cash_balance +\n                                             ((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +\n                                             calculated_balance.net_market_flows +\n                                             calculated_balance.non_cash_adjustments\n            expected_end = expected_end_cash_component + expected_end_non_cash_component\n            assert_equal balances[:end], expected_end,\n              \"End balance mismatch for #{date}\"\n          end\n        end\n\n        # Flow assertions\n        # If flows passed is 0, we assert all columns are 0\n        if flows.is_a?(Integer) && flows == 0\n          assert_equal 0, calculated_balance.cash_inflows,\n            \"Cash inflows mismatch for #{date}\"\n\n          assert_equal 0, calculated_balance.cash_outflows,\n            \"Cash outflows mismatch for #{date}\"\n\n          assert_equal 0, calculated_balance.non_cash_inflows,\n            \"Non-cash inflows mismatch for #{date}\"\n\n          assert_equal 0, calculated_balance.non_cash_outflows,\n            \"Non-cash outflows mismatch for #{date}\"\n\n          assert_equal 0, calculated_balance.net_market_flows,\n            \"Net market flows mismatch for #{date}\"\n        elsif flows.is_a?(Hash) && flows.any?\n          # Cash flows - must be asserted together\n          if flows.key?(:cash_inflows) || flows.key?(:cash_outflows)\n            assert flows.key?(:cash_inflows) && flows.key?(:cash_outflows),\n              \"Cash inflows and outflows must be asserted together for #{date}\"\n\n            assert_equal flows[:cash_inflows], calculated_balance.cash_inflows,\n              \"Cash inflows mismatch for #{date}\"\n\n            assert_equal flows[:cash_outflows], calculated_balance.cash_outflows,\n              \"Cash outflows mismatch for #{date}\"\n          end\n\n          # Non-cash flows - must be asserted together\n          if flows.key?(:non_cash_inflows) || flows.key?(:non_cash_outflows)\n            assert flows.key?(:non_cash_inflows) && flows.key?(:non_cash_outflows),\n              \"Non-cash inflows and outflows must be asserted together for #{date}\"\n\n            assert_equal flows[:non_cash_inflows], calculated_balance.non_cash_inflows,\n              \"Non-cash inflows mismatch for #{date}\"\n\n            assert_equal flows[:non_cash_outflows], calculated_balance.non_cash_outflows,\n              \"Non-cash outflows mismatch for #{date}\"\n          end\n\n          # Market flows - can be asserted independently\n          if flows.key?(:net_market_flows)\n            assert_equal flows[:net_market_flows], calculated_balance.net_market_flows,\n              \"Net market flows mismatch for #{date}\"\n          end\n        end\n\n        # Adjustment assertions\n        if adjustments.is_a?(Integer) && adjustments == 0\n          assert_equal 0, calculated_balance.cash_adjustments,\n            \"Cash adjustments mismatch for #{date}\"\n\n          assert_equal 0, calculated_balance.non_cash_adjustments,\n            \"Non-cash adjustments mismatch for #{date}\"\n        elsif adjustments.is_a?(Hash) && adjustments.any?\n          assert_equal adjustments[:cash_adjustments], calculated_balance.cash_adjustments,\n            \"Cash adjustments mismatch for #{date}\" if adjustments.key?(:cash_adjustments)\n\n          assert_equal adjustments[:non_cash_adjustments], calculated_balance.non_cash_adjustments,\n            \"Non-cash adjustments mismatch for #{date}\" if adjustments.key?(:non_cash_adjustments)\n        end\n\n        # Temporary assertions during migration (remove after migration complete)\n        # TODO: Remove these assertions after migration is complete\n        # Since we're not persisting balances, we calculate the end values\n        flows_factor = calculated_balance.account.classification == \"asset\" ? 1 : -1\n        expected_end_cash = calculated_balance.start_cash_balance +\n                           ((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +\n                           calculated_balance.cash_adjustments\n        expected_end_balance = expected_end_cash +\n                              calculated_balance.start_non_cash_balance +\n                              ((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +\n                              calculated_balance.net_market_flows +\n                              calculated_balance.non_cash_adjustments\n\n        assert_equal calculated_balance.cash_balance, expected_end_cash,\n          \"Temporary assertion failed: end_cash_balance should equal cash_balance for #{date}\"\n\n        assert_equal calculated_balance.balance, expected_end_balance,\n          \"Temporary assertion failed: end_balance should equal balance for #{date}\"\n      else\n        assert_nil calculated_balance, \"Unexpected balance calculated for #{date}\"\n      end\n    end\n\n    # Verify we got all expected dates\n    expected_dates = expected_hash.keys.sort\n    calculated_dates = calculated_data.map(&:date).sort\n\n    expected_dates.each do |date|\n      assert_includes calculated_dates, date,\n        \"Expected balance for #{date} was not in calculated data\"\n    end\n  end\nend\n"
  },
  {
    "path": "test/support/provider_test_helper.rb",
    "content": "module ProviderTestHelper\n  def provider_success_response(data)\n    Provider::Response.new(\n      success?: true,\n      data: data,\n      error: nil\n    )\n  end\n\n  def provider_error_response(error)\n    Provider::Response.new(\n      success?: false,\n      data: nil,\n      error: error\n    )\n  end\nend\n"
  },
  {
    "path": "test/support/securities_test_helper.rb",
    "content": "module SecuritiesTestHelper\n  def create_security(ticker, prices:)\n    security = Security.create!(\n      ticker: ticker,\n      exchange_mic: \"XNAS\"\n    )\n\n    prices.each do |price|\n      Security::Price.create!(\n        security: security,\n        date: price[:date],\n        price: price[:price],\n        currency: \"USD\"\n      )\n    end\n\n    security\n  end\nend\n"
  },
  {
    "path": "test/system/accounts_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass AccountsTest < ApplicationSystemTestCase\n  setup do\n    sign_in @user = users(:family_admin)\n\n    Family.any_instance.stubs(:get_link_token).returns(\"test-link-token\")\n\n    visit root_url\n    open_new_account_modal\n  end\n\n  test \"can create depository account\" do\n    assert_account_created(\"Depository\")\n  end\n\n  test \"can create investment account\" do\n    assert_account_created(\"Investment\")\n  end\n\n  test \"can create crypto account\" do\n    assert_account_created(\"Crypto\")\n  end\n\n  test \"can create property account\" do\n    # Step 1: Select property type and enter basic details\n    click_link \"Property\"\n\n    account_name = \"[system test] Property Account\"\n    fill_in \"Name*\", with: account_name\n    select \"Single Family Home\", from: \"Property type*\"\n    fill_in \"Year Built (optional)\", with: 2005\n    fill_in \"Area (optional)\", with: 2250\n\n    click_button \"Next\"\n\n    # Step 2: Enter balance information\n    assert_text \"Value\"\n    fill_in \"account[balance]\", with: 500000\n    click_button \"Next\"\n\n    # Step 3: Enter address information\n    assert_text \"Address\"\n    fill_in \"Address Line 1\", with: \"123 Main St\"\n    fill_in \"City\", with: \"San Francisco\"\n    fill_in \"State/Region\", with: \"CA\"\n    fill_in \"Postal Code\", with: \"94101\"\n    fill_in \"Country\", with: \"US\"\n\n    click_button \"Save\"\n\n    # Verify account was created and is now active\n    assert_text account_name\n\n    created_account = Account.order(:created_at).last\n    assert_equal \"active\", created_account.status\n    assert_equal 500000, created_account.balance\n    assert_equal \"123 Main St\", created_account.property.address.line1\n    assert_equal \"San Francisco\", created_account.property.address.locality\n  end\n\n  test \"can create vehicle account\" do\n    assert_account_created \"Vehicle\" do\n      fill_in \"Make\", with: \"Toyota\"\n      fill_in \"Model\", with: \"Camry\"\n      fill_in \"Year\", with: \"2020\"\n      fill_in \"Mileage\", with: \"30000\"\n    end\n  end\n\n  test \"can create other asset account\" do\n    assert_account_created(\"OtherAsset\")\n  end\n\n  test \"can create credit card account\" do\n    assert_account_created \"CreditCard\" do\n      fill_in \"Available credit\", with: 1000\n      fill_in \"account[accountable_attributes][minimum_payment]\", with: 25.51\n      fill_in \"APR\", with: 15.25\n      fill_in \"Expiration date\", with: 1.year.from_now.to_date\n      fill_in \"Annual fee\", with: 100\n    end\n  end\n\n  test \"can create loan account\" do\n    assert_account_created \"Loan\" do\n      fill_in \"account[accountable_attributes][initial_balance]\", with: 1000\n      fill_in \"Interest rate\", with: 5.25\n      select \"Fixed\", from: \"Rate type\"\n      fill_in \"Term (months)\", with: 360\n    end\n  end\n\n  test \"can create other liability account\" do\n    assert_account_created(\"OtherLiability\")\n  end\n\n  private\n\n    def open_new_account_modal\n      within \"[data-controller='DS--tabs']\" do\n        click_button \"All\"\n        click_link \"New account\"\n      end\n    end\n\n    def assert_account_created(accountable_type, &block)\n      click_link Accountable.from_type(accountable_type).display_name.singularize\n      click_link \"Enter account balance\" if accountable_type.in?(%w[Depository Investment Crypto Loan CreditCard])\n\n      account_name = \"[system test] #{accountable_type} Account\"\n\n      fill_in \"Account name*\", with: account_name\n      fill_in \"account[balance]\", with: 100.99\n\n      yield if block_given?\n\n      click_button \"Create Account\"\n\n      within_testid(\"account-sidebar-tabs\") do\n        click_on \"All\"\n        find(\"details\", text: Accountable.from_type(accountable_type).display_name).click\n        assert_text account_name\n      end\n\n      visit accounts_url\n      assert_text account_name\n\n      created_account = Account.order(:created_at).last\n\n      visit account_url(created_account)\n\n      within_testid(\"account-menu\") do\n        find(\"button\").click\n        click_on \"Edit\"\n      end\n\n      fill_in \"Account name\", with: \"Updated account name\"\n      click_button \"Update Account\"\n      assert_selector \"h2\", text: \"Updated account name\"\n    end\n\n    def humanized_accountable(accountable_type)\n      Accountable.from_type(accountable_type).display_name.singularize\n    end\nend\n"
  },
  {
    "path": "test/system/categories_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass CategoriesTest < ApplicationSystemTestCase\n  setup do\n    sign_in @user = users(:family_admin)\n  end\n\n  test \"can create category\" do\n    visit categories_url\n    click_link I18n.t(\"categories.new.new_category\")\n    fill_in \"Name\", with: \"My Shiny New Category\"\n    click_button \"Create Category\"\n\n    visit categories_url\n    assert_text \"My Shiny New Category\"\n  end\n\n  test \"trying to create a duplicate category fails\" do\n    visit categories_url\n    click_link I18n.t(\"categories.new.new_category\")\n    fill_in \"Name\", with: categories(:food_and_drink).name\n    click_button \"Create Category\"\n\n    assert_text \"Name has already been taken\"\n  end\nend\n"
  },
  {
    "path": "test/system/chats_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass ChatsTest < ApplicationSystemTestCase\n  setup do\n    @user = users(:family_admin)\n    login_as(@user)\n  end\n\n  test \"sidebar shows consent if ai is disabled for user\" do\n    @user.update!(ai_enabled: false)\n\n    visit root_path\n\n    within \"#chat-container\" do\n      assert_selector \"h3\", text: \"Enable Maybe AI\"\n    end\n  end\n\n  test \"sidebar shows index when enabled and chats are empty\" do\n    @user.update!(ai_enabled: true)\n    @user.chats.destroy_all\n\n    visit root_url\n\n    within \"#chat-container\" do\n      assert_selector \"h1\", text: \"Chats\"\n    end\n  end\n\n  test \"sidebar shows last viewed chat\" do\n    @user.update!(ai_enabled: true)\n\n    click_on @user.chats.first.title\n\n    # Page refresh\n    visit root_url\n\n    # After page refresh, we're still on the last chat we were viewing\n    within \"#chat-container\" do\n      assert_selector \"h1\", text: @user.chats.first.title\n    end\n  end\n\n  test \"create chat and navigate chats sidebar\" do\n    @user.chats.destroy_all\n\n    visit root_url\n\n    Chat.any_instance.expects(:ask_assistant_later).once\n\n    within \"#chat-form\" do\n      fill_in \"chat[content]\", with: \"Can you help with my finances?\"\n      find(\"button[type='submit']\").click\n    end\n\n    assert_text \"Can you help with my finances?\"\n\n    find(\"#chat-nav-back\").click\n\n    assert_selector \"h1\", text: \"Chats\"\n\n    click_on @user.chats.reload.first.title\n\n    assert_text \"Can you help with my finances?\"\n  end\nend\n"
  },
  {
    "path": "test/system/imports_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass ImportsTest < ApplicationSystemTestCase\n  include ActiveJob::TestHelper\n\n  setup do\n    sign_in @user = users(:family_admin)\n\n    # Trade securities will be imported as \"offline\" tickers\n    Security.stubs(:provider).returns(nil)\n  end\n\n  test \"transaction import\" do\n    visit new_import_path\n\n    click_on \"Import transactions\"\n\n    within_testid(\"import-tabs\") do\n      click_on \"Copy & Paste\"\n    end\n\n    fill_in \"import[raw_file_str]\", with: file_fixture(\"imports/transactions.csv\").read\n\n    within \"form\" do\n      click_on \"Upload CSV\"\n    end\n\n    select \"Date\", from: \"import[date_col_label]\"\n    select \"YYYY-MM-DD\", from: \"import[date_format]\"\n    select \"Amount\", from: \"import[amount_col_label]\"\n    select \"Account\", from: \"import[account_col_label]\"\n    select \"Name\", from: \"import[name_col_label]\"\n    select \"Category\", from: \"import[category_col_label]\"\n    select \"Tags\", from: \"import[tags_col_label]\"\n    select \"Notes\", from: \"import[notes_col_label]\"\n\n    click_on \"Apply configuration\"\n\n    click_on \"Next step\"\n\n    assert_selector \"h1\", text: \"Assign your categories\"\n    click_on \"Next\"\n\n    assert_selector \"h1\", text: \"Assign your tags\"\n    click_on \"Next\"\n\n    assert_selector \"h1\", text: \"Assign your accounts\"\n    click_on \"Next\"\n\n    click_on \"Publish import\"\n\n    assert_text \"Import in progress\"\n\n    perform_enqueued_jobs\n\n    click_on \"Check status\"\n\n    assert_text \"Import successful\"\n\n    click_on \"Back to dashboard\"\n  end\n\n  test \"trade import\" do\n    visit new_import_path\n\n    click_on \"Import investments\"\n\n    within_testid(\"import-tabs\") do\n      click_on \"Copy & Paste\"\n    end\n\n    fill_in \"import[raw_file_str]\", with: file_fixture(\"imports/trades.csv\").read\n\n    within \"form\" do\n      click_on \"Upload CSV\"\n    end\n\n    select \"date\", from: \"import[date_col_label]\"\n    select \"YYYY-MM-DD\", from: \"import[date_format]\"\n    select \"qty\", from: \"import[qty_col_label]\"\n    select \"ticker\", from: \"import[ticker_col_label]\"\n    select \"price\", from: \"import[price_col_label]\"\n    select \"account\", from: \"import[account_col_label]\"\n\n    click_on \"Apply configuration\"\n\n    click_on \"Next step\"\n\n    assert_selector \"h1\", text: \"Assign your accounts\"\n    click_on \"Next\"\n\n    click_on \"Publish import\"\n\n    assert_text \"Import in progress\"\n\n    perform_enqueued_jobs\n\n    click_on \"Check status\"\n\n    assert_text \"Import successful\"\n\n    click_on \"Back to dashboard\"\n  end\n\n  test \"account import\" do\n    visit new_import_path\n\n    click_on \"Import accounts\"\n\n    within_testid(\"import-tabs\") do\n      click_on \"Copy & Paste\"\n    end\n\n    fill_in \"import[raw_file_str]\", with: file_fixture(\"imports/accounts.csv\").read\n\n    within \"form\" do\n      click_on \"Upload CSV\"\n    end\n\n    select \"type\", from: \"import[entity_type_col_label]\"\n    select \"name\", from: \"import[name_col_label]\"\n    select \"amount\", from: \"import[amount_col_label]\"\n\n    click_on \"Apply configuration\"\n\n    click_on \"Next step\"\n\n    assert_selector \"h1\", text: \"Assign your account types\"\n\n    all(\"form\").each do |form|\n      within(form) do\n        select = form.find(\"select\")\n        select \"Depository\", from: select[\"id\"]\n        sleep 0.5\n      end\n    end\n\n    click_on \"Next\"\n\n    click_on \"Publish import\"\n\n    assert_text \"Import in progress\"\n\n    perform_enqueued_jobs\n\n    click_on \"Check status\"\n\n    assert_text \"Import successful\"\n\n    click_on \"Back to dashboard\"\n  end\n\n  test \"mint import\" do\n    visit new_import_path\n\n    click_on \"Import from Mint\"\n\n    within_testid(\"import-tabs\") do\n      click_on \"Copy & Paste\"\n    end\n\n    fill_in \"import[raw_file_str]\", with: file_fixture(\"imports/mint.csv\").read\n\n    within \"form\" do\n      click_on \"Upload CSV\"\n    end\n\n    click_on \"Apply configuration\"\n\n    click_on \"Next step\"\n\n    assert_selector \"h1\", text: \"Assign your categories\"\n    click_on \"Next\"\n\n    assert_selector \"h1\", text: \"Assign your tags\"\n    click_on \"Next\"\n\n    assert_selector \"h1\", text: \"Assign your accounts\"\n    click_on \"Next\"\n\n    click_on \"Publish import\"\n\n    assert_text \"Import in progress\"\n\n    perform_enqueued_jobs\n\n    click_on \"Check status\"\n\n    assert_text \"Import successful\"\n\n    click_on \"Back to dashboard\"\n  end\nend\n"
  },
  {
    "path": "test/system/onboardings_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass OnboardingsTest < ApplicationSystemTestCase\n  setup do\n    @user = users(:family_admin)\n    @family = @user.family\n\n    # Reset onboarding state\n    @user.update!(set_onboarding_preferences_at: nil)\n\n    sign_in @user\n  end\n\n  test \"can complete the full onboarding flow\" do\n    # Start at the main onboarding page\n    visit onboarding_path\n\n    assert_text \"Let's set up your account\"\n    assert_button \"Continue\"\n\n    # Navigate to preferences\n    click_button \"Continue\"\n\n    assert_current_path preferences_onboarding_path\n    assert_text \"Configure your preferences\"\n\n    # Test that the chart renders without errors (this would catch the Series bug)\n    assert_selector \"[data-controller='time-series-chart']\"\n\n    # Fill out preferences form\n    select \"English (en)\", from: \"user_family_attributes_locale\"\n    select \"United States Dollar (USD)\", from: \"user_family_attributes_currency\"\n    select \"MM/DD/YYYY\", from: \"user_family_attributes_date_format\"\n    select \"Light\", from: \"user_theme\"\n\n    # Submit preferences\n    click_button \"Complete\"\n\n    # Should redirect to goals page\n    assert_current_path goals_onboarding_path\n    assert_text \"What brings you to Maybe?\"\n  end\n\n  test \"preferences page renders chart without errors\" do\n    visit preferences_onboarding_path\n\n    # This test specifically targets the Series model bug\n    # The chart should render without throwing JavaScript errors\n    assert_selector \"[data-controller='time-series-chart']\"\n    assert_selector \"#previewChart\"\n\n    # Verify the chart data is properly formatted JSON\n    chart_element = find(\"[data-controller='time-series-chart']\")\n    chart_data = chart_element[\"data-time-series-chart-data-value\"]\n\n    # Should be valid JSON\n    assert_nothing_raised do\n      JSON.parse(chart_data)\n    end\n\n    # Verify the preview example shows\n    assert_text \"Example\"\n    assert_text \"$2,325.25\"\n    assert_text \"+$78.90\"\n  end\n\n  test \"can change currency and see preview update\" do\n    visit preferences_onboarding_path\n\n    # Change currency\n    select \"Euro (EUR)\", from: \"user_family_attributes_currency\"\n\n    # The preview should update (this tests the JavaScript controller)\n    # Note: This would require the onboarding controller to handle currency changes\n    assert_text \"Example\"\n  end\n\n  test \"can change date format and see preview update\" do\n    visit preferences_onboarding_path\n\n    # Change date format\n    select \"DD/MM/YYYY\", from: \"user_family_attributes_date_format\"\n\n    # The preview should update\n    assert_text \"Example\"\n  end\n\n  test \"can change theme\" do\n    visit preferences_onboarding_path\n\n    # Change theme\n    select \"Dark\", from: \"user_theme\"\n\n    # Theme should be applied (this tests the JavaScript controller)\n    assert_text \"Example\"\n  end\n\n  test \"preferences form validation\" do\n    visit preferences_onboarding_path\n\n    # Clear required fields and try to submit\n    select \"\", from: \"user_family_attributes_locale\"\n    click_button \"Complete\"\n\n    # Should stay on preferences page with validation errors (may have query params)\n    assert_match %r{/onboarding/preferences}, current_path\n  end\n\n  test \"preferences form saves data correctly\" do\n    visit preferences_onboarding_path\n\n    # Fill out form with specific values\n    select \"Spanish (es)\", from: \"user_family_attributes_locale\"\n    select \"Euro (EUR)\", from: \"user_family_attributes_currency\"\n    select \"DD/MM/YYYY\", from: \"user_family_attributes_date_format\"\n    select \"Dark\", from: \"user_theme\"\n\n    click_button \"Complete\"\n\n    # Wait for redirect to goals page to ensure form was submitted\n    assert_current_path goals_onboarding_path\n\n    # Verify data was saved\n    @family.reload\n    @user.reload\n\n    assert_equal \"es\", @family.locale\n    assert_equal \"EUR\", @family.currency\n    assert_equal \"%d/%m/%Y\", @family.date_format\n    assert_equal \"dark\", @user.theme\n    assert_not_nil @user.set_onboarding_preferences_at\n  end\n\n  test \"goals page renders correctly\" do\n    # Complete preferences first\n    @user.update!(set_onboarding_preferences_at: Time.current)\n\n    visit goals_onboarding_path\n\n    assert_text \"What brings you to Maybe?\"\n    assert_button \"Next\"\n  end\n\n  test \"trial page renders correctly\" do\n    visit trial_onboarding_path\n\n    assert_text \"trial\" # Adjust based on actual content\n  end\n\n  test \"navigation between onboarding steps\" do\n    # Start at main onboarding\n    visit onboarding_path\n    click_button \"Continue\"\n\n    # Should be at preferences\n    assert_current_path preferences_onboarding_path\n\n    # Complete preferences\n    select \"English (en)\", from: \"user_family_attributes_locale\"\n    select \"United States Dollar (USD)\", from: \"user_family_attributes_currency\"\n    select \"MM/DD/YYYY\", from: \"user_family_attributes_date_format\"\n    click_button \"Complete\"\n\n    # Should be at goals\n    assert_current_path goals_onboarding_path\n  end\n\n  test \"onboarding nav shows correct steps\" do\n    visit preferences_onboarding_path\n\n    # Check that navigation shows current step\n    assert_selector \"ul.hidden.md\\\\:flex.items-center.gap-2\"\n  end\n\n  test \"logout option is available during onboarding\" do\n    visit preferences_onboarding_path\n\n    # Should have logout option (rendered as a button component)\n    assert_text \"Sign out\"\n  end\n\n  private\n\n    def sign_in(user)\n      visit new_session_path\n      within \"form\" do\n        fill_in \"Email\", with: user.email\n        fill_in \"Password\", with: user_password_test\n        click_on \"Log in\"\n      end\n\n      # Wait for successful login\n      assert_current_path root_path\n    end\nend\n"
  },
  {
    "path": "test/system/settings/api_keys_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass Settings::ApiKeysTest < ApplicationSystemTestCase\n  setup do\n    @user = users(:family_admin)\n    @user.api_keys.destroy_all # Ensure clean state\n    login_as @user\n  end\n\n  test \"should show no API key state when user has no active keys\" do\n    visit settings_api_key_path\n\n    assert_text \"Create Your API Key\"\n    assert_text \"Get programmatic access to your Maybe data\"\n    assert_text \"Access your account data programmatically\"\n    assert_link \"Create API Key\", href: new_settings_api_key_path\n  end\n\n  test \"should navigate to create new API key form\" do\n    visit settings_api_key_path\n    click_link \"Create API Key\"\n\n    assert_current_path new_settings_api_key_path\n    assert_text \"Create New API Key\"\n    assert_field \"API Key Name\"\n    assert_text \"Read Only\"\n    assert_text \"Read/Write\"\n  end\n\n  test \"should create a new API key with selected scopes\" do\n    visit new_settings_api_key_path\n\n    fill_in \"API Key Name\", with: \"Test Integration Key\"\n    choose \"Read/Write\"\n\n    click_button \"Create API Key\"\n\n    # Should redirect to show page with the API key details\n    assert_current_path settings_api_key_path\n    assert_text \"Test Integration Key\"\n    assert_text \"Your API Key\"\n\n    # Should show the actual API key value\n    api_key_display = find(\"#api-key-display\")\n    assert api_key_display.text.length > 30 # Should be a long hex string\n\n    # Should show copy buttons\n    assert_button \"Copy API Key\"\n    assert_link \"Create New Key\"\n  end\n\n  test \"should show current API key details after creation\" do\n    # Create an API key first\n    api_key = ApiKey.create!(\n      user: @user,\n      name: \"Production API Key\",\n      display_key: \"test_plain_key_123\",\n      scopes: [ \"read_write\" ]\n    )\n\n    visit settings_api_key_path\n\n    assert_text \"Your API Key\"\n    assert_text \"Production API Key\"\n    assert_text \"Active\"\n    assert_text \"Read/Write\"\n    assert_text \"Never used\"\n    assert_link \"Create New Key\"\n    assert_button \"Revoke Key\"\n  end\n\n  test \"should show usage instructions and example curl command\" do\n    api_key = ApiKey.create!(\n      user: @user,\n      name: \"Test API Key\",\n      display_key: \"test_key_123\",\n      scopes: [ \"read\" ]\n    )\n\n    visit settings_api_key_path\n\n    assert_text \"How to use your API key\"\n    assert_text \"curl -H \\\"X-Api-Key: test_key_123\\\"\"\n    assert_text \"/api/v1/accounts\"\n  end\n\n  test \"should allow regenerating API key\" do\n    api_key = ApiKey.create!(\n      user: @user,\n      name: \"Old API Key\",\n      display_key: \"old_key_123\",\n      scopes: [ \"read\" ]\n    )\n\n    visit settings_api_key_path\n    click_link \"Create New Key\"\n\n    # Should be on the new API key form\n    assert_text \"Create New API Key\"\n\n    fill_in \"API Key Name\", with: \"New API Key\"\n    choose \"Read Only\"\n    click_button \"Create API Key\"\n\n    # Should redirect to show page with new key\n    assert_text \"New API Key\"\n    assert_text \"Your API Key\"\n\n    # Old key should be revoked\n    api_key.reload\n    assert api_key.revoked?\n  end\n\n  test \"should allow revoking API key with confirmation\" do\n    api_key = ApiKey.create!(\n      user: @user,\n      name: \"Test API Key\",\n      display_key: \"test_key_123\",\n      scopes: [ \"read\" ]\n    )\n\n    visit settings_api_key_path\n\n    # Click the revoke button to open the modal\n    click_button \"Revoke Key\"\n\n    # Wait for the dialog and then confirm\n    assert_selector \"#confirm-dialog\", visible: true\n    within \"#confirm-dialog\" do\n      click_button \"Confirm\"\n    end\n\n    # Wait for redirect after revoke\n    assert_no_selector \"#confirm-dialog\"\n\n    assert_text \"Create Your API Key\"\n    assert_text \"Get programmatic access to your Maybe data\"\n\n    # Key should be revoked in the database\n    api_key.reload\n    assert api_key.revoked?\n  end\n\n  test \"should redirect to show when user already has active key and tries to visit new\" do\n    api_key = ApiKey.create!(\n      user: @user,\n      name: \"Existing API Key\",\n      display_key: \"existing_key_123\",\n      scopes: [ \"read\" ]\n    )\n\n    visit new_settings_api_key_path\n\n    assert_current_path settings_api_key_path\n  end\n\n  test \"should show API key in navigation\" do\n    visit settings_api_key_path\n\n    within(\"nav\") do\n      assert_text \"API Key\"\n    end\n  end\n\n  test \"should validate API key name is required\" do\n    visit new_settings_api_key_path\n\n    # Try to submit without name\n    choose \"Read Only\"\n    click_button \"Create API Key\"\n\n    # Should stay on form with validation error\n    assert_current_path new_settings_api_key_path\n    assert_field \"API Key Name\" # Form should still be visible\n    # The form might not show the validation error inline, but should remain on the form\n  end\n\n  test \"should show last used timestamp when API key has been used\" do\n    api_key = ApiKey.create!(\n      user: @user,\n      name: \"Used API Key\",\n      display_key: \"used_key_123\",\n      scopes: [ \"read\" ],\n      last_used_at: 2.hours.ago\n    )\n\n    visit settings_api_key_path\n\n    assert_text \"2 hours ago\"\n    assert_no_text \"Never used\"\n  end\n\n  test \"should show expiration date when API key has expiration\" do\n    api_key = ApiKey.create!(\n      user: @user,\n      name: \"Expiring API Key\",\n      display_key: \"expiring_key_123\",\n      scopes: [ \"read\" ],\n      expires_at: 30.days.from_now\n    )\n\n    visit settings_api_key_path\n\n    # Should show some indication of expiration (exact format may vary)\n    assert_no_text \"Never expires\"\n  end\nend\n"
  },
  {
    "path": "test/system/settings_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass SettingsTest < ApplicationSystemTestCase\n  setup do\n    sign_in @user = users(:family_admin)\n\n    @settings_links = [\n      [ \"Account\", settings_profile_path ],\n      [ \"Preferences\", settings_preferences_path ],\n      [ \"Accounts\", accounts_path ],\n      [ \"Tags\", tags_path ],\n      [ \"Categories\", categories_path ],\n      [ \"Merchants\", family_merchants_path ],\n      [ \"Imports\", imports_path ],\n      [ \"What's new\", changelog_path ],\n      [ \"Feedback\", feedback_path ]\n    ]\n  end\n\n  test \"can access settings from sidebar\" do\n    VCR.use_cassette(\"git_repository_provider/fetch_latest_release_notes\") do\n      open_settings_from_sidebar\n      assert_selector \"h1\", text: \"Account\"\n      assert_current_path settings_profile_path, ignore_query: true\n\n      @settings_links.each do |name, path|\n        click_link name\n        assert_selector \"h1\", text: name\n        assert_current_path path\n      end\n    end\n  end\n\n  test \"can update self hosting settings\" do\n    Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)\n    Provider::Registry.stubs(:get_provider).with(:synth).returns(nil)\n    open_settings_from_sidebar\n    assert_selector \"li\", text: \"Self hosting\"\n    click_link \"Self hosting\"\n    assert_current_path settings_hosting_path\n    assert_selector \"h1\", text: \"Self-Hosting\"\n    check \"setting[require_invite_for_signup]\", allow_label_click: true\n    click_button \"Generate new code\"\n    assert_selector 'span[data-clipboard-target=\"source\"]', visible: true, count: 1 # invite code copy widget\n    copy_button = find('button[data-action=\"clipboard#copy\"]', match: :first) # Find the first copy button (adjust if needed)\n    copy_button.click\n    assert_selector 'span[data-clipboard-target=\"iconSuccess\"]', visible: true, count: 1 # text copied and icon changed to checkmark\n  end\n\n  test \"does not show billing link if self hosting\" do\n    Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)\n    open_settings_from_sidebar\n    assert_no_selector \"li\", text: I18n.t(\"settings.settings_nav.billing_label\")\n  end\n\n  private\n\n    def open_settings_from_sidebar\n      within \"div[data-testid=user-menu]\" do\n        find(\"button\").click\n      end\n      click_link \"Settings\"\n    end\nend\n"
  },
  {
    "path": "test/system/trades_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass TradesTest < ApplicationSystemTestCase\n  include ActiveJob::TestHelper\n\n  setup do\n    sign_in @user = users(:family_admin)\n\n    @user.update!(show_sidebar: false, show_ai_sidebar: false)\n\n    @account = accounts(:investment)\n\n    visit_account_portfolio\n\n    # Disable provider to focus on form testing\n    Security.stubs(:provider).returns(nil)\n  end\n\n  test \"can create buy transaction\" do\n    shares_qty = 25\n\n    open_new_trade_modal\n\n    fill_in \"Ticker symbol\", with: \"AAPL\"\n    fill_in \"Date\", with: Date.current\n    fill_in \"Quantity\", with: shares_qty\n    fill_in \"model[price]\", with: 214.23\n\n    click_button \"Add transaction\"\n\n    visit_trades\n\n    within_trades do\n      assert_text \"Buy #{shares_qty}.0 shares of AAPL\"\n    end\n  end\n\n  test \"can create sell transaction\" do\n    qty = 10\n    aapl = @account.holdings.find { |h| h.security.ticker == \"AAPL\" }\n\n    open_new_trade_modal\n\n    select \"Sell\", from: \"Type\"\n    fill_in \"Ticker symbol\", with: \"AAPL\"\n    fill_in \"Date\", with: Date.current\n    fill_in \"Quantity\", with: qty\n    fill_in \"model[price]\", with: 215.33\n\n    click_button \"Add transaction\"\n\n    visit_trades\n\n    within_trades do\n      assert_text \"Sell #{qty}.0 shares of AAPL\"\n    end\n  end\n\n  private\n    def open_new_trade_modal\n      click_on \"New transaction\"\n    end\n\n    def within_trades(&block)\n      within \"#\" + dom_id(@account, \"entries\"), &block\n    end\n\n    def visit_trades\n      visit account_path(@account, tab: \"activity\")\n    end\n\n    def visit_account_portfolio\n      visit account_path(@account, tab: \"holdings\")\n    end\nend\n"
  },
  {
    "path": "test/system/transactions_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass TransactionsTest < ApplicationSystemTestCase\n  setup do\n    sign_in @user = users(:family_admin)\n\n    Entry.delete_all # clean slate\n\n    create_transaction(\"one\", 12.days.ago.to_date, 100)\n    create_transaction(\"two\", 10.days.ago.to_date, 100)\n    create_transaction(\"three\", 9.days.ago.to_date, 100)\n    create_transaction(\"four\", 8.days.ago.to_date, 100)\n    create_transaction(\"five\", 7.days.ago.to_date, 100)\n    create_transaction(\"six\", 7.days.ago.to_date, 100)\n    create_transaction(\"seven\", 4.days.ago.to_date, 100)\n    create_transaction(\"eight\", 3.days.ago.to_date, 100)\n    create_transaction(\"nine\", 1.days.ago.to_date, 100)\n    @uncategorized_transaction = create_transaction(\"ten\", 1.days.ago.to_date, 100)\n    create_transaction(\"eleven\", Date.current, 100, category: categories(:food_and_drink), tags: [ tags(:one) ], merchant: merchants(:amazon))\n\n    @transactions = @user.family.entries\n                         .transactions\n                         .reverse_chronological\n\n    @transaction = @transactions.first\n\n    @page_size = 10\n\n    visit transactions_url(per_page: @page_size)\n  end\n\n  test \"can search for a transaction\" do\n    assert_selector \"h1\", text: \"Transactions\"\n\n    within \"form#transactions-search\" do\n      fill_in \"Search transactions ...\", with: @transaction.name\n      find(\"#q_search\").send_keys(:tab) # Trigger blur to submit form\n    end\n\n    assert_selector \"#\" + dom_id(@transaction), count: 1\n\n    within \"#transaction-search-filters\" do\n      assert_text @transaction.name\n    end\n  end\n\n  test \"can open filters and apply one or more\" do\n    find(\"#transaction-filters-button\").click\n\n    within \"#transaction-filters-menu\" do\n      check(@transaction.account.name)\n      click_button \"Category\"\n      check(@transaction.transaction.category.name)\n      click_button \"Apply\"\n    end\n\n    assert_selector \"#\" + dom_id(@transaction), count: 1\n\n    within \"#transaction-search-filters\" do\n      assert_text @transaction.account.name\n      assert_text @transaction.transaction.category.name\n    end\n  end\n\n  test \"can filter uncategorized transactions\" do\n    find(\"#transaction-filters-button\").click\n\n    within \"#transaction-filters-menu\" do\n      click_button \"Category\"\n      check(\"Uncategorized\")\n      click_button \"Apply\"\n    end\n\n    assert_selector \"#\" + dom_id(@uncategorized_transaction), count: 1\n    assert_no_selector(\"#\" + dom_id(@transaction))\n\n    find(\"#transaction-filters-button\").click\n\n    within \"#transaction-filters-menu\" do\n      click_button \"Category\"\n      check(@transaction.transaction.category.name)\n      click_button \"Apply\"\n    end\n\n    assert_selector \"#\" + dom_id(@transaction), count: 1\n    assert_selector \"#\" + dom_id(@uncategorized_transaction), count: 1\n  end\n\n  test \"all filters work and empty state shows if no match\" do\n    find(\"#transaction-filters-button\").click\n\n    account = @transaction.account\n    category = @transaction.transaction.category\n    merchant = @transaction.transaction.merchant\n\n    within \"#transaction-filters-menu\" do\n      click_button \"Account\"\n      check(account.name)\n\n      click_button \"Date\"\n      fill_in \"q_start_date\", with: 10.days.ago.to_date\n      fill_in \"q_end_date\", with: 1.day.ago.to_date\n\n      click_button \"Type\"\n      check(\"Income\")\n\n      click_button \"Amount\"\n      select \"Less than\"\n      fill_in \"q_amount\", with: 200\n\n      click_button \"Category\"\n      check(category.name)\n\n      click_button \"Merchant\"\n      check(merchant.name)\n\n      click_button \"Apply\"\n    end\n\n    assert_text \"No entries found\"\n\n    # Wait for Turbo to finish updating the DOM\n    sleep 0.5\n\n    # Page reload doesn't affect results\n    visit current_url\n\n    assert_text \"No entries found\"\n\n    # Remove all filters by clicking their X buttons\n    # Get all the filter buttons at once to avoid stale elements\n    filter_count = page.all(\"ul#transaction-search-filters li button\").count\n\n    # Click each one with a small delay to let Turbo update\n    filter_count.times do\n      page.all(\"ul#transaction-search-filters li button\").first.click\n      sleep 0.1\n    end\n\n    assert_text @transaction.name\n  end\n\n  test \"can select and deselect entire page of transactions\" do\n    all_transactions_checkbox.check\n    assert_selection_count(number_of_transactions_on_page)\n    all_transactions_checkbox.uncheck\n    assert_selection_count(0)\n  end\n\n  test \"can select and deselect groups of transactions\" do\n    date_transactions_checkbox(1.day.ago.to_date).check\n    assert_selection_count(2)\n\n    date_transactions_checkbox(1.day.ago.to_date).uncheck\n    assert_selection_count(0)\n  end\n\n  test \"can select and deselect individual transactions\" do\n    transaction_checkbox(@transactions.first).check\n    assert_selection_count(1)\n    transaction_checkbox(@transactions.second).check\n    assert_selection_count(2)\n    transaction_checkbox(@transactions.second).uncheck\n    assert_selection_count(1)\n  end\n\n  test \"outermost group always overrides inner selections\" do\n    transaction_checkbox(@transactions.first).check\n    assert_selection_count(1)\n\n    all_transactions_checkbox.check\n    assert_selection_count(number_of_transactions_on_page)\n\n    transaction_checkbox(@transactions.first).uncheck\n    assert_selection_count(number_of_transactions_on_page - 1)\n\n    date_transactions_checkbox(1.day.ago.to_date).uncheck\n    assert_selection_count(number_of_transactions_on_page - 3)\n\n    all_transactions_checkbox.uncheck\n    assert_selection_count(0)\n  end\n\n\n  test \"can create deposit transaction for investment account\" do\n    investment_account = accounts(:investment)\n    investment_account.entries.create!(name: \"Investment account\", date: Date.current, amount: 1000, currency: \"USD\", entryable: Transaction.new)\n    transfer_date = Date.current\n    visit account_url(investment_account, tab: \"activity\")\n    within \"[data-testid='activity-menu']\" do\n      click_on \"New\"\n      click_on \"New transaction\"\n    end\n    select \"Deposit\", from: \"Type\"\n    fill_in \"Date\", with: transfer_date\n    fill_in \"model[amount]\", with: 175.25\n    click_button \"Add transaction\"\n    within \"#\" + dom_id(investment_account, \"entries_#{transfer_date}\") do\n      assert_text \"175.25\"\n    end\n  end\n\n  test \"transfers should always sum to zero\" do\n    asset_account = accounts(:other_asset)\n    investment_account = accounts(:investment)\n    outflow_entry = create_transaction(\"outflow\", Date.current, 500, account: asset_account)\n    inflow_entry = create_transaction(\"inflow\", 1.day.ago.to_date, -500, account: investment_account)\n    @user.family.auto_match_transfers!\n    visit transactions_url\n\n    within \"#entry-group-\" + Date.current.to_s + \"-totals\" do\n      assert_text \"-$100.00\" # transaction eleven from setup\n    end\n  end\n\n  private\n\n    def create_transaction(name, date, amount, category: nil, merchant: nil, tags: [], account: nil)\n      account ||= accounts(:depository)\n\n      account.entries.create! \\\n        name: name,\n        date: date,\n        amount: amount,\n        currency: \"USD\",\n        entryable: Transaction.new(category: category, merchant: merchant, tags: tags)\n    end\n\n    def number_of_transactions_on_page\n      [ @user.family.entries.count, @page_size ].min\n    end\n\n    def all_transactions_checkbox\n      find(\"#selection_entry\")\n    end\n\n    def date_transactions_checkbox(date)\n      find(\"#selection_entry_#{date}\")\n    end\n\n    def transaction_checkbox(transaction)\n      find(\"#\" + dom_id(transaction, \"selection\"))\n    end\n\n    def assert_selection_count(count)\n      if count == 0\n        assert_no_selector(\"#entry-selection-bar\")\n      else\n        within \"#entry-selection-bar\" do\n          assert_text \"#{count} transaction#{count == 1 ? \"\" : \"s\"} selected\"\n        end\n      end\n    end\nend\n"
  },
  {
    "path": "test/system/transfers_test.rb",
    "content": "require \"application_system_test_case\"\n\nclass TransfersTest < ApplicationSystemTestCase\n  setup do\n    sign_in @user = users(:family_admin)\n    visit transactions_url\n  end\n\n  test \"can create a transfer\" do\n    checking_name = accounts(:depository).name\n    savings_name = accounts(:credit_card).name\n    transfer_date = Date.current\n\n    click_on \"New transaction\"\n\n    # Will navigate to different route in same modal\n    click_on \"Transfer\"\n    assert_text \"New transfer\"\n\n    select checking_name, from: \"From\"\n    select savings_name, from: \"To\"\n    fill_in \"transfer[amount]\", with: 500\n    fill_in \"Date\", with: transfer_date\n\n    click_button \"Create transfer\"\n\n    within \"#entry-group-\" + transfer_date.to_s do\n      assert_text \"Payment to\"\n    end\n  end\nend\n"
  },
  {
    "path": "test/test_helper.rb",
    "content": "if ENV[\"COVERAGE\"] == \"true\"\n  require \"simplecov\"\n  SimpleCov.start \"rails\" do\n    enable_coverage :branch\n  end\nend\n\nrequire_relative \"../config/environment\"\n\nENV[\"RAILS_ENV\"] ||= \"test\"\n\n# Set Plaid to sandbox mode for tests\nENV[\"PLAID_ENV\"] = \"sandbox\"\nENV[\"PLAID_CLIENT_ID\"] ||= \"test_client_id\"\nENV[\"PLAID_SECRET\"] ||= \"test_secret\"\n\n# Fixes Segfaults on M1 Macs when running tests in parallel (temporary workaround)\nENV[\"PGGSSENCMODE\"] = \"disable\"\n\nrequire \"rails/test_help\"\nrequire \"minitest/mock\"\nrequire \"minitest/autorun\"\nrequire \"mocha/minitest\"\nrequire \"aasm/minitest\"\n\nVCR.configure do |config|\n  config.cassette_library_dir = \"test/vcr_cassettes\"\n  config.hook_into :webmock\n  config.ignore_localhost = true\n  config.default_cassette_options = { erb: true }\n  config.filter_sensitive_data(\"<SYNTH_API_KEY>\") { ENV[\"SYNTH_API_KEY\"] }\n  config.filter_sensitive_data(\"<OPENAI_ACCESS_TOKEN>\") { ENV[\"OPENAI_ACCESS_TOKEN\"] }\n  config.filter_sensitive_data(\"<OPENAI_ORGANIZATION_ID>\") { ENV[\"OPENAI_ORGANIZATION_ID\"] }\n  config.filter_sensitive_data(\"<STRIPE_SECRET_KEY>\") { ENV[\"STRIPE_SECRET_KEY\"] }\n  config.filter_sensitive_data(\"<STRIPE_WEBHOOK_SECRET>\") { ENV[\"STRIPE_WEBHOOK_SECRET\"] }\n  config.filter_sensitive_data(\"<PLAID_CLIENT_ID>\") { ENV[\"PLAID_CLIENT_ID\"] }\n  config.filter_sensitive_data(\"<PLAID_SECRET>\") { ENV[\"PLAID_SECRET\"] }\nend\n\nmodule ActiveSupport\n  class TestCase\n    # Run tests in parallel with specified workers\n    parallelize(workers: :number_of_processors) unless ENV[\"DISABLE_PARALLELIZATION\"] == \"true\"\n\n    # https://github.com/simplecov-ruby/simplecov/issues/718#issuecomment-538201587\n    if ENV[\"COVERAGE\"] == \"true\"\n      parallelize_setup do |worker|\n        SimpleCov.command_name \"#{SimpleCov.command_name}-#{worker}\"\n      end\n\n      parallelize_teardown do |worker|\n        SimpleCov.result\n      end\n    end\n\n    # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.\n    fixtures :all\n\n    # Add more helper methods to be used by all tests here...\n    def sign_in(user)\n      post sessions_path, params: { email: user.email, password: user_password_test }\n    end\n\n    def with_env_overrides(overrides = {}, &block)\n      ClimateControl.modify(**overrides, &block)\n    end\n\n    def with_self_hosting\n      Rails.configuration.stubs(:app_mode).returns(\"self_hosted\".inquiry)\n      yield\n    end\n\n    def user_password_test\n      \"maybetestpassword817983172\"\n    end\n  end\nend\n\nDir[Rails.root.join(\"test\", \"interfaces\", \"**\", \"*.rb\")].each { |f| require f }\n"
  },
  {
    "path": "test/vcr_cassettes/git_repository_provider/fetch_latest_release_notes.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: get\n    uri: https://api.github.com/repos/maybe-finance/maybe/releases\n    body:\n      encoding: US-ASCII\n      string: ''\n    headers:\n      Accept:\n      - application/vnd.github.v3+json\n      User-Agent:\n      - Octokit Ruby Gem 9.2.0\n      Content-Type:\n      - application/json\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Wed, 19 Mar 2025 12:40:58 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Cache-Control:\n      - public, max-age=60, s-maxage=60\n      Vary:\n      - Accept,Accept-Encoding, Accept, X-Requested-With\n      Etag:\n      - W/\"cc42fb8190e3219e91e46d75f709c8b5762a1e8bf472008a702a4adf1e7dfb95\"\n      X-Github-Media-Type:\n      - github.v3; format=json\n      X-Github-Api-Version-Selected:\n      - '2022-11-28'\n      Access-Control-Expose-Headers:\n      - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,\n        X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,\n        X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,\n        X-GitHub-Request-Id, Deprecation, Sunset\n      Access-Control-Allow-Origin:\n      - \"*\"\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubdomains; preload\n      X-Frame-Options:\n      - deny\n      X-Content-Type-Options:\n      - nosniff\n      X-Xss-Protection:\n      - '0'\n      Referrer-Policy:\n      - origin-when-cross-origin, strict-origin-when-cross-origin\n      Content-Security-Policy:\n      - default-src 'none'\n      Server:\n      - github.com\n      Accept-Ranges:\n      - bytes\n      X-Ratelimit-Limit:\n      - '60'\n      X-Ratelimit-Remaining:\n      - '59'\n      X-Ratelimit-Reset:\n      - '1742391658'\n      X-Ratelimit-Resource:\n      - core\n      X-Ratelimit-Used:\n      - '1'\n      Transfer-Encoding:\n      - chunked\n      X-Github-Request-Id:\n      - DDED:38A4A1:15FB4B:2C2CC0:67DABB5A\n    body:\n      encoding: ASCII-8BIT\n      string: !binary |-\n        W3sidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzIwMzEwMjI3OCIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMjAzMTAyMjc4L2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzIwMzEwMjI3OC9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuNC4zIiwiaWQiOjIwMzEwMjI3OCwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwidXNlcl92aWV3X3R5cGUiOiJwdWJsaWMiLCJzaXRlX2FkbWluIjpmYWxzZX0sIm5vZGVfaWQiOiJSRV9rd0RPS190eEhNNE1HeGhHIiwidGFnX25hbWUiOiJ2MC40LjMiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC40LjMiLCJkcmFmdCI6ZmFsc2UsInByZXJlbGVhc2UiOmZhbHNlLCJjcmVhdGVkX2F0IjoiMjAyNS0wMi0yOFQyMDoxMTo0MVoiLCJwdWJsaXNoZWRfYXQiOiIyMDI1LTAyLTI4VDIwOjE3OjE3WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuNC4zIiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC40LjMiLCJib2R5IjoiIyMgRGF0YSByZXNldHMsIG9mZmxpbmUgaW52ZXN0bWVudCB0cmFkZXMsIGFuZCBtaXNjZWxsYW5lb3VzIHN0YWJpbGl0eSBpbXByb3ZlbWVudHNcclxuXHJcblRoaXMgcmVsZWFzZSBjb21lcyB3aXRoIGEgd2lkZSBtaXggb2Ygc3RhYmlsaXR5IGltcHJvdmVtZW50cyBhbmQgcXVhbGl0eSBvZiBsaWZlIHVwZGF0ZXM7IHBhcnRpY3VsYXJseSBmb3Igc2VsZiBob3N0ZWQgYXBwcywgd2hpY2ggY2FuIG5vdyBiZSBcInJlc2V0XCIgaW4gdXNlciBzZXR0aW5ncy4gIElmIHlvdXIgZGF0YSBsb29rcyB3cm9uZyBvciB5b3Ugd2FudCBhIFwiY2xlYW4gc2xhdGVcIiB0byB3b3JrIGZyb20sIHdlJ3ZlIGFkZGVkIHRoZSBhYmlsaXR5IGZvciB5b3UgdG8gZWFzaWx5IHBlcmZvcm0gdGhlc2UgcmVzZXRzIHdpdGhvdXQgd3JpdGluZyBTUUwgb3IgbWFudWFsbHkgZGVsZXRpbmcgcmVjb3Jkcy5cclxuXHJcblRoaXMgcmVsZWFzZSBhbHNvIGNvbWVzIHdpdGggYSBtdWNoIGNsZWFyZXIgVUkgc3Vycm91bmRpbmcgdGhlIFN5bnRoIGRhdGEgcHJvdmlkZXIuICBOZXcgc2VsZiBob3N0ZWQgdXNlcnMgd2lsbCBub3cgc2VlIGEgcHJvbWluZW50IHdhcm5pbmcgbWVzc2FnZSBpZiB0aGV5IGhhdmUgbWlzc2luZyBkYXRhIGFzIGEgcmVzdWx0IG9mIGEgbWlzY29uZmlndXJlZCBvciBhYnNlbnQgZGF0YSBwcm92aWRlci5cclxuXHJcbiMjIFdoYXQncyBDaGFuZ2VkXHJcbiogQWRkIG5ldyBjYXRlZ29yeSBmbG93IGJ5IEBzeWVkYmFyaW1hbmphbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4NTdcclxuKiBGaXggcGFyZW50IGNhdGVnb3J5IHN1bXMgaW4gYnVkZ2V0IGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4OTRcclxuKiBBZGQgYnJlYWRjcnVtYnMgc3VwcG9ydCBhY3Jvc3MgYXBwbGljYXRpb24gYnkgQFNocGlnZm9yZCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4OTdcclxuKiBEYXNoYm9hcmQgZGVzaWduIGZpeGVzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4OThcclxuKiBBbGxvdyBhY2NvdW50IGJhbGFuY2UgdG8gZHluYW1pY2FsbHkgdXNlIGN1cnJlbmN5IGZvcm1hdCBvbiBwcmVmZXJlbmNlIHBhZ2UgYnkgQEhhcnJ5LWtwIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTkxMFxyXG4qIEZlYXQ6IERhdGEgXCJyZXNldFwiIGJ1dHRvbiBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTkxM1xyXG4qIEZpeDogTWFrZSBUYWdzIHNlbGVjdGlvbiBzY3JvbGxhYmxlIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xOTIxXHJcbiogRml4IHZhbHVlIHdyYXBwaW5nIG9uIGFjY291bnQgYmFsYW5jZSBpbiBzaWRlYmFyIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE5MjJcclxuKiBGaXggaW1wb3J0IGNvbmZpZ3VyYXRpb24gZm9ybSBzbyBudW1iZXIgZm9ybWF0IGlzIGFwcGxpZWQgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTkyM1xyXG4qIEFkZCB0cmFuc2l0aW9ucyB0byBidXR0b25zIGFuZCBvdGhlciBjb21tb24gZGVzaWduIHN5c3RlbSBlbGVtZW50cyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xOTI0XHJcbiogQWxsb3cgb2ZmbGluZSB0cmFkZSB0aWNrZXJzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE5MjVcclxuKiBmaXg6IERvbid0IHNob3cgQmlsbGluZ3Mgb24gc2V0dGluZ3MgbmF2YmFyIHdoZW4gc2VsZi1ob3N0ZWQgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE5MTJcclxuKiBTaG93IFVJIHdhcm5pbmcgdG8gdXNlciB3aGVuIHRoZXkgbmVlZCBwcm92aWRlciBkYXRhIGJ1dCBoYXZlIG5vdCBzZXR1cCBTeW50aCB5ZXQgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTkyNlxyXG4qIEludmVydCBsaWFiaWxpdHkgZ3JhcGhzIHRvIGhhdmUgY29ycmVjdCBzaWduYWdlIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE5MjhcclxuKiBFc2NhcGUgcXVvdGF0aW9ucyBpbiBDU1YgaW1wb3J0cyBwcm9wZXJseSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xOTI5XHJcblxyXG4jIyBOZXcgQ29udHJpYnV0b3JzXHJcbiogQHN5ZWRiYXJpbWFuamFuIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTg1N1xyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjQuMi4uLnYwLjQuMyIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMjAzMTAyMjc4L3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjoyMiwiKzEiOjAsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5IjowLCJjb25mdXNlZCI6MCwiaGVhcnQiOjIyLCJyb2NrZXQiOjAsImV5ZXMiOjB9LCJtZW50aW9uc19jb3VudCI6NX0seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMjAxNzY2MjkyIiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8yMDE3NjYyOTIvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMjAxNzY2MjkyL2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC40LjEiLCJpZCI6MjAxNzY2MjkyLCJhdXRob3IiOnsibG9naW4iOiJ6YWNoZ29sbCIsImlkIjoxNjY3NjE1Nywibm9kZV9pZCI6Ik1EUTZWWE5sY2pFMk5qYzJNVFUzIiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xNjY3NjE1Nz92PTQiLCJncmF2YXRhcl9pZCI6IiIsInVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwiLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS96YWNoZ29sbCIsImZvbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2VycyIsImZvbGxvd2luZ191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2dpc3Rzey9naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdGFycmVkey9vd25lcn17L3JlcG99Iiwic3Vic2NyaXB0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N1YnNjcmlwdGlvbnMiLCJvcmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVwb3MiLCJldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRfZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJ1c2VyX3ZpZXdfdHlwZSI6InB1YmxpYyIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00TUJyV1UiLCJ0YWdfbmFtZSI6InYwLjQuMSIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjQuMSIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6ZmFsc2UsImNyZWF0ZWRfYXQiOiIyMDI1LTAyLTIxVDE5OjE5OjUwWiIsInB1Ymxpc2hlZF9hdCI6IjIwMjUtMDItMjFUMTk6MjA6NDNaIiwiYXNzZXRzIjpbXSwidGFyYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvdGFyYmFsbC92MC40LjEiLCJ6aXBiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS96aXBiYWxsL3YwLjQuMSIsImJvZHkiOiJQYXRjaCByZWxlYXNlIGZvciBnaXQgdmVyc2lvbmluZyBpbmZvIGNyYXNoIGVycm9yIiwicmVhY3Rpb25zIjp7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8yMDE3NjYyOTIvcmVhY3Rpb25zIiwidG90YWxfY291bnQiOjEzLCIrMSI6MTMsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5IjowLCJjb25mdXNlZCI6MCwiaGVhcnQiOjAsInJvY2tldCI6MCwiZXllcyI6MH19LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzIwMTc0NTA4NSIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMjAxNzQ1MDg1L2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzIwMTc0NTA4NS9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuNC4wIiwiaWQiOjIwMTc0NTA4NSwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwidXNlcl92aWV3X3R5cGUiOiJwdWJsaWMiLCJzaXRlX2FkbWluIjpmYWxzZX0sIm5vZGVfaWQiOiJSRV9rd0RPS190eEhNNE1CbUs5IiwidGFnX25hbWUiOiJ2MC40LjAiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC40LjAiLCJkcmFmdCI6ZmFsc2UsInByZXJlbGVhc2UiOmZhbHNlLCJjcmVhdGVkX2F0IjoiMjAyNS0wMi0yMVQxNzozOTo0OVoiLCJwdWJsaXNoZWRfYXQiOiIyMDI1LTAyLTIxVDE3OjQ0OjI1WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuNC4wIiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC40LjAiLCJib2R5IjoiIyMgTWF5YmUgUmVmcmVzaCDwn46JIFxyXG5cclxuVGhpcyBsYXRlc3QgdmVyc2lvbiBjb21lcyB3aXRoIGEgKipicmFuZCBuZXcgYW5kIHNpbXBsaWZpZWQgVUkqKiBwbHVzIGEgdG9uIG9mIGh1Z2UgcGVyZm9ybWFuY2UgaW1wcm92ZW1lbnRzIG9uIHRoZSB0cmFuc2FjdGlvbnMsIGRhc2hib2FyZCwgYW5kIGFjY291bnRzIHBhZ2VzLlxyXG5cclxuIVtDbGVhblNob3QgMjAyNS0wMi0yMSBhdCAxMiAzOSAxMl0oaHR0cHM6Ly9naXRodWIuY29tL3VzZXItYXR0YWNobWVudHMvYXNzZXRzLzdjOWQyMjBjLTRhMTMtNGVlZi05ODY2LTA1YTYxMzEwZTJjZilcclxuXHJcbiMjIFdoYXQncyBDaGFuZ2VkXHJcblxyXG4qIEZpeCBhY2NvdW50IGRlbGV0aW9uIGNhc2NhZGUgYnVnIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE2NDRcclxuKiBEbyBub3QgcmFpc2Ugb24gUGxhaWQgaXRlbSBub3QgZm91bmQgZXhjZXB0aW9ucyBmb3IgaXRlbSBkZWxldGlvbnMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTY0NlxyXG4qIEFsaWduIGNhc2NhZGUgZGVsZXRlIGJlaGF2aW9yIGZvciB0cmFuc2ZlcnMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTY0N1xyXG4qIGZpeDogT25seSBhZG1pbnMgY2FuIGdlbmVyYXRlIGludml0ZSBjb2RlcyBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTYxMVxyXG4qIE9ubHkgdXBkYXRlIGFjY291bnQgYmFsYW5jZSBpZiBjaGFuZ2VkIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE2NzZcclxuKiBBZGp1c3QgcXVldWVzIHRvIHByaW9yaXRpemUgYWNjb3VudCBzeW5jcywgaGFuZGxlIG1pc3NpbmcgY3VycmVudCBkYXkgdmFsdWVzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE2ODJcclxuKiBBZGRlZCBEZWNpbWFsIFN1cHBvcnQgaW4gbWluIHRyYW5zYWN0aW9uIGJ5IEBIYXJyeS1rcCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE2ODFcclxuKiBGaXggbisxIGZvciBjYXRlZ29yaWVzIGluIGZvciB0b19kb251dF9zZWdtZW50c19qc29uIGluIGJ1ZGdldCBieSBAdGFwYWxpbG92IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTY5M1xyXG4qIEFkZCBNb3JlIFRpbWVsaW5lcyBieSBAbmlraGlsYmFkeWFsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTcxNFxyXG4qIEFkZCBjYWJpbiAvIGNvdHRhZ2UgYXMgYSBwcm9wZXJ0eSB0eXBlIGJ5IEBlaXRoZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE2NThcclxuKiBBZGQgUmVqZWN0ZWRUcmFuc2ZlciBtb2RlbCwgc2ltcGxpZnkgYXV0byBtYXRjaGluZyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNjkwXHJcbiogRml4OiBFZGl0aW5nIGEgY2F0ZWdvcnkgZG9lcyBub3Qgc2hvdyBub3RpZmljYXRpb24gdW50aWwgbmV4dCBwYWdlIHJlZnJlc2ggYnkgQGplc3Rpbmpvc2hpIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTcyMFxyXG4qIEJ1bXAgdG8gUnVieSAzLjQuMSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNzIxXHJcbiogTGF6eSBsb2FkIHN5bnRoIGxvZ29zIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE3MzFcclxuKiBPbmx5IGJ1aWxkIGFybXY3IG9uIG9mZmljaWFsIHJlbGVhc2VzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE3MzJcclxuKiBEb24ndCBhbGxvdyBhIHN1YmNhdGVnb3J5IHRvIGJlIGFzc2lnbmVkIHRvIGFub3RoZXIgc3ViY2F0ZWdvcnkgdG8gZW5zdXJlIDEgbGV2ZWwgb2YgbmVzdGluZyBtYXggYnkgQGVsdmlzc2VycmFvIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTczMFxyXG4qIFByZXNlcnZlIHRyYW5zYWN0aW9uIGZpbHRlcnMgYW5kIHRyYW5zYWN0aW9uIGZvY3VzIGFjcm9zcyBwYWdlIHZpc2l0cyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNzMzXHJcbiogQWRkL3JlbW92ZSBtZW1iZXJzIGFuZCBpbnZpdGF0aW9ucyBieSBAU2hwaWdmb3JkIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTc0NFxyXG4qIEVuc3VyZSBDb25zaXN0ZW50IENhdGVnb3J5IENvbG9ycyBieSBASkxhbWJlcnRhenpvIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTcyMlxyXG4qIEFsbG93IHVzZXJzIHRvIHVwZGF0ZSB0aGVpciBlbWFpbCBhZGRyZXNzIGJ5IEBTaHBpZ2ZvcmQgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNzQ1XHJcbiogSW5pdGlhbCBwYXNzIGF0IFBsYWlkIEVVIGJ5IEBTaHBpZ2ZvcmQgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNTU1XHJcbiogRml4IEVVIHBsYWlkIGZsb3cgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTc2MVxyXG4qIEltcHJvdmUgc3BlZWQgb2YgdHJhbnNhY3Rpb25zIHBhZ2UgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTc1MlxyXG4qIEZpeDogdW5hYmxlIHRvIGFkZCBhY2NvdW50cyB3aXRob3V0IHBsYWluIHNldCB1cCBieSBARjIyMTAgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNzY5XHJcbiogRml4OiBtYWtlIGRhdGUgZm9ybWF0IHllYXIgY29uc2lzdGVudCBvdmVyYWxsICMxNzEyIGJ5IEBzY29kZXM3MyBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE3MjZcclxuKiBDdXJzb3IgcnVsZXMgYW5kIHByb2plY3QgZGVzaWduIG92ZXJ2aWV3IGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE3ODhcclxuKiBGaXggY3J5cHRvLnJhbmRvbVVVSUQgZXJyb3JzIHdoZW4gYWRkaW5nIGhvbGRpbmdzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE3OTVcclxuKiBTcGxpdCBmYW1pbHkgYW5kIFBsYWlkIGl0ZW0gc3luY3MgaW50byBtdWx0aXBsZSBqb2JzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE3OTlcclxuKiBGaXg6IEluY29ycmVjdCBDdXJyZW5jeSBBc3NpZ25tZW50IGZvciBTdG9jayBQcmljZXMgKCMxNjIzKSBieSBAc2FwaHAgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNzk4XHJcbiogRml4IGJ1ZGdldCBhbGxvY2F0aW9uIGZvcm1zIGZyb20gcmVzZXR0aW5nIGFuZCBjbGVhcmluZyBkYXRhIG9uIHNsb3cgbmV0d29ya3MgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTgwNFxyXG4qIFJlZmFjdG9yIHRyYW5zYWN0aW9uIGVucmljaG1lbnQgdG8gc3VwcG9ydCBiYXRjaCBwcm9jZXNzaW5nIGJ5IEBTaHBpZ2ZvcmQgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xODAzXHJcbiogQWRkIHNjb3BlIHRvIGZpbHRlciB0cmFuc2FjdGlvbnMgZnJvbSBhY3RpdmUgYWNjb3VudHMgYnkgQFNocGlnZm9yZCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4MTBcclxuKiBmaXg6IFNhdmUgY29tcGxldGVseSBhbGxvY2F0ZWQgYnVkZ2V0IGJ5IEBNMTIzLWRldiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4MTFcclxuKiBmZWF0OiBBZGQgaW5zdGl0dXRpb24gZGV0YWlscyB0byBQbGFpZCBpdGVtcyBieSBAU2hwaWdmb3JkIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTgxNlxyXG4qIE11bHRpLWZhY3RvciBhdXRoZW50aWNhdGlvbiBieSBAU2hwaWdmb3JkIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTgxN1xyXG4qIGZpeDogUGxhaWQgd2ViaG9vayB2ZXJpZmljYXRpb24gYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTgyNFxyXG4qIFBsYWlkIEVVIHdlYmhvb2tzIG1pZ3JhdGlvbiB0YXNrIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4MjVcclxuKiBGaXggQWNjb3VudCBIb2xkaW5nIHZhbGlkYXRpb24gYW5kIHN5bmNocm9uaXphdGlvbiBieSBAU2hwaWdmb3JkIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTgxOFxyXG4qIEZlYXR1cmU6IEFkZCB0aGUgYWJpbGl0eSB0byBcInJldmVydFwiIGEgQ1NWIGltcG9ydCBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xODE0XHJcbiogZmVhdChpbXBvcnQpOiBhZGQgY3VycmVuY3kgYW5kIG51bWJlciBmb3JtYXQgc3VwcG9ydCBmb3IgQ1NWIGltcG9ydHMgYnkgQGRhbmVzdHZlcyBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4MTlcclxuKiBmaXg6IHN1YmNhdGVnb3JpZXMgYXJlIG5vdCBwcm9wZXJseSBoYW5kbGVkIGZvciBidWRnZXQgYWxsb2NhdGlvbnMgYnkgQHBhdWxla2U2NSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4NDRcclxuKiBFbmhhbmNlIHNlY3VyaXR5IGluZm9ybWF0aW9uIHJldHJpZXZhbCBhbmQgaGFuZGxpbmcgYnkgQFNocGlnZm9yZCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4MjZcclxuKiBmaXg6IExpYWJpbGl0aWVzIGZhdm9yYWJsZSBkaXJlY3Rpb24gaXMgXCJkb3duXCIgYnkgQE0xMjMtZGV2IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTg0OVxyXG4qIFVwZ3JhZGUgdG8gVGFpbHdpbmQgdjQgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTg1M1xyXG4qIEVuaGFuY2UgUGxhaWQgY29ubmVjdGlvbiBtYW5hZ2VtZW50IHdpdGggcmUtYXV0aGVudGljYXRpb24gYW5kIGVycm9yIGhhbmRsaW5nIGJ5IEBTaHBpZ2ZvcmQgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xODU0XHJcbiogTWF5YmUgRGVzaWduIFN5c3RlbSBVcGRhdGVzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4NTZcclxuKiBmaXg6IFRyYW5zZmVycyBzaG91bGQgYWx3YXlzIHRvdGFsIHRvIHplcm8gYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4NTlcclxuKiBmaXg6IEdob3N0IHN1YmNhdGVnb3JpZXMgd2hlbiBwYXJlbnQgY2F0ZWdvcnkgaXMgZGVsZXRlZCBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTg3NFxyXG4qIEZpeCBpbXBvcnQgY29uZmlndXJhdGlvbiBmYWlsdXJlcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xODc2XHJcbiogTmV3IERlc2lnbiBTeXN0ZW0gKyBDb2RlYmFzZSBSZWZyZXNoIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4MjNcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBASkxhbWJlcnRhenpvIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTY5OVxyXG4qIEB0YXBhbGlsb3YgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNjkzXHJcbiogQGVpdGhlIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTY1OFxyXG4qIEBlbHZpc3NlcnJhbyBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE3MzBcclxuKiBARjIyMTAgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNzY5XHJcbiogQHNjb2RlczczIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTcyNlxyXG4qIEBzYXBocCBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE3OThcclxuKiBATTEyMy1kZXYgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xODExXHJcbiogQGRhbmVzdHZlcyBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE4MTlcclxuKiBAcGF1bGVrZTY1IG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTg0NFxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjMuMC4uLnYwLjQuMCIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMjAxNzQ1MDg1L3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjoyOCwiKzEiOjUsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5Ijo3LCJjb25mdXNlZCI6MCwiaGVhcnQiOjExLCJyb2NrZXQiOjUsImV5ZXMiOjB9LCJtZW50aW9uc19jb3VudCI6MTZ9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE5NTQxNTE3NyIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTk1NDE1MTc3L2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE5NTQxNTE3Ny9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMy4wIiwiaWQiOjE5NTQxNTE3NywiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwidXNlcl92aWV3X3R5cGUiOiJwdWJsaWMiLCJzaXRlX2FkbWluIjpmYWxzZX0sIm5vZGVfaWQiOiJSRV9rd0RPS190eEhNNExwY3lKIiwidGFnX25hbWUiOiJ2MC4zLjAiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC4zLjAiLCJkcmFmdCI6ZmFsc2UsInByZXJlbGVhc2UiOmZhbHNlLCJjcmVhdGVkX2F0IjoiMjAyNS0wMS0xN1QyMjowMToyNloiLCJwdWJsaXNoZWRfYXQiOiIyMDI1LTAxLTE3VDIyOjA1OjE5WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMy4wIiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4zLjAiLCJib2R5IjoiIyMgQnVkZ2V0aW5nIGlzIGhlcmUhXHJcblxyXG5BIGxvbmcgYXdhaXRlZCBmZWF0dXJlLCBNYXliZSBub3cgc3VwcG9ydHMgYnVkZ2V0aW5nISAgV2UgYmVsaWV2ZSBidWRnZXRpbmcgc2hvdWxkIGJlIGVhc3kgYW5kIHNpbXBsZS4gIENhdGVnb3JpemUgeW91ciB0cmFuc2FjdGlvbnMsIG1hcmsgdHJhbnNmZXJzLCBvbmUtdGltZSBleHBlbnNlcywgYW5kIGdldCBhIGNsZWFyIGJyZWFrZG93biBvZiB3aGVyZSB5b3VyIG1vbmV5IGlzIGdvaW5nIGVhY2ggbW9udGguXHJcblxyXG5odHRwczovL2dpdGh1Yi5jb20vdXNlci1hdHRhY2htZW50cy9hc3NldHMvNGRjZjNmNjYtN2IyNi00MmU3LTljNmItNDhkZjdlMTlhNzk4XHJcblxyXG4jIyBXaGF0J3MgQ2hhbmdlZFxyXG4qIEFkZCBhY2NvdW50IGRhdGEgZW5yaWNobWVudCBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNTMyXHJcbiogSGFuZGxlIG5pbCBuYW1lIGZvciBlbnRyaWVzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE1NTBcclxuKiBGaXggZGF0ZSBmb3JtYXQgdmFsaWRhdGlvbiBlcnJvciBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNTUxXHJcbiogTWFrZSB0cmFuc2FjdGlvbiBlbnJpY2htZW50IG9wdC1pbiBmb3IgYWxsIHVzZXJzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE1NTJcclxuKiBQcmVzZXJ2ZSBvcmlnaW5hbCB0cmFuc2FjdGlvbiBuYW1lcyB3aGVuIGVucmljaGluZyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNTU2XHJcbiogUHJlc2VydmUgcGFnaW5hdGlvbiBvbiBlbnRyeSB1cGRhdGVzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE1NjNcclxuKiBOZXN0ZWQgQ2F0ZWdvcmllcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNTYxXHJcbiogU2F2ZSBlcnJvciBiYWNrdHJhY2UgZm9yIHN5bmMgZXJyb3JzIGZvciBiZXR0ZXIgZGVidWdnaW5nIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNTc4XHJcbiogZml4OiBCdWcgfCBjcmVhdGluZyBkdXBsaWNhdGUgY2F0ZWdvcnkgbGVhZHMgdG8gY3Jhc2ggc2NyZWVuIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNTc3XHJcbiogRml4IHVua25vd24gYXR0cmlidXRlICdwYXJlbnRfY2F0ZWdvcnknIGZvciBDYXRlZ29yeSBpbiBkZW1vIGdlbmVyYXRvciBieSBAYWthYmlydSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE1NzVcclxuKiBGaXg6IGJyZWFraW5nIGNoYW5nZSBhZnRlciBidW1waW5nIGhvdHdpcmUtbGl2ZXJlbG9hZCB0byAyLjAuMCBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTU4OVxyXG4qIFRyYW5zZmVyIGFuZCBQYXltZW50IGF1dG8tbWF0Y2hpbmcsIG1vZGVsIGFuZCBVSSBpbXByb3ZlbWVudHMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTU4NVxyXG4qIEJ1ZGdldGluZyBWMSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNjA5XHJcbiogRml4IHRyYW5zZmVyIG1hdGNoaW5nIGxvZ2ljIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE2MjVcclxuKiBGaXggYnVkZ2V0IG1vbmV5IGZvcm1hdHRpbmcgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTYyNlxyXG4qIFVwZGF0ZSBzbmFwc2hvdF9hY2NvdW50X3RyYW5zYWN0aW9ucyB0byBvbmx5IGdpdmUgdHJhbnNhY3Rpb25zIGJ5IGZpbHRlcmluZyBvbiBlbnRyeWFibGVfdHlwZSBieSBAUmVwc2F5IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTYyOVxyXG4qIEZJWDogY29ycmVjdCBkaXNwbGF5IG9mIHBlcmNlbnRhZ2VzIGJ5IEBNclRvYiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE2MjJcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBAYWthYmlydSBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE1NzVcclxuKiBAUmVwc2F5IG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTYyOVxyXG4qIEBNclRvYiBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE2MjJcclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4yLjAuLi52MC4zLjAiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE5NTQxNTE3Ny9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6NzMsIisxIjoyMywiLTEiOjAsImxhdWdoIjowLCJob29yYXkiOjIsImNvbmZ1c2VkIjowLCJoZWFydCI6MzcsInJvY2tldCI6MTEsImV5ZXMiOjB9LCJtZW50aW9uc19jb3VudCI6NX0seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTkwNjk1NDQyIiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xOTA2OTU0NDIvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTkwNjk1NDQyL2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC4yLjAiLCJpZCI6MTkwNjk1NDQyLCJhdXRob3IiOnsibG9naW4iOiJ6YWNoZ29sbCIsImlkIjoxNjY3NjE1Nywibm9kZV9pZCI6Ik1EUTZWWE5sY2pFMk5qYzJNVFUzIiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xNjY3NjE1Nz92PTQiLCJncmF2YXRhcl9pZCI6IiIsInVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwiLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS96YWNoZ29sbCIsImZvbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2VycyIsImZvbGxvd2luZ191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2dpc3Rzey9naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdGFycmVkey9vd25lcn17L3JlcG99Iiwic3Vic2NyaXB0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N1YnNjcmlwdGlvbnMiLCJvcmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVwb3MiLCJldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRfZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJ1c2VyX3ZpZXdfdHlwZSI6InB1YmxpYyIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00TFhjZ1MiLCJ0YWdfbmFtZSI6InYwLjIuMCIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjIuMCIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6ZmFsc2UsImNyZWF0ZWRfYXQiOiIyMDI0LTEyLTEzVDE3OjE2OjIxWiIsInB1Ymxpc2hlZF9hdCI6IjIwMjQtMTItMTNUMTg6MTk6MjdaIiwiYXNzZXRzIjpbXSwidGFyYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvdGFyYmFsbC92MC4yLjAiLCJ6aXBiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS96aXBiYWxsL3YwLjIuMCIsImJvZHkiOiIjIyBQbGFpZCBJbnRlZ3JhdGlvbiArIE11bHRpLWN1cnJlbmN5IEludmVzdG1lbnQgUG9ydGZvbGlvIFN1cHBvcnRcclxuXHJcbldlIGhhdmUgY29tcGxldGVkIHRoZSBmaXJzdCBwYXNzIGF0IG91ciBmdWxseSBhdXRvbWF0ZWQgYmFuayBzeW5jaW5nIHdpdGggUGxhaWQgb24gb3VyIF9ob3N0ZWQgdmVyc2lvbl8gb2YgdGhlIGFwcCFcclxuXHJcbkF1dG9tYXRlZCBiYW5rIHN5bmNpbmcgaXMgYSBodWdlIG1pbGVzdG9uZSBmb3IgdGhlIGFwcCBhbmQgaW5jbHVkZXMgbWFueSBVSSBpbXByb3ZlbWVudHMsIGFjY3VyYWN5IGltcHJvdmVtZW50cywgYW5kIGJ1ZyBmaXhlczpcclxuXHJcbi0gRWRpdGluZyB0cmFuc2FjdGlvbnMgaXMgbm93IG11Y2ggc21vb3RoZXJcclxuLSBBY2NvdW50IGhpc3RvcnkgY2FuIGJlIGNhbGN1bGF0ZWQgY2hyb25vbG9naWNhbGx5IGFuZCByZXZlcnNlLWNocm9ub2xvZ2ljYWxseSAocmVxdWlyZWQgZm9yIFBsYWlkIGFjY291bnRzKVxyXG4tIE11bHRpLWN1cnJlbmN5IGludmVzdG1lbnQgYWNjb3VudHMgYXJlIG5vdyBhdmFpbGFibGVcclxuLSBJbnZlc3RtZW50IGFjY291bnRzIG5vdyBwcm9wZXJseSBjYWxjdWxhdGUgXCJicm9rZXJhZ2UgY2FzaFwiIHZzLiBcImhvbGRpbmdzIHZhbHVlXCIgYW5kIGJyZWFrIHRoaXMgb3V0IGNsZWFybHkgaW4gdGhlIHZpZXdcclxuLSBJbnZlc3RtZW50IGFjY291bnRzIGNhbGN1bGF0ZSBjb3N0IGJhc2lzIG1vcmUgYWNjdXJhdGVseVxyXG5cclxuIVtDbGVhblNob3QgMjAyNC0xMi0xMyBhdCAxMiAzMSAwM10oaHR0cHM6Ly9naXRodWIuY29tL3VzZXItYXR0YWNobWVudHMvYXNzZXRzLzFmZGJhYjc5LTdiYzUtNDYzMS04MGI0LWE3MDhkZjE3ODNmZClcclxuXHJcbiMjIyBDYW4gSSBzZWxmLWhvc3QgUGxhaWQ/XHJcblxyXG5BcyBhbiBvcGVuLXNvdXJjZSBwcm9qZWN0LCBzZWxmLWhvc3RlcnMgYXJlIG1vcmUgdGhhbiB3ZWxjb21lIHRvIGNvbmZpZ3VyZSAoYW5kIHBheSBmb3IpIHRoZWlyIG93biBQbGFpZCBhY2NvdW50cy4gIFlvdSBjYW4gc2VlIHRoZSBgLmVudi5leGFtcGxlYCBmaWxlIGZvciB0aGUgcmVxdWlyZWQgYXBpIGtleXMgdGhhdCBuZWVkIHRvIGJlIHByb3ZpZGVkIHRvIHRoZSBhcHAuXHJcblxyXG5UaGF0IHNhaWQsIGR1ZSB0byB0aGUgY29tcGxleGl0eSBhcm91bmQgT0F1dGgsIFBsYWlkJ3MgcHJpY2luZyBzdHJ1Y3R1cmUsIGFuZCBjb3N0cyBhc3NvY2lhdGVkIHdpdGggYSBwZXJzb25hbCBQbGFpZCBzdWJzY3JpcHRpb24sIHdlIHdpbGwgbm90IGJlIG9mZmljaWFsbHkgc3VwcG9ydGluZyB0aGlzIHNldHVwLiAgVGhlIGhvc3RlZCB2ZXJzaW9uIG9mIG91ciBhcHAgaGFzIGZ1bGwgUGxhaWQgc3VwcG9ydCBhbmQgaXMgb3VyIHJlY29tbWVuZGVkIHdheSB0byBnZXQgYWNjZXNzIHRvIGF1dG9tYXRlZCBiYW5rIHN5bmNpbmcgKG1vcmUgaW52aXRlcyBjb21pbmcgc29vbiEpLlxyXG5cclxuIyMgV2hhdCdzIENoYW5nZWRcclxuXHJcbiogRml4IHJlZ2lzdHJhdGlvbiBmYWlscyBzaWxlbnRseSB3aGVuIHRoZXJlIGFyZSBlcnJvcnMgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE0NTVcclxuKiBBZGRzIGEgY29tbW9uIERFIGRhdGUgZm9ybWF0IGJ5IEBzYmVocmVuZHMgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNDQ1XHJcbiogQmFzaWMgUGxhaWQgSW50ZWdyYXRpb24gYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTQzM1xyXG4qIE1ha2UgZW5jcnlwdGlvbiBjb25maWcgb3B0aW9uYWwgZm9yIHNlbGYgaG9zdGluZyB1c2VycyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNDc2XHJcbiogQWxsb3cgY3VzdG9tIGNvbHVtbiBzZXBhcmF0b3IgZm9yIENTViBwYXJzaW5nIGluIHVwbG9hZHMgY29udHJvbGxlciBieSBAYWNmbGludCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE0NzBcclxuKiBGaXggdHJhbnNmZXJzIGFuZCBmb3JtIGN1cnJlbmNpZXMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTQ3N1xyXG4qIERvbid0IHJlZnJlc2ggcGFnZSB3aGVuIHRyYW5zYWN0aW9uIGRldGFpbHMgYXJlIGVkaXRlZCBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNDc5XHJcbiogQWRkIHBvc3Qtc3luYyBVSSBzdHJlYW0gdXBkYXRlcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNDgyXHJcbiogQ2FsY3VsYXRlcyB0cmVuZCBiYXNlZCBvbiBwcmV2aW91cyB0cmFuc2FjdGlvbidzIGJhbGFuY2Ugb24gdGhlIHNhbWUgZGF0ZSBieSBAbmljb2dhbGRhbWV6IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTQ4M1xyXG4qIFJlcGxhY2VkIE5hdGl2ZSBTY3JvbGxiYXJzIHdpdGggVGFpbHdpbmQgU2Nyb2xsYmFycyBvbiBXaW5kb3dzIGJ5IEBqZXN0aW5qb3NoaSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE0OTNcclxuKiBGaXggYnVnOiBMb2FuICUgZG9lc24ndCBhbGxvdyBleGFjdCByYXRlIGJ5IEBhcnNlbnN0b3JtIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTQ5MlxyXG4qIEZpeCBBY2NvdW50IERpc2FibGluZyBVSSBieSBAYXJzZW5zdG9ybSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE0OTFcclxuKiBGZWF0dXJlOiBBZGQgc3VwcG9ydCBmb3IgY3VzdG9taXplZCBzeW50aCBVUkwgZnJvbSBlbnYgdmFyaWFibGUgYnkgQEV2bG9zIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTQ5MFxyXG4qIFN5bnRoIGVycm9yIGhhbmRsaW5nIGJ5IEBTaHBpZ2ZvcmQgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNTAyXHJcbiogVXBkYXRlZCB1c2FnZSBjaGVjayB0aHJlc2hvbGQgdG8gMTAwcGMgaW5zdGVhZCBvZiAxIGJ5IEBuaWtoaWxiYWR5YWwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNTA0XHJcbiogSW1wcm92ZSBhY2NvdW50IHRyYW5zYWN0aW9uLCB0cmFkZSwgYW5kIHZhbHVhdGlvbiBlZGl0aW5nIGFuZCBzeW5jIGV4cGVyaWVuY2UgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTUwNlxyXG4qIEhhbmRsZSBpbnZhbGlkIEFQSSBrZXkgYnkgQG5pa2hpbGJhZHlhbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE1MTVcclxuKiBQbGFpZCBwb3J0Zm9saW8gc3luYyBhbGdvcml0aG0gYW5kIGNhbGN1bGF0aW9uIGltcHJvdmVtZW50cyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNTI2XHJcbiogUGxhaWQgc3luYyB0ZXN0cyBhbmQgbXVsdGktY3VycmVuY3kgaW52ZXN0bWVudCBzdXBwb3J0IGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE1MzFcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuXHJcbiogQHNiZWhyZW5kcyBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE0NDVcclxuKiBAYWNmbGludCBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE0NzBcclxuKiBARXZsb3MgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNDkwXHJcbiogQG5pa2hpbGJhZHlhbCBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE1MDRcclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4xLjAuLi52MC4yLjAiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE5MDY5NTQ0Mi9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6MzQsIisxIjoxNCwiLTEiOjAsImxhdWdoIjowLCJob29yYXkiOjAsImNvbmZ1c2VkIjowLCJoZWFydCI6NCwicm9ja2V0IjoxNiwiZXllcyI6MH0sIm1lbnRpb25zX2NvdW50IjoxMH0seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTg0NDM5NDc4IiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xODQ0Mzk0NzgvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTg0NDM5NDc4L2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC4yLjAtYWxwaGEuMiIsImlkIjoxODQ0Mzk0NzgsImF1dGhvciI6eyJsb2dpbiI6InphY2hnb2xsIiwiaWQiOjE2Njc2MTU3LCJub2RlX2lkIjoiTURRNlZYTmxjakUyTmpjMk1UVTMiLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE2Njc2MTU3P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbCIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL3phY2hnb2xsIiwiZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93aW5ney9vdGhlcl91c2VyfSIsImdpc3RzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZ2lzdHN7L2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N0YXJyZWR7L293bmVyfXsvcmVwb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3Vic2NyaXB0aW9ucyIsIm9yZ2FuaXphdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZXBvcyIsImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2V2ZW50c3svcHJpdmFjeX0iLCJyZWNlaXZlZF9ldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIsInVzZXJfdmlld190eXBlIjoicHVibGljIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRLX2xLMiIsInRhZ19uYW1lIjoidjAuMi4wLWFscGhhLjIiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC4yLjAtYWxwaGEuMiIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMTEtMDhUMTk6NTU6NTZaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0xMS0wOFQyMDowMzowMloiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjIuMC1hbHBoYS4yIiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4yLjAtYWxwaGEuMiIsImJvZHkiOiIjIyBBY3Rpdml0eSBWaWV3IFxyXG5cclxuQWxvbmdzaWRlIHNldmVyYWwgYnVnIGZpeGVzLCB0aGlzIHByZS1yZWxlYXNlIGNvbWVzIHdpdGggYSBicmFuZCBuZXcgXCJBY3Rpdml0eSBWaWV3XCIgYmFzZWQgb24gdG9ucyBvZiB1c2VyIGZlZWRiYWNrLiAgVGhpcyBuZXcgYWN0aXZpdHkgdmlldyBhbGxvd3MgeW91IHRvIHNlZSB0aGUgY2hyb25vbG9naWNhbCB1cGRhdGVzIG9mIGVhY2ggYWNjb3VudCBhbGwgaW4gb25lIHBsYWNlLiAgSW4gYWRkaXRpb24sIGl0IHNob3VsZCBob3cgZWFjaCB0cmFuc2FjdGlvbiBhbmQgYmFsYW5jZSB1cGRhdGUgYWZmZWN0cyB0aGUgb3ZlcmFsbCBoaXN0b3JpY2FsIGJhbGFuY2Ugb24gdGhlIGFjY291bnQuICBZb3UgY2FuIHNlZSBpdCBpbiBhY3Rpb24gYmVsb3c6XHJcblxyXG5odHRwczovL2dpdGh1Yi5jb20vdXNlci1hdHRhY2htZW50cy9hc3NldHMvMmVmN2U2OTEtYTJmZS00ZGM2LWI4YmItMDg5NmI5NjM0MjU4XHJcblxyXG4jIyBXaGF0J3MgQ2hhbmdlZFxyXG5cclxuKiBSZW1vdmUgZGVwZW5kZW5jeSBvbiBzdG9jayBleGNoYW5nZSB0YWJsZSBieSBAU2hwaWdmb3JkIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTM2OFxyXG4qIGZpeCBidWxrIGFjdGlvbiBiYXIgcG9zaXRpb25pbmcgYnkgQGdhcmlhc2YgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMzcwXHJcbiogU3RvY2sgZmlsdGVyIGJ5IEBTaHBpZ2ZvcmQgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMzc2XHJcbiogUmVtb3ZlIG1pc3NpbmcgcHJpY2VzIGlzc3VlIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEzOTBcclxuKiBGaXJzdCBwYXNzIGF0IHNlY3VyaXR5IHByaWNlIHJlZmVyZW5jZSBieSBAU2hwaWdmb3JkIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTM4OFxyXG4qIEluaXRpYWwgcGFzcyBhdCBTeW50aC1iYXNlZCB0aWNrZXIgc2VsZWN0aW9uIGJ5IEBTaHBpZ2ZvcmQgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMzkyXHJcbiogR3JvdW5kd29yayBmb3Igc2VjdXJpdHkgaW5mbyBieSBAU2hwaWdmb3JkIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTM5NlxyXG4qIEFkZHMgY29uZGl0aW9uIHRvIHNraXAgbGluayB0byB0cmFuc2FjdGlvbiBmb3JtIGlmIGl0J3Mgbm90IGVkaXRhYmxlIGJ5IEBuaWNvZ2FsZGFtZXogaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMzk0XHJcbiogRG8gbm90IGluY2x1ZGUgaW5jb21lIHRyYW5zYWN0aW9ucyBpbiBsaWFiaWxpdHkgYWNjb3VudHMgZm9yIHNhdmluZ3MgcmF0ZSBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTM4NVxyXG4qIEF1dG8gbmFtaW5nIG9mIFRyYW5zZmVyIFRyYW5zYWN0aW9uIGJ5IEBIYXJyeS1rcCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEzOTNcclxuKiBGYW1pbHkgaW52aXRlcyBieSBAU2hwaWdmb3JkIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTM5N1xyXG4qIEVuIHRyYW5zbGF0aW9uIGZpeCBieSBAYWxla3NleXAgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNDAxXHJcbiogQWNjb3VudCBBY3Rpdml0eSBWaWV3ICsgQWNjb3VudCBGb3JtcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNDA2XHJcbiogRml4IGFjY291bnQgbmFtZXMgYXJlIG5vdCB0cnVuY2F0ZWQgcHJvcGVybHkgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE0MzFcclxuKiBFeGNsdWRlIGluYWN0aXZlIGFjY291bnRzIGZyb20gbmV0LXdvcnRoIGNhbGN1bGF0aW9uIGFuZCBmcm9tIHNpZGViYXIgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE0MzJcclxuKiBGaXggdGltZWZyYW1lIGRyb3Bkb3duIG5leHQgdG8gUG9ydGZvbGlvIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNDM0XHJcbiogU2tpcCBhY2NvdW50IHZhbHVhdGlvbiBvbiBlbnRyeSBiYWxhbmNlX2FmdGVyX2VudHJ5IGJ5IEBicnVuby1jb3N0YW56byBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE0MzVcclxuKiBGaXggZHVwbGljYXRlIGludml0ZXMgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzE0MzdcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBAYWxla3NleXAgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xNDAxXHJcbiogQDN6Y3VyZGlhIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTQwMlxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjIuMC1hbHBoYS4xLi4udjAuMi4wLWFscGhhLjIiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE4NDQzOTQ3OC9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6MjMsIisxIjo1LCItMSI6MCwibGF1Z2giOjAsImhvb3JheSI6MCwiY29uZnVzZWQiOjAsImhlYXJ0IjowLCJyb2NrZXQiOjE4LCJleWVzIjowfSwibWVudGlvbnNfY291bnQiOjl9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE4MTk0NTc0NCIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTgxOTQ1NzQ0L2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE4MTk0NTc0NC9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMi4wLWFscGhhLjEiLCJpZCI6MTgxOTQ1NzQ0LCJhdXRob3IiOnsibG9naW4iOiJ6YWNoZ29sbCIsImlkIjoxNjY3NjE1Nywibm9kZV9pZCI6Ik1EUTZWWE5sY2pFMk5qYzJNVFUzIiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xNjY3NjE1Nz92PTQiLCJncmF2YXRhcl9pZCI6IiIsInVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwiLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS96YWNoZ29sbCIsImZvbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2VycyIsImZvbGxvd2luZ191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2dpc3Rzey9naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdGFycmVkey9vd25lcn17L3JlcG99Iiwic3Vic2NyaXB0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N1YnNjcmlwdGlvbnMiLCJvcmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVwb3MiLCJldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRfZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJ1c2VyX3ZpZXdfdHlwZSI6InB1YmxpYyIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00SzJFV1EiLCJ0YWdfbmFtZSI6InYwLjIuMC1hbHBoYS4xIiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMi4wLWFscGhhLjEiLCJkcmFmdCI6ZmFsc2UsInByZXJlbGVhc2UiOnRydWUsImNyZWF0ZWRfYXQiOiIyMDI0LTEwLTI1VDEzOjM3OjUwWiIsInB1Ymxpc2hlZF9hdCI6IjIwMjQtMTAtMjVUMTM6NTA6NTJaIiwiYXNzZXRzIjpbXSwidGFyYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvdGFyYmFsbC92MC4yLjAtYWxwaGEuMSIsInppcGJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3ppcGJhbGwvdjAuMi4wLWFscGhhLjEiLCJib2R5IjoiIyMgSW1wcm92ZWQgQWNjb3VudCBGbG93cyArIE9uYm9hcmRpbmdcclxuXHJcbldpdGggdGhpcyByZWxlYXNlLCB3ZSBraWNrIG9mZiB3b3JrIHRvd2FyZHMgYHYwLjIuMGAsIHdoaWNoIHdpbGwgYWRkIGEgYmFzaWMgYmFuayBwcm92aWRlciBpbnRlZ3JhdGlvbiAoUGxhaWQpIVxyXG5cclxuSW4gdGhpcyByZWxlYXNlLCB3ZSd2ZSBhZGRlZCBvbmJvYXJkaW5nIHRvIHRoZSBhcHAgc28gdGhhdCB1c2VycyBjYW4gc2VsZWN0IGFsbCBvZiB0aGVpciBwcmVmZXJlbmNlcyBhbG9uZyB3aXRoIG11Y2ggc2ltcGxlciBhbmQgaW50dWl0aXZlIGFjY291bnQgYWRkaXRpb24gZmxvd3MuXHJcblxyXG5IZXJlJ3MgYSBxdWljayBkZW1vIG9mIGhvdyBlYXN5IGl0IGlzIHRvIGdldCBzdGFydGVkIHdpdGggTWF5YmUhXHJcblxyXG5odHRwczovL2dpdGh1Yi5jb20vdXNlci1hdHRhY2htZW50cy9hc3NldHMvZGZkZDg4Y2ItMjNmNC00MGI4LWI0ODctZDk3YWRkOTYxN2ZhXHJcblxyXG4jIyBXaGF0J3MgQ2hhbmdlZFxyXG4qIEFkZCBwZXJpb2QgdG8gdmFsdWUgZGVsZXRlIG1vZGFsIGJ5IEBhaGF0enoxMSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyOTdcclxuKiBBZGQgQmlvbWVKUyBmb3IgTGludGluZyBhbmQgRm9ybWF0dGluZyBKYXZhU2NyaXB0IHJlbGF0ZXMgdG8gIzEyOTUgYnkgQG94ZGV2MDMgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjk5XHJcbiogQWNjb3VudHMgaW4gc2lkZWJhciBzaG91bGQgYmUgb3JkZXJlZCB0aGUgc2FtZSBhcyBpbiB0aGUgYWNjb3VudHMgc3VtbWFyeSBwYWdlIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMzE4XHJcbiogYWRkIGRhc2hib2FyZCBhY2NvdW50IHBpbGwgdG9vbHRpcHMgYnkgQGdhcmlhc2YgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMzE1XHJcbiogUmVkaXJlY3QgdXBsb2FkIHN0ZXAgYnkgQGVuZGVyYWhtZXR5dXJ0IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTMyM1xyXG4qIEltcGVyc29uYXRpb24gYnkgQFNocGlnZm9yZCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEzMjVcclxuKiBSZXdvcmsgYWNjb3VudCB2aWV3cyBhbmQgYWRkaXRpb24gZmxvdyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMzI0XHJcbiogQmFzaWMgYWNjb3VudCBvbmJvYXJkaW5nIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEzMjhcclxuKiBGaXhlcyBpc3N1ZSB3aXRoIG1hcHBpbmcgdmFsdWVzIGR1cmluZyB0aGUgdHJhbnNhY3Rpb25zIGltcG9ydCBieSBAbmljb2dhbGRhbWV6IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTMyN1xyXG4qIFN0b2NrIEV4Y2hhbmdlcyB3aXRoIHNlZWQgYnkgQFNocGlnZm9yZCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEzNTFcclxuKiBVc2VyIE9uYm9hcmRpbmcgKyBCdWcgRml4ZXMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTM1MlxyXG4qIEJldGEgVGVzdGluZyBSb3VuZCAzIEJ1ZyBGaXhlcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMzU3XHJcbiogQWRkIGdvb2Qgam9iIGRhc2hib2FyZCB3aXRoIGF1dGggYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTM2NFxyXG4qIFN0b2NrIGltcG9ydHMgYnkgQFNocGlnZm9yZCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEzNjNcclxuKiBGZWF0dXJlIHwgRmlsdGVyIG9uIHVuY2F0ZWdvcml6ZWQgdHJhbnNhY3Rpb25zIGJ5IEBicnVuby1jb3N0YW56byBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEzNTlcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBAb3hkZXYwMyBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyOTlcclxuKiBAZW5kZXJhaG1ldHl1cnQgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMzIzXHJcbiogQG5pY29nYWxkYW1leiBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEzMjdcclxuKiBAYnJ1bm8tY29zdGFuem8gbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMzU5XHJcblxyXG4qKkZ1bGwgQ2hhbmdlbG9nKio6IGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL2NvbXBhcmUvdjAuMS4wLi4udjAuMi4wLWFscGhhLjEiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE4MTk0NTc0NC9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6MzIsIisxIjo5LCItMSI6MCwibGF1Z2giOjAsImhvb3JheSI6MTcsImNvbmZ1c2VkIjowLCJoZWFydCI6Niwicm9ja2V0IjowLCJleWVzIjowfSwibWVudGlvbnNfY291bnQiOjl9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3OTU4MTU5OCIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTc5NTgxNTk4L2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3OTU4MTU5OC9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wIiwiaWQiOjE3OTU4MTU5OCwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwidXNlcl92aWV3X3R5cGUiOiJwdWJsaWMiLCJzaXRlX2FkbWluIjpmYWxzZX0sIm5vZGVfaWQiOiJSRV9rd0RPS190eEhNNEt0REtlIiwidGFnX25hbWUiOiJ2MC4xLjAiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC4xLjAiLCJkcmFmdCI6ZmFsc2UsInByZXJlbGVhc2UiOmZhbHNlLCJjcmVhdGVkX2F0IjoiMjAyNC0xMC0xMVQxNzoxNTo0NloiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTEwLTExVDE3OjUyOjA3WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wIiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAiLCJib2R5IjoiIyMgV2UncmUgbGl2ZSEg8J+MriDwn6WzIFxyXG5cclxuIVt0aGUtb2ZmaWNlLXRoZV0oaHR0cHM6Ly9naXRodWIuY29tL3VzZXItYXR0YWNobWVudHMvYXNzZXRzLzI4YTNlMWUxLTAyN2ItNGY5NC1hYmQzLTg5ZDM0Yzc2ZDA1YilcclxuXHJcbkFmdGVyIGh1bmRyZWRzIG9mIGNvbnRyaWJ1dG9ycyBhbmQgaHVuZHJlZHMgb2YgdGhvdXNhbmRzIG9mIGxpbmVzIG9mIGNvZGUsIGB2MC4xLjBgIGlzIGhlcmUhXHJcblxyXG5UaGlzIHJlbGVhc2UgY29tZXMgd2l0aCBhIFRPTiBvZiBidWcgZml4ZXMgYW5kIG1hcmtzIHRoZSBsYXVuY2ggb2YgXCJob3N0ZWRcIiBNYXliZS4gIFdlJ3ZlIHN0aWxsIGdvdCBhIF90b25fIG9mIHdvcmsgYWhlYWQsIGJ1dCB3ZSdyZSBleGNpdGVkIHRvIHJlbGVhc2Ugb3VyIGZpcnN0IHByb2R1Y3Rpb24tcmVhZHkgdmVyc2lvbiBvZiBNYXliZS5cclxuXHJcbldlJ3JlIGN1cnJlbnRseSBpbiBhIHByaXZhdGUsIGludml0ZS1vbmx5IGFscGhhLiAgQmUgc3VyZSB0byBbam9pbiBvdXIgRGlzY29yZF0oaHR0cHM6Ly9saW5rLm1heWJlLmNvL2Rpc2NvcmQpIGZvciBhbm5vdW5jZW1lbnRzIHdoZW4gbmV3IHNwb3RzIGJlY29tZSBhdmFpbGFibGUhXHJcblxyXG5JbiB0aGlzIGZpcnN0IHZlcnNpb24sIHlvdSBjYW46XHJcblxyXG4tIEFkZCBhbGwgb2YgeW91ciBhY2NvdW50c1xyXG4tIEltcG9ydCBhY2NvdW50cywgdHJhbnNhY3Rpb25zLCBhbmQgdHJhZGVzIGJ5IENTVlxyXG4tIFNlZSB5b3VyIG5ldCB3b3J0aCwgdG90YWwgc3BlbmRpbmcsIGFuZCB0b3RhbCBpbmNvbWVcclxuLSBNYW5hZ2UgdHJhbnNhY3Rpb25zIChjYXRlZ29yaWVzLCB0YWdzLCBub3RlcylcclxuLSBNYW5hZ2UgaW52ZXN0bWVudCBwb3J0Zm9saW9zIChidXlzLCBzZWxscywgZGVwb3NpdHMsIHdpdGhkcmF3YWxzKVxyXG5cclxuQWZ0ZXIgd2UgaW5jb3Jwb3JhdGUgYWxsIHRoZSBmZWVkYmFjayBjb21pbmcgaW4sIG91ciBob3N0ZWQgYXBwIHdpbGwgYmUgaW50cm9kdWNpbmcgYXV0b21hdGVkIGJhbmsgc3luY2luZyFcclxuXHJcbiMjIFdoYXQncyBDaGFuZ2VkXHJcblxyXG4qIEhpZGUgY3VycmVuY3kgZm9yIHRyYW5zZmVycyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjYwXHJcbiogSGlkZSBpbmZpbml0eSB0cmVuZCBwZXJjZW50YWdlIGNoYW5nZXMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTI2MVxyXG4qIEZpbmFsaXplIG90aGVyIGFzc2V0cyBhbmQgbGlhYmlsaXRpZXMgdmlldyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjY0XHJcbiogSW50ZXJjb20gaW50ZWdyYXRpb24gYnkgQFNocGlnZm9yZCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyNjdcclxuKiBBZGQgZW1wdHkgc3RhdGVzIHRvIGFjY291bnQgc3VtbWFyeSBwYWdlIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyNjVcclxuKiBCaWxsaW5nIGJ5IEBTaHBpZ2ZvcmQgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjY5XHJcbiogQWRkIGxvYW4gYW5kIGNyZWRpdCBjYXJkIHZpZXdzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyNjhcclxuKiBTZXQgMzAwMCBhcyB0aGUgZGVmYXVsdCB3ZWIgcG9ydCBieSBAYWxhZ29zIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTIxNVxyXG4qIEZpeCBhY2NvdW50IHBpbGwgb24gZGFzaGJvYXJkIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyNzBcclxuKiBFYXJseSBhY2Nlc3MgYnkgQFNocGlnZm9yZCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyNzJcclxuKiBCYXNpYyB0cmFkZSBhbmQgaG9sZGluZ3MgdmlldyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjcxXHJcbiogTGluayB0byBDU1YgaW1wb3J0cyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjczXHJcbiogQnVnIGZpeGVzIGZvciBzcGVjaWFsaXplZCBhY2NvdW50IHBhZ2VzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyNzVcclxuKiBGaXggY3VycmVuY3kgZm9ybWF0dGluZyBmb3IgMCB2YWx1ZXMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTI3NlxyXG4qIEZpeCBncm91cCB0cmVuZCBjb2xvciBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjc3XHJcbiogZml4OiB1c2UgY29ycmVjdCBkZWxpbWl0ZXIgb24gY3JlZGl0IGNhcmQgemVybyB2YWx1ZXMgYnkgQGdhcmlhc2YgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjgwXHJcbiogZml4OiBhbWVuZCBpbnB1dHMgb24gbG9hbiwgYy5jLiwgdmVoaWNsZSwgYW5kIHByb3BlcnR5IHBhcnRpYWxzIGJ5IEBnYXJpYXNmIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTI4MVxyXG4qIEJldHRlciBpbXBvcnQgaW5zdHJ1Y3Rpb25zLCByZW1vdmUgYW1iaWd1b3VzIGZpZWxkIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyODRcclxuKiBBbGxvdyBpbmxpbmUgYWNjb3VudCBjcmVhdGlvbiB3aGVuIGltcG9ydGluZyBDU1YgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTI5MVxyXG4qIE1pbm9yIGltcHJvdmVtZW50cyB0byBjYXRlZ29yaWVzICYgY2hhbmdlbG9nIHBhZ2VzIGJ5IEBhcnNlbnN0b3JtIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTI3NFxyXG4qIEhhbmRsZSBtYXJrZXQgaG9saWRheXMgZHVyaW5nIGhvbGRpbmcgc3luYyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjkyXHJcbiogZml4OiBkZWZhdWx0IHZhbHVlIGlmIHVzZXIncyBuYW1lIGlzbid0IHNldCBieSBAYWptZWVzZTcgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjYyXHJcbiogQWRkIGFkZGl0aW9uYWwgc3VidHlwZXMsIGFkZCBOb25lIG9wdGlvbiwgcHJlZmlsbCBlZGl0IHdpdGggcHJldmlvdXNseSBzZWxlY3RlZCBvcHRpb24uIGJ5IEBhaGF0enoxMSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyODZcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBAZ2FyaWFzZiBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyODBcclxuKiBAYXJzZW5zdG9ybSBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyNzRcclxuKiBAYWptZWVzZTcgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjYyXHJcbiogQGFoYXR6ejExIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTI4NlxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjEuMC1hbHBoYS4xOC4uLnYwLjEuMCIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTc5NTgxNTk4L3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjoxMzksIisxIjo4LCItMSI6MCwibGF1Z2giOjAsImhvb3JheSI6ODYsImNvbmZ1c2VkIjowLCJoZWFydCI6MjIsInJvY2tldCI6MjMsImV5ZXMiOjB9LCJtZW50aW9uc19jb3VudCI6N30seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTc4NDY5NDg5IiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNzg0Njk0ODkvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTc4NDY5NDg5L2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC4xLjAtYWxwaGEuMTgiLCJpZCI6MTc4NDY5NDg5LCJhdXRob3IiOnsibG9naW4iOiJ6YWNoZ29sbCIsImlkIjoxNjY3NjE1Nywibm9kZV9pZCI6Ik1EUTZWWE5sY2pFMk5qYzJNVFUzIiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xNjY3NjE1Nz92PTQiLCJncmF2YXRhcl9pZCI6IiIsInVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwiLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS96YWNoZ29sbCIsImZvbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2VycyIsImZvbGxvd2luZ191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2dpc3Rzey9naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdGFycmVkey9vd25lcn17L3JlcG99Iiwic3Vic2NyaXB0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N1YnNjcmlwdGlvbnMiLCJvcmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVwb3MiLCJldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRfZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJ1c2VyX3ZpZXdfdHlwZSI6InB1YmxpYyIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00S296cHgiLCJ0YWdfbmFtZSI6InYwLjEuMC1hbHBoYS4xOCIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjEuMC1hbHBoYS4xOCIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMTAtMDRUMTk6MDk6NThaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0xMC0wNFQxOToxNDoxOVoiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjEuMC1hbHBoYS4xOCIsInppcGJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3ppcGJhbGwvdjAuMS4wLWFscGhhLjE4IiwiYm9keSI6IiMjIENTViBJbXBvcnRzXHJcblxyXG5XZSd2ZSBtYWRlIHNvbWUgYmlnIHVwZGF0ZXMgZm9yIHRoZSBDU1YgaW1wb3J0cyBmZWF0dXJlIPCfjK4g8J+OiSBcclxuXHJcbioqWW91IGNhbiBub3cgaW1wb3J0IHRyYW5zYWN0aW9ucywgdHJhZGVzLCBhY2NvdW50cywgYW5kIGV2ZW4gYW4gb2xkIE1pbnQgZXhwb3J0ISoqXHJcblxyXG5IZXJlJ3MgYSBxdWljayBkZW1vIHRoYXQgc2hvd3MgaG93IGl0IGFsbCB3b3JrczogXHJcblxyXG5odHRwczovL2dpdGh1Yi5jb20vdXNlci1hdHRhY2htZW50cy9hc3NldHMvZTJlZmY1NWEtM2MzZC00ZTQyLThlNGEtYTk2NjQ0MWRjOWRjXHJcblxyXG5JbiBhZGRpdGlvbiB0byB0aGUgQ1NWIGltcG9ydHMsIHdlIGhhdmUgc2V2ZXJhbCBhcHAgc3RhYmlsaXR5IGZpeGVzIGFuZCBpbXByb3ZlbWVudHMgc3VjaCBhczpcclxuXHJcbi0gYEVVUmAgY3VycmVuY2llcyBhbmQgZGF0ZXMgYXJlIG5vdyBmb3JtYXR0ZWQgY29ycmVjdGx5IGJhc2VkIG9uIHVzZXIgbG9jYWxlXHJcbi0gVGhlIHRyYW5zYWN0aW9ucyBwYWdlIG5vdyBoYXMgYSBjb21wbGV0ZWQgbGlzdCBvZiBmaWx0ZXJzIHNvIHlvdSBjYW4gZmluZCB5b3VyIHRyYW5zYWN0aW9ucyBlYXNpZXJcclxuXHJcbiMjIFdoYXQncyBDaGFuZ2VkXHJcblxyXG4qIEZpeCBzdHlsZXMgb24gaW1wb3J0IG1vZGFsIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExODhcclxuKiBBZGQgZXJyb3IgaGFuZGxpbmcgZm9yIHZlaGljbGUgYW5kIHByb3BlcnR5IGFjY291bnQgY3JlYXRpb24gYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExNzlcclxuKiBGaW5pc2ggcmVtYWluaW5nIHRyYW5zYWN0aW9uIGZpbHRlcnMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTE4OVxyXG4qIEZpbmFsaXplIHRyYW5zYWN0aW9uIGRyYXdlciwgc2ltcGxpZnkgbW9uZXkgZm9ybSBoZWxwZXJzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExOTFcclxuKiBGaW5hbGl6ZSBwcm9maWxlIHNldHRpbmdzIHBhZ2UgZm9yIHYwLjIuMC1hbHBoYSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTk0XHJcbiogRml4OiBFc2NhcGUgYnV0dG9uIG5vdCBiZWluZyBoYW5kbGVkIG9uIHNldHRpbmdzIHBhZ2VzIGJ5IEBqZXN0aW5qb3NoaSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyMTBcclxuKiBDU1YgSW1wb3J0cyBPdmVyaGF1bCAoVHJhbnNhY3Rpb25zLCBUcmFkZXMsIEFjY291bnRzLCBhbmQgTWludCBpbXBvcnQgc3VwcG9ydCkgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTIwOVxyXG4qIFNvcnQgY3VycmVuY2llcyBieSBuYW1lIGFzIGEgc2Vjb25kIG9yZGVyIGJ5IEBhbGFnb3MgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjE2XHJcbiogRml4IGluY29ycmVjdCBwYXJ0aWFsIHN5bmMgYmFsYW5jZSBnZW5lcmF0aW9uIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyMjNcclxuKiBGaXggaW1wb3J0IG1pZ3JhdGlvbiBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjI3XHJcbiogQWRkIFN5bnRoIHByb3ZpZGVyIHRvIHNlbGYgaG9zdCBzZXR0aW5nIHBhZ2UgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTIzMFxyXG4qIEFsbG93IHVzZXJzIHRvIHNldCBwcmVmZXJyZWQgbG9jYWxlIGluIHNldHRpbmdzIGFuZCBwcm92aWRlIGJhc2ljIGRhdGUgYW5kIHRpbWUgbG9jYWxpemF0aW9uIHN1cHBvcnQgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTIyNlxyXG4qIEFkZCBmb3JtYXR0aW5nIGZvciBFVVIgbG9jYWxlcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjMxXHJcbiogVXNlIERCIGZvciBhdXRoIHNlc3Npb25zIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyMzNcclxuKiBGaXggc2lnbmFnZSBvbiB0cmFuc2FjdGlvbiBpbXBvcnRzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyMzZcclxuKiBBZGQgdGFnIGZpbHRlcmluZyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMjQwXHJcbiogSGFuZGxlIG1pc3Npbmcgd2Vla2VuZCBzdG9jayBwcmljZXMgaW4gc3luYyBwcm9jZXNzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEyNDJcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBAYWxhZ29zIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTIxNlxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjEuMC1hbHBoYS4xNy4uLnYwLjEuMC1hbHBoYS4xOCIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTc4NDY5NDg5L3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjozMSwiKzEiOjAsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5IjoyMCwiY29uZnVzZWQiOjAsImhlYXJ0Ijo0LCJyb2NrZXQiOjcsImV5ZXMiOjB9LCJtZW50aW9uc19jb3VudCI6NH0seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTc1MDQxNTU4IiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNzUwNDE1NTgvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTc1MDQxNTU4L2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC4xLjAtYWxwaGEuMTciLCJpZCI6MTc1MDQxNTU4LCJhdXRob3IiOnsibG9naW4iOiJ6YWNoZ29sbCIsImlkIjoxNjY3NjE1Nywibm9kZV9pZCI6Ik1EUTZWWE5sY2pFMk5qYzJNVFUzIiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xNjY3NjE1Nz92PTQiLCJncmF2YXRhcl9pZCI6IiIsInVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwiLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS96YWNoZ29sbCIsImZvbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2VycyIsImZvbGxvd2luZ191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2dpc3Rzey9naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdGFycmVkey9vd25lcn17L3JlcG99Iiwic3Vic2NyaXB0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N1YnNjcmlwdGlvbnMiLCJvcmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVwb3MiLCJldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRfZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJ1c2VyX3ZpZXdfdHlwZSI6InB1YmxpYyIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00S2J1d1ciLCJ0YWdfbmFtZSI6InYwLjEuMC1hbHBoYS4xNyIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjEuMC1hbHBoYS4xNyIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMDktMTNUMjE6MjQ6NDZaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0wOS0xM1QyMToyODo0NloiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjEuMC1hbHBoYS4xNyIsInppcGJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3ppcGJhbGwvdjAuMS4wLWFscGhhLjE3IiwiYm9keSI6IiMjIFNlbGYgSG9zdGVkIE1heWJlIEluc3RhbmNlcyBjYW4gbm93IGJsb2NrIG5ldyByZWdpc3RyYXRpb25zXHJcblxyXG5UaGlzIHJlbGVhc2UgY29tZXMgd2l0aCB0b25zIG9mIGxpdHRsZSBVSSBpbXByb3ZlbWVudHMsIGJ1ZyBmaXhlcywgYW5kIG1vc3QgaW1wb3J0YW50bHksIHRoZSBhYmlsaXR5IHRvIGJsb2NrIHVzZXIgcmVnaXN0cmF0aW9ucyBvbiB5b3VyIHNlbGYgaG9zdGVkIE1heWJlIGluc3RhbmNlISDwn46JIFxyXG5cclxuQmlnIFMvTyB0byBAdG9ueXZpbmNlIGZvciBnZXR0aW5nIHRoaXMgaW1wbGVtZW50ZWQuICBIZXJlJ3MgYSBxdWljayBkZW1vIG9mIGhvdyB5b3UgY2FuIGJsb2NrIHNpZ251cHMgdG8geW91ciBpbnN0YW5jZSB3aXRoIGludml0ZSBjb2RlczpcclxuXHJcbmh0dHBzOi8vZ2l0aHViLmNvbS91c2VyLWF0dGFjaG1lbnRzL2Fzc2V0cy9lNGRhOWZkZC1hNzZmLTRlZDAtODQzMC1iODZlNzFkYmYzMjBcclxuXHJcbiMjIFdoYXQncyBDaGFuZ2VkXHJcbiogRml4IHVuYWJsZSB0byBjcmVhdGUgRGVwb3NpdCBlbnRyaWVzIGluIGludmVzdG1lbnQgcG9ydGZvbGlvIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTI1XHJcbiogRml4IGFjY291bnQgc3luYyB3aGVuIHByaWNlcyBtaXNzaW5nIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTI3XHJcbiogRml4IGFjY291bnQgdHJhbnNhY3Rpb24gZm9ybSByZXNldHRpbmcgYW1vdW50IHRvIDAgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTEzM1xyXG4qIEZpeCBtZXJjaGFudHMgY29sb3IgcGlja2VyIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExMzRcclxuKiBDYXRlZ29yaWVzLCB0YWdzLCBtZXJjaGFudHMsIGFuZCBtZW51cyBpbXByb3ZlbWVudHMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTEzNVxyXG4qIFJlbW92ZSB1bnVzZWQgc2V0dGluZ3MgdGVtcG9yYXJpbHkgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTEzNlxyXG4qIEZlZWRiYWNrIHBhZ2UgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTE2MFxyXG4qIEZpeCB2YWx1YXRpb24gZnJhbWUgaXNzdWUgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTE2MlxyXG4qIENvbnNvbGlkYXRlIHRyYW5zYWN0aW9uIG1lbnUgaXRlbXMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTE2NFxyXG4qIFVwZGF0ZSBlbXB0eSBhY2NvdW50IHN0YXRlcyBvbiBkYXNoYm9hcmQgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTE2NlxyXG4qIEZpeDogV2hlbiB0cmFuc2FjdGlvbiBkcmF3ZXIgY2xvc2VkLCB0dXJibyBmcmFtZSByZW5kZXJzIGJlbG93IG1haW4gY29udGVudCBieSBAamVzdGluam9zaGkgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTY3XHJcbiogQWRkIHNldHRpbmcgdG8gZGlzYWJsZSBuZXcgdXNlciByZWdpc3RyYXRpb24gb24gc2VsZi1ob3N0ZWQgaW5zdGFuY2VzIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTYzXHJcbiogRml4IGd1aWRlIHRleHQgZnJvbSBcInJlZ2lzdGVyXCIgdG8gXCJjcmVhdGUgYW4gYWNjb3VudFwiIGJ5IEB2YWxsZXp3IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTE2OFxyXG4qIEFkZCBzeW5jIHN0YXR1cyBhbmQgZXJyb3JzIHRvIGFjY291bnQgc2V0dGluZ3MgcGFnZSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTY5XHJcbiogRml4IG1pc3Npbmcgc3luY19hbGxfYnV0dG9uIHBhcnRpYWwgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExNzJcclxuKiBPbWl0IHRyZW5kIGlmIHplcm8gaW4gc2lkZWJhciBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTczXHJcbiogU3VwcG9ydCBtdWx0aS1jdXJyZW5jeSB0cmFuc2ZlcnMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTE3NVxyXG4qIEFsbG93IHBhcnRpYWwgaW52ZXN0bWVudCBxdWFudGl0aWVzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExNzRcclxuKiBUcmFuc2FjdGlvbiBwYWdlIGRlc2lnbiBmaXhlcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTc2XHJcbiogQWRkIGJhc2ljIHNlbGYgaG9zdGVkIG9uYm9hcmRpbmcgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTE3N1xyXG5cclxuIyMgTmV3IENvbnRyaWJ1dG9yc1xyXG4qIEBqZXN0aW5qb3NoaSBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExNjdcclxuKiBAdmFsbGV6dyBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExNjhcclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4xLjAtYWxwaGEuMTYuLi52MC4xLjAtYWxwaGEuMTciLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3NTA0MTU1OC9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6MjksIisxIjo2LCItMSI6MCwibGF1Z2giOjAsImhvb3JheSI6MCwiY29uZnVzZWQiOjAsImhlYXJ0Ijo3LCJyb2NrZXQiOjEzLCJleWVzIjozfSwibWVudGlvbnNfY291bnQiOjR9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3MTY4NDg3MCIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTcxNjg0ODcwL2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3MTY4NDg3MC9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wLWFscGhhLjE2IiwiaWQiOjE3MTY4NDg3MCwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwidXNlcl92aWV3X3R5cGUiOiJwdWJsaWMiLCJzaXRlX2FkbWluIjpmYWxzZX0sIm5vZGVfaWQiOiJSRV9rd0RPS190eEhNNEtPN1FHIiwidGFnX25hbWUiOiJ2MC4xLjAtYWxwaGEuMTYiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC4xLjAtYWxwaGEuMTYiLCJkcmFmdCI6ZmFsc2UsInByZXJlbGVhc2UiOnRydWUsImNyZWF0ZWRfYXQiOiIyMDI0LTA4LTIzVDE0OjM5OjE0WiIsInB1Ymxpc2hlZF9hdCI6IjIwMjQtMDgtMjNUMTQ6NDQ6MTVaIiwiYXNzZXRzIjpbXSwidGFyYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvdGFyYmFsbC92MC4xLjAtYWxwaGEuMTYiLCJ6aXBiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS96aXBiYWxsL3YwLjEuMC1hbHBoYS4xNiIsImJvZHkiOiJUaGlzIHdlZWsncyByZWxlYXNlIGNvbWVzIHdpdGggYSB2YXJpZXR5IG9mIGJ1ZyBmaXhlcyBhbmQgaW1wcm92ZW1lbnRzIHRvIHRoZSBVSS5cclxuXHJcbkFkZGl0aW9uYWxseSwgdXNlcnMgY2FuIG5vdyBpbnB1dCBkZXRhaWxzIGZvciB0aGVpciBwcm9wZXJ0eSBhbmQgdmVoaWNsZSBhY2NvdW50cyBhcyBzaG93biBpbiB0aGUgdmlkZW8gYmVsb3cuICBJbiB0aGUgbmVhciBmdXR1cmUsIE1heWJlIHdpbGwgc3VwcG9ydCBkYXRhIHByb3ZpZGVycyByZWxhdGVkIHRvIHRoZSBcInZhbHVhdGlvblwiIG9mIHByb3BlcnRpZXMgYW5kIHZlaGljbGVzIChpLmUuIFppbGxvdywgS0JCKS4gIFdlIHdpbGwgdXNlIHRoZSBpbmZvcm1hdGlvbiBmcm9tIHVzZXIgYWNjb3VudHMgdG8gYXV0b21hdGljYWxseSBmZXRjaCBlc3RpbWF0ZWQgbWFya2V0IHZhbHVlcyBmb3IgdGhlc2UgYXNzZXRzIHdoaWNoIHdpbGwgdGhlbiBiZSBhZGRlZCBwZXJpb2RpY2FsbHkgYXMgXCJWYWx1YXRpb25zXCIgaW4gdGhlIHZhbHVlIHRhYiBvZiBlYWNoIGFjY291bnQuICBUaGlzIHdpbGwgdGhlbiBzaG93IHVwIGluIHRoZSBoaXN0b3J5IGdyYXBoIGZvciB0aGUgYWNjb3VudCBiYWxhbmNlLlxyXG5cclxuaHR0cHM6Ly9naXRodWIuY29tL3VzZXItYXR0YWNobWVudHMvYXNzZXRzL2ZkNzU5YzgyLWEyNWMtNGM4ZC04ZjE2LWY1NzdlMDQxMGZiNVxyXG5cclxuIyMgV2hhdCdzIENoYW5nZWRcclxuXHJcbiogUmVmYWN0b3I6IEFsbG93IG90aGVyIGltcG9ydCBmaWxlcyBieSBAcGVkcm9jYXJtb25hIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA5OVxyXG4qIEJ1bXAgc2VudHJ5LXJ1YnkgZnJvbSA1LjE4LjIgdG8gNS4xOS4wIGJ5IEBkZXBlbmRhYm90IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTEwOFxyXG4qIEJ1bXAgc3RpbXVsdXMtcmFpbHMgZnJvbSAxLjMuMyB0byAxLjMuNCBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExMDZcclxuKiBCdW1wIGF3cy1zZGstczMgZnJvbSAxLjE1Ny4wIHRvIDEuMTU4LjAgYnkgQGRlcGVuZGFib3QgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTA1XHJcbiogQnVtcCBydWJ5LWxzcC1yYWlscyBmcm9tIDAuMy4xMiB0byAwLjMuMTMgYnkgQGRlcGVuZGFib3QgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTA3XHJcbiogQnVtcCBwcm9wc2hhZnQgZnJvbSAwLjkuMCB0byAwLjkuMSBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExMDRcclxuKiBCdW1wIGdvb2Rfam9iIGZyb20gNC4xLjEgdG8gNC4yLjAgYnkgQGRlcGVuZGFib3QgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTAyXHJcbiogQnVtcCB0YWlsd2luZGNzcy1yYWlscyBmcm9tIDIuNy4yIHRvIDIuNy4zIGJ5IEBkZXBlbmRhYm90IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTEwM1xyXG4qIEZpeCBxdWVyeSB3aGVuIGFjY291bnQgaGFzIHplcm8gaW5jb21lIGFuZCBleHBlbnNlIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExMTJcclxuKiBGaXggaG9sZGluZyBuYW1lIGVycm9yIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExMTNcclxuKiBBZGQgUHJvcGVydHkgRGV0YWlscyBWaWV3IGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzExMTZcclxuKiBCYXNpYyBWZWhpY2xlIFZpZXcgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTExN1xyXG4qIFJ1Ym9jb3AgdXBkYXRlcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMTE4XHJcbiogRml4IGZpbGUgdXBsb2FkIFVJIG9wZW5pbmcgdHdpY2UgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTExOVxyXG5cclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4xLjAtYWxwaGEuMTUuLi52MC4xLjAtYWxwaGEuMTYiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3MTY4NDg3MC9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6MjMsIisxIjoxMCwiLTEiOjAsImxhdWdoIjowLCJob29yYXkiOjAsImNvbmZ1c2VkIjowLCJoZWFydCI6MCwicm9ja2V0IjoxMCwiZXllcyI6M30sIm1lbnRpb25zX2NvdW50IjozfSx7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNzA2NTU5MjQiLCJhc3NldHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3MDY1NTkyNC9hc3NldHMiLCJ1cGxvYWRfdXJsIjoiaHR0cHM6Ly91cGxvYWRzLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNzA2NTU5MjQvYXNzZXRzez9uYW1lLGxhYmVsfSIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvdGFnL3YwLjEuMC1hbHBoYS4xNSIsImlkIjoxNzA2NTU5MjQsImF1dGhvciI6eyJsb2dpbiI6InphY2hnb2xsIiwiaWQiOjE2Njc2MTU3LCJub2RlX2lkIjoiTURRNlZYTmxjakUyTmpjMk1UVTMiLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE2Njc2MTU3P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbCIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL3phY2hnb2xsIiwiZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93aW5ney9vdGhlcl91c2VyfSIsImdpc3RzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZ2lzdHN7L2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N0YXJyZWR7L293bmVyfXsvcmVwb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3Vic2NyaXB0aW9ucyIsIm9yZ2FuaXphdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZXBvcyIsImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2V2ZW50c3svcHJpdmFjeX0iLCJyZWNlaXZlZF9ldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIsInVzZXJfdmlld190eXBlIjoicHVibGljIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRLTEFDMCIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjE1IiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjE1IiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wOC0xNlQyMDowOTozN1oiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA4LTE2VDIwOjE4OjIyWiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjE1IiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuMTUiLCJib2R5IjoiVGhpcyB3ZWVrLCB3ZSByZWxlYXNlZCB0aGUgZmlyc3QgdmVyc2lvbiBvZiBhbiBpc3N1ZSB0cmFja2luZyBzeXN0ZW0gZGlyZWN0bHkgd2l0aGluIHRoZSBNYXliZSBhcHAhXHJcblxyXG5XaHk/XHJcblxyXG5BcyBhbiBvcGVuLXNvdXJjZSBwZXJzb25hbCBmaW5hbmNlIGFwcCwgTWF5YmUgaGFzIGEgbXVjaCBsYXJnZXIgXCJzZWxmIHNlcnZpY2VcIiBjb21wb25lbnQgdGhhbiBtb3N0IGFwcGxpY2F0aW9ucy4gIFdoaWxlIHdlJ3JlIGluIHRoZSBlYXJseSBkYXlzLCB0aGUgZ29hbCBpcyB0byBzdXBwb3J0IGEgZ2xvYmFsIHVzZXIgYmFzZSwgd2hpY2ggbWVhbnMgd2UgaGF2ZSB0byBzdXBwb3J0IF9hIGxvdF8gb2YgZGF0YSBwcm92aWRlcnMuXHJcblxyXG5FYWNoIGRhdGEgcHJvdmlkZXIgY29tZXMgd2l0aCBpdHMgb3duIHNldCBvZiBudWFuY2VzIGFuZCBlcnJvcnMgdGhhdCBjYW4gYmUgY2hhbGxlbmdpbmcgdG8gZGVidWc7IGVzcGVjaWFsbHkgZm9yIG91ciBzbWFsbCB0ZWFtIHRoYXQgZG9lc24ndCBoYXZlIGFjY2VzcyB0byB5b3VyIHNlbGYgaG9zdGVkIGNvbmZpZ3VyYXRpb24uICBXaGVuIHRoaW5ncyBnbyB3cm9uZywgdGhlcmUgYXJlIGEgaGFuZGZ1bCBvZiBwb3NzaWJsZSBjYXVzZXMuXHJcblxyXG5JbiBvcmRlciB0byBjb21iYXQgdGhpcywgdGhlIGB2MC4xLjAtYWxwaGEuMTVgIHJlbGVhc2UgaW50cm9kdWNlcyBhIFwic2VsZiByZXNvbHV0aW9uXCIgaXNzdWUgdHJhY2tpbmcgc3lzdGVtLiAgVGhlIHZpZGVvIGJlbG93IGRlbW9uc3RyYXRlcyBob3cgYSB1c2VyIHdobyBoYXMgaW5jb3JyZWN0bHkgY29uZmlndXJlZCB0aGVpciBleGNoYW5nZSByYXRlcyBwcm92aWRlciBjYW4gKippZGVudGlmeSwgZGlhZ25vc2UsIGFuZCBmaXggdGhlIGlzc3VlIGFsbCB3aXRoaW4gdGhlIE1heWJlIGFwcCoqOlxyXG5cclxuaHR0cHM6Ly9naXRodWIuY29tL3VzZXItYXR0YWNobWVudHMvYXNzZXRzLzdjYzk4Yzc5LTliNzItNDI2MC1iYzE3LTkwN2JjMzNiMTM5MFxyXG5cclxuIyMgV2hhdCdzIENoYW5nZWRcclxuKiBEZXBvc2l0LCBXaXRoZHJhd2FsLCBhbmQgSW50ZXJlc3QgVHJhbnNhY3Rpb25zIGZvciBJbnZlc3RtZW50IFZpZXcgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA3NVxyXG4qIEJ1bXAgdGFpbHdpbmRjc3MtcmFpbHMgZnJvbSAyLjYuNSB0byAyLjcuMiBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNzhcclxuKiBCdW1wIGJvb3RzbmFwIGZyb20gMS4xOC4zIHRvIDEuMTguNCBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNzlcclxuKiBCdW1wIHJ1YnktbHNwLXJhaWxzIGZyb20gMC4zLjExIHRvIDAuMy4xMiBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwODFcclxuKiBSZWZhY3RvcjogVXNlIG5hdGl2ZSBlcnJvciBpMThuIGxvb2t1cCBieSBAcGVkcm9jYXJtb25hIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA3NlxyXG4qIEJ1bXAgcmFpbHMgZnJvbSBgNDM1MzBiNGAgdG8gYGY2ZDYyYjVgIGJ5IEBkZXBlbmRhYm90IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA4M1xyXG4qIEZpeDogaTE4biBzeW1ib2wgdHlwbyBieSBAcGVkcm9jYXJtb25hIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA4NVxyXG4qIEJ1bXAgcnVieSBmcm9tIDMuMy4xIHRvIDMuMy40IGJ5IEBDbHVzdGVyNDQ0IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA4NFxyXG4qIEZpeCBmb3IgaW52YWxpZCBhY2NvdW50YWJsZSBkYXRhIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwODZcclxuKiBhZGQgcGFnaW5hdGlvbiB0byBhY2NvdW50IHRyYW5zYWN0aW9ucyBsaXN0IGJ5IEBjb2RlLWNvbnN0cnVjdG9yIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA5NVxyXG4qIEFjY291bnQgSXNzdWUgTW9kZWwgYW5kIFJlc29sdXRpb24gRmxvdyArIFRyb3VibGVzaG9vdGluZyBndWlkZXMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA5MFxyXG4qIEFkZCBzdXBwb3J0IGZvciBkaWZmZXJlbnQgY29sdW1uIHNlcGFyYXRvciBpbiBjc3YgaW1wb3J0IGxvZ2ljIGJ5IEBjb2RlLWNvbnN0cnVjdG9yIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA5NlxyXG4qIEltcHJvdmVkIFVJIHdhcm5pbmcgc3RhdGVzIGZvciBob2xkaW5ncyB3aXRoIG1pc3NpbmcgZGF0YSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDk4XHJcblxyXG4jIyBOZXcgQ29udHJpYnV0b3JzXHJcbiogQHBlZHJvY2FybW9uYSBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNzZcclxuKiBAQ2x1c3RlcjQ0NCBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwODRcclxuKiBAY29kZS1jb25zdHJ1Y3RvciBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwOTVcclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4xLjAtYWxwaGEuMTQuLi52MC4xLjAtYWxwaGEuMTUiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE3MDY1NTkyNC9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6MTEsIisxIjoxMSwiLTEiOjAsImxhdWdoIjowLCJob29yYXkiOjAsImNvbmZ1c2VkIjowLCJoZWFydCI6MCwicm9ja2V0IjowLCJleWVzIjowfSwibWVudGlvbnNfY291bnQiOjV9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2OTU5MTAyOSIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY5NTkxMDI5L2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2OTU5MTAyOS9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wLWFscGhhLjE0IiwiaWQiOjE2OTU5MTAyOSwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwidXNlcl92aWV3X3R5cGUiOiJwdWJsaWMiLCJzaXRlX2FkbWluIjpmYWxzZX0sIm5vZGVfaWQiOiJSRV9rd0RPS190eEhNNEtHOEQxIiwidGFnX25hbWUiOiJ2MC4xLjAtYWxwaGEuMTQiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC4xLjAtYWxwaGEuMTQiLCJkcmFmdCI6ZmFsc2UsInByZXJlbGVhc2UiOnRydWUsImNyZWF0ZWRfYXQiOiIyMDI0LTA4LTA5VDIxOjQyOjQ4WiIsInB1Ymxpc2hlZF9hdCI6IjIwMjQtMDgtMDlUMjE6NDk6MjBaIiwiYXNzZXRzIjpbXSwidGFyYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvdGFyYmFsbC92MC4xLjAtYWxwaGEuMTQiLCJ6aXBiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS96aXBiYWxsL3YwLjEuMC1hbHBoYS4xNCIsImJvZHkiOiJCYXNpYyBpbnZlc3RtZW50IHBvcnRmb2xpb3MgYXJlIGhlcmUhIPCfpbMgXHJcblxyXG5XaGlsZSB3ZSdyZSBzdGlsbCBpbiB0aGUgdmVyeSBlYXJseSBkYXlzIGZvciBpbnZlc3RtZW50IGFjY291bnRzLCB0aGlzIHdlZWsgY2FwcGVkIG9mZiBzb21lIHJlYWxseSBpbXBvcnRhbnQgYWRkaXRpb25zIHRvIHRoZSBpbnZlc3RtZW50IGFjY291bnQgcGFnZSB2aWV3LlxyXG5cclxuVXNlcnMgY2FuIG5vdzpcclxuXHJcbi0gU2VlIGEgYnJlYWtkb3duIG9mIHRoZWlyIGNhc2ggKyBob2xkaW5ncyBiYWxhbmNlIGluIGEgdG9vbHRpcFxyXG4tIEFkZCBidXkvc2VsbCBpbnZlc3RtZW50IHRyYWRlcyB0aGF0IHdpbGwgYXV0b21hdGljYWxseSBzeW5jIGFuZCByZWJ1aWxkIHRoZWlyIHBvcnRmb2xpbyBhbmQgaGlzdG9yaWNhbCBncmFwaFxyXG5cclxuTW92aW5nIGZvcndhcmQsIHdlJ2xsIGJlIHJlZmluaW5nIGFuZCBhZGRpbmcgdG8gdGhlIGludmVzdG1lbnQgcG9ydGZvbGlvIGZlYXR1cmUgYW5kIGNvbXBsZW1lbnRpbmcgaXQgd2l0aCB0cm91Ymxlc2hvb3RpbmcgZ3VpZGVzIHNvIHlvdSBrbm93IGV4YWN0bHkgd2hhdCdzIGNhdXNpbmcgYW55IGRpc2NyZXBhbmNpZXMgYmV0d2VlbiBNYXliZSdzIGNhbGN1bGF0aW9ucyBhbmQgeW91ciBicm9rZXJhZ2UncyBjYWxjdWxhdGlvbnMuXHJcblxyXG5odHRwczovL2dpdGh1Yi5jb20vdXNlci1hdHRhY2htZW50cy9hc3NldHMvZjRjOGJjNjUtMzFmNi00NjI3LWI0ZTctNDc3ZDA2ODdjNTcwXHJcblxyXG4jIyBXaGF0J3MgQ2hhbmdlZFxyXG5cclxuKiBCdW1wIHRhaWx3aW5kY3NzLXJhaWxzIGZyb20gMi42LjQgdG8gMi42LjUgYnkgQGRlcGVuZGFib3QgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDU4XHJcbiogQnVtcCBmYXJhZGF5IGZyb20gMi4xMC4wIHRvIDIuMTAuMSBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNTVcclxuKiBCdW1wIGVyYl9saW50IGZyb20gMC41LjAgdG8gMC42LjAgYnkgQGRlcGVuZGFib3QgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDU3XHJcbiogQnVtcCBhd3Mtc2RrLXMzIGZyb20gMS4xNTYuMCB0byAxLjE1Ny4wIGJ5IEBkZXBlbmRhYm90IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA1NFxyXG4qIEJ1bXAgZ29vZF9qb2IgZnJvbSA0LjEuMCB0byA0LjEuMSBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNTNcclxuKiBCdW1wIHBhZ3kgZnJvbSA5LjAuMyB0byA5LjAuNSBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNTZcclxuKiBCdW1wIHJhaWxzIGZyb20gYDVjYjVjYWRgIHRvIGA0MzUzMGI0YCBieSBAZGVwZW5kYWJvdCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNTlcclxuKiBBZGQgc291cmNlIGhlYWRlcnMgdG8gU3ludGggY2FsbHMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA2MlxyXG4qIEFkZCBzdGltdWx1cyB0b29sdGlwIGNvbnRyb2xsZXIgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNjVcclxuKiBGZXRjaCBleGNoYW5nZSByYXRlcyBpbiBidWxrIGZyb20gc3ludGggYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwNjlcclxuKiBGaXggbWluaXRlc3QgYXNzZXJ0X25pbCB3YXJuaW5nIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDcwXHJcbiogQWxsb3cgdXNlciB0byBhZGQgYnV5IGFuZCBzZWxsIHRyYWRlIHRyYW5zYWN0aW9ucyBmb3IgaW52ZXN0bWVudCBhY2NvdW50cyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDY2XHJcbiogVGVtcCBmaXggZm9yIG1pc3NpbmcgYWNjb3VudGFibGVzIG9uIHNlbGYgaG9zdGVkIGluc3RhbmNlcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDcxXHJcblxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjEuMC1hbHBoYS4xMy4uLnYwLjEuMC1hbHBoYS4xNCIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY5NTkxMDI5L3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjoxNiwiKzEiOjAsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5IjoxNiwiY29uZnVzZWQiOjAsImhlYXJ0IjowLCJyb2NrZXQiOjAsImV5ZXMiOjB9LCJtZW50aW9uc19jb3VudCI6M30seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY4NTEwNTU5IiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjg1MTA1NTkvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY4NTEwNTU5L2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC4xLjAtYWxwaGEuMTMiLCJpZCI6MTY4NTEwNTU5LCJhdXRob3IiOnsibG9naW4iOiJ6YWNoZ29sbCIsImlkIjoxNjY3NjE1Nywibm9kZV9pZCI6Ik1EUTZWWE5sY2pFMk5qYzJNVFUzIiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xNjY3NjE1Nz92PTQiLCJncmF2YXRhcl9pZCI6IiIsInVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwiLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS96YWNoZ29sbCIsImZvbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2VycyIsImZvbGxvd2luZ191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2dpc3Rzey9naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdGFycmVkey9vd25lcn17L3JlcG99Iiwic3Vic2NyaXB0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N1YnNjcmlwdGlvbnMiLCJvcmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVwb3MiLCJldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRfZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJ1c2VyX3ZpZXdfdHlwZSI6InB1YmxpYyIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00S0MwUmYiLCJ0YWdfbmFtZSI6InYwLjEuMC1hbHBoYS4xMyIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjEuMC1hbHBoYS4xMyIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMDgtMDJUMjE6MTA6MTZaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0wOC0wMlQyMToxMDo1OVoiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjEuMC1hbHBoYS4xMyIsInppcGJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3ppcGJhbGwvdjAuMS4wLWFscGhhLjEzIiwiYm9keSI6IkNvbWluZyBvZmYgdGhlIGJhY2sgb2YgbGFzdCB3ZWVrJ3MgcmVsZWFzZSB3aGljaCBpbnRyb2R1Y2VkIGJhc2ljIGludmVzdG1lbnQgcG9ydGZvbGlvIHZpZXdzIGFuZCB0aGUgYWJpbGl0eSB0byBzeW5jIGludmVzdG1lbnQgaG9sZGluZ3MsIHRoaXMgd2VlaydzIHJlbGVhc2UgaW50cm9kdWNlcyBmdWxsIHN1cHBvcnQgZm9yIHN0b2NrIHByaWNlcyB3aXRoIHRoZSBbU3ludGggQVBJXShodHRwczovL3N5bnRoZmluYW5jZS5jb20vKSEgKHdoaWNoIGhhcyBhIGdlbmVyb3VzIGZyZWUgdGllciB0byBnZXQgc3RhcnRlZClcclxuXHJcblRoaXMgbWVhbnMgdGhhdCBpbnZlc3RtZW50IGFjY291bnQgdmlld3MgY2FuIG5vdyBwcm9wZXJseSBjYWxjdWxhdGUgYW5kIHNob3cgYW4gYWdncmVnYXRlZCBtYXJrZXQgdmFsdWUgb2YgYWxsIHRoZSBpbnZlc3RtZW50cyBpbiBpdCBhbmQgZ3JhY2VmdWxseSBoYW5kbGUgbWlzc2luZyBzdG9jayBwcmljZXMuXHJcblxyXG5JbiBhIGZ1dHVyZSByZWxlYXNlLCB1c2VycyB3aWxsIGhhdmUgZnVsbCBjb250cm9sIHRvIGNyZWF0ZSBidXkgJiBzZWxsIHRyYWRlcywgbWFuYWdlIHRoZWlyIGludmVzdG1lbnQgcG9ydGZvbGlvLCBhbmQgc2VlIHRvdGFsIHJldHVybnMgb3ZlciB2YXJpb3VzIHRpbWUgcGVyaW9kcyBhbG9uZ3NpZGUgdGhlaXIgaGlzdG9yaWNhbCB2YWx1ZSBncmFwaC5cclxuXHJcbmh0dHBzOi8vZ2l0aHViLmNvbS91c2VyLWF0dGFjaG1lbnRzL2Fzc2V0cy9jMzliMTcwNi05N2E3LTRlM2UtYjU2OC05ZDliMDllMzhlMDVcclxuXHJcbiMjIFdoYXQncyBDaGFuZ2VkXHJcbiogRml4OiBPbWl0IGxheW91dCBmb3IgdHVyYm8gZnJhbWVzIHdpdGggY3VzdG9tIHNpZGViYXIgbGF5b3V0IGJ5IEBwcmFuYXZiYWJ1IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTAyNFxyXG4qIGZpeDogbG9uZyBlbWFpbHMgb3ZlcmZsb3cgaW4gYWNjb3VudCBtZW51IGRyb3Bkb3duIGJ5IEBNaWtoYWlsV2FoaWIgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDM0XHJcbiogRml4IGRlbW8gZGF0YSByZXNldCBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA0MVxyXG4qIEVuc3VyZSB0cmFuc2ZlciBuYW1lIGlzIHBvcHVsYXRlZCBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDQyXHJcbiogQWRkIHNlY3VyaXR5IHByaWNlcyBwcm92aWRlciAoU3ludGggaW50ZWdyYXRpb24pIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwMzlcclxuKiBTaG93IGNhc2ggKyBob2xkaW5ncyB2YWx1ZSBmb3IgaW52ZXN0bWVudCBhY2NvdW50IHZpZXcgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTA0NlxyXG5cclxuIyMgTmV3IENvbnRyaWJ1dG9yc1xyXG4qIEBwcmFuYXZiYWJ1IG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTAyNFxyXG4qIEBNaWtoYWlsV2FoaWIgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDM0XHJcblxyXG4qKkZ1bGwgQ2hhbmdlbG9nKio6IGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL2NvbXBhcmUvdjAuMS4wLWFscGhhLjEyLi4udjAuMS4wLWFscGhhLjEzIiwicmVhY3Rpb25zIjp7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjg1MTA1NTkvcmVhY3Rpb25zIiwidG90YWxfY291bnQiOjMwLCIrMSI6NCwiLTEiOjAsImxhdWdoIjowLCJob29yYXkiOjEzLCJjb25mdXNlZCI6MCwiaGVhcnQiOjYsInJvY2tldCI6NywiZXllcyI6MH0sIm1lbnRpb25zX2NvdW50Ijo0fSx7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjczODIyNzciLCJhc3NldHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2NzM4MjI3Ny9hc3NldHMiLCJ1cGxvYWRfdXJsIjoiaHR0cHM6Ly91cGxvYWRzLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjczODIyNzcvYXNzZXRzez9uYW1lLGxhYmVsfSIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvdGFnL3YwLjEuMC1hbHBoYS4xMiIsImlkIjoxNjczODIyNzcsImF1dGhvciI6eyJsb2dpbiI6InphY2hnb2xsIiwiaWQiOjE2Njc2MTU3LCJub2RlX2lkIjoiTURRNlZYTmxjakUyTmpjMk1UVTMiLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE2Njc2MTU3P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbCIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL3phY2hnb2xsIiwiZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93aW5ney9vdGhlcl91c2VyfSIsImdpc3RzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZ2lzdHN7L2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N0YXJyZWR7L293bmVyfXsvcmVwb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3Vic2NyaXB0aW9ucyIsIm9yZ2FuaXphdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZXBvcyIsImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2V2ZW50c3svcHJpdmFjeX0iLCJyZWNlaXZlZF9ldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIsInVzZXJfdmlld190eXBlIjoicHVibGljIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRKLWcwRiIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjEyIiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjEyIiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wNy0yNlQxNDo0ODoyMVoiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA3LTI2VDE0OjUwOjM2WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjEyIiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuMTIiLCJib2R5IjoiIyMgV2hhdCdzIENoYW5nZWRcclxuKiBTZXQgbGFzdF9sb2dpbl9hdCBvbmx5IG9uIGxvZ2luIGluc3RlYWQgb2YgZXZlcnkgc2luZ2xlIGFjdGlvbiBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTAxN1xyXG4qIEJ1bXAgcGFneSB3aXRoIGZpeCBmb3IgYnJlYWtpbmcgY2hhbmdlcyAgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwMTZcclxuKiBGaXggZm9ybSBsYWJlbHMgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwMDRcclxuKiBGaXggY3VyZW5jeSBmb3JtYXQgYnkgQEp1bGl1c01pZWxpYXVza2FzIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTAyMFxyXG4qIEltcGxlbWVudCBhdXRvIGZhbWlseSBzeW5jcyBvbiBsb2dpbiBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDIxXHJcbiogQmFzaWMgUG9ydGZvbGlvIFZpZXdzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzEwMDBcclxuKiBGaXggY3VycmVuY3kgZm9ybWF0dGluZyBpbiBwaWUgY2hhcnQgdmlzdWFsaXphdGlvbiBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDIyXHJcbiogU2V0IG1pbmltdW0gc3VwcG9ydGVkIGRhdGUgZm9yIGFjY291bnQgZW50cmllcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC8xMDIzXHJcblxyXG4jIyBOZXcgQ29udHJpYnV0b3JzXHJcbiogQEp1bGl1c01pZWxpYXVza2FzIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvMTAyMFxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjEuMC1hbHBoYS4xMS4uLnYwLjEuMC1hbHBoYS4xMiIsIm1lbnRpb25zX2NvdW50IjozfSx7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjYyOTY5NTMiLCJhc3NldHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2NjI5Njk1My9hc3NldHMiLCJ1cGxvYWRfdXJsIjoiaHR0cHM6Ly91cGxvYWRzLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjYyOTY5NTMvYXNzZXRzez9uYW1lLGxhYmVsfSIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvdGFnL3YwLjEuMC1hbHBoYS4xMSIsImlkIjoxNjYyOTY5NTMsImF1dGhvciI6eyJsb2dpbiI6InphY2hnb2xsIiwiaWQiOjE2Njc2MTU3LCJub2RlX2lkIjoiTURRNlZYTmxjakUyTmpjMk1UVTMiLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE2Njc2MTU3P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbCIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL3phY2hnb2xsIiwiZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93aW5ney9vdGhlcl91c2VyfSIsImdpc3RzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZ2lzdHN7L2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N0YXJyZWR7L293bmVyfXsvcmVwb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3Vic2NyaXB0aW9ucyIsIm9yZ2FuaXphdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZXBvcyIsImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2V2ZW50c3svcHJpdmFjeX0iLCJyZWNlaXZlZF9ldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIsInVzZXJfdmlld190eXBlIjoicHVibGljIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRKNlgxNSIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjExIiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjExIiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wNy0xOVQyMDowOTowNVoiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA3LTE5VDIwOjA5OjM4WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjExIiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuMTEiLCJib2R5IjoiIyMgV2hhdCdzIENoYW5nZWRcclxuKiBXcmFwIGFjY291bnQgdXBkYXRlIGluIGEgdHJhbnNhY3Rpb24gYnkgQGFjY2Vzc2QgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85ODVcclxuKiBBbGxvdyBDU1YgZmlsZSB1cGxvYWQgaW4gaW1wb3J0IGZsb3cgYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk4NlxyXG4qIFNhbml0aXplIGlucHV0IGZvciBpbGlrZSBpbiBBY2NvdW50OjpFbnRyeS5zZWFyY2ggYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk4OFxyXG4qIEludmVzdG1lbnQgUG9ydGZvbGlvIFN5bmMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTc0XHJcbiogTW9yZSBjb21wb3NhYmxlIGZvcm1zIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk4OVxyXG4qIEFkZCBkZWZhdWx0IGN1cnJlbmNpZXMgdG8gZm9ybXMgYmFzZWQgb24gcHJlZmVyZW5jZSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85OTRcclxuKiBCdWlsZCBzYW1wbGUgcG9ydGZvbGlvIGRldGVybWluaXN0aWNhbGx5IGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk5M1xyXG4qIEFkZCBjdXJyZW5jeSB2YWxpZGF0aW9uIHRvIGFjY291bnQsIHVwZGF0ZSBkZW1vIGRhdGEgZ2VuZXJhdG9yIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk5NlxyXG4qIFN5bmMgbm90aWZpY2F0aW9ucyBhbmQgdHJvdWJsZXNob290aW5nIGd1aWRlcyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85OThcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBAYWNjZXNzZCBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk4NVxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjEuMC1hbHBoYS4xMC4uLnYwLjEuMC1hbHBoYS4xMSIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY2Mjk2OTUzL3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjoxNCwiKzEiOjAsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5IjowLCJjb25mdXNlZCI6MCwiaGVhcnQiOjAsInJvY2tldCI6MTQsImV5ZXMiOjB9LCJtZW50aW9uc19jb3VudCI6M30seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY1MjYzMzI0IiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjUyNjMzMjQvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY1MjYzMzI0L2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC4xLjAtYWxwaGEuMTAiLCJpZCI6MTY1MjYzMzI0LCJhdXRob3IiOnsibG9naW4iOiJ6YWNoZ29sbCIsImlkIjoxNjY3NjE1Nywibm9kZV9pZCI6Ik1EUTZWWE5sY2pFMk5qYzJNVFUzIiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xNjY3NjE1Nz92PTQiLCJncmF2YXRhcl9pZCI6IiIsInVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwiLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS96YWNoZ29sbCIsImZvbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2VycyIsImZvbGxvd2luZ191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2dpc3Rzey9naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdGFycmVkey9vd25lcn17L3JlcG99Iiwic3Vic2NyaXB0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N1YnNjcmlwdGlvbnMiLCJvcmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVwb3MiLCJldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRfZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJ1c2VyX3ZpZXdfdHlwZSI6InB1YmxpYyIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00SjJiZmMiLCJ0YWdfbmFtZSI6InYwLjEuMC1hbHBoYS4xMCIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjEuMC1hbHBoYS4xMCIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMDctMTJUMjI6Mzg6MTdaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0wNy0xMlQyMjozOToxMVoiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjEuMC1hbHBoYS4xMCIsInppcGJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3ppcGJhbGwvdjAuMS4wLWFscGhhLjEwIiwiYm9keSI6IiMjIFdoYXQncyBDaGFuZ2VkXHJcbiogQWRkIGVycm9yIGhhbmRsaW5nIGZvciBBY2NvdW50c0NvbnRyb2xsZXIjY3JlYXRlIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85NTdcclxuKiBmaXg6ICM5NTEgcG9pbnRlciBjdXJzb3IgYW5kIGJnIGhvdmVyIGZvciBpbXBvcnQgZmxvdyBidXR0b25zIGJ5IEBNYWdudXNISmVuc2VuIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTU0XHJcbiogSGFuZGxlIG1pc3NpbmcgZXhjaGFuZ2UgcmF0ZSBwcm92aWRlciwgYWxsb3cgZmFsbGJhY2sgZm9yIG1pc3NpbmcgcmF0ZXMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTU1XHJcbiogQWRkIG1pc3NpbmcgbWlncmF0aW9ucyBmb3IgZ29vZF9qb2IgNHggYnkgQHRvbnl2aW5jZSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk2N1xyXG4qIEFjY291bnQ6OlN5bmMgbW9kZWwgYW5kIHRlc3QgZml4dHVyZSBzaW1wbGlmaWNhdGlvbnMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTY4XHJcbiogRGVtbyBGYW1pbHkgZGF0YSB1cGRhdGVzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk3MlxyXG4qIE1ha2UgYmFsYW5jZSBlZGl0aW5nIGVhc2llciBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85NzZcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBATWFnbnVzSEplbnNlbiBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk1NFxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjEuMC1hbHBoYS45Li4udjAuMS4wLWFscGhhLjEwIiwicmVhY3Rpb25zIjp7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjUyNjMzMjQvcmVhY3Rpb25zIiwidG90YWxfY291bnQiOjcsIisxIjo3LCItMSI6MCwibGF1Z2giOjAsImhvb3JheSI6MCwiY29uZnVzZWQiOjAsImhlYXJ0IjowLCJyb2NrZXQiOjAsImV5ZXMiOjB9LCJtZW50aW9uc19jb3VudCI6M30seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY0MTk4ODQ4IiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjQxOTg4NDgvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTY0MTk4ODQ4L2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC4xLjAtYWxwaGEuOSIsImlkIjoxNjQxOTg4NDgsImF1dGhvciI6eyJsb2dpbiI6InphY2hnb2xsIiwiaWQiOjE2Njc2MTU3LCJub2RlX2lkIjoiTURRNlZYTmxjakUyTmpjMk1UVTMiLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE2Njc2MTU3P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbCIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL3phY2hnb2xsIiwiZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93aW5ney9vdGhlcl91c2VyfSIsImdpc3RzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZ2lzdHN7L2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N0YXJyZWR7L293bmVyfXsvcmVwb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3Vic2NyaXB0aW9ucyIsIm9yZ2FuaXphdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZXBvcyIsImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2V2ZW50c3svcHJpdmFjeX0iLCJyZWNlaXZlZF9ldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIsInVzZXJfdmlld190eXBlIjoicHVibGljIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRKeVhuQSIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjkiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC4xLjAtYWxwaGEuOSIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMDctMDVUMTg6MTY6NDFaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0wNy0wNVQxODoxNzoyMloiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjEuMC1hbHBoYS45IiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuOSIsImJvZHkiOiIjIyBXaGF0J3MgQ2hhbmdlZFxyXG4qIEFjY291bnQ6OkVudHJ5IERlbGVnYXRlZCBUeXBlIChuYW1lc3BhY2UgdXBkYXRlcyBwYXJ0IDcpIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzkyM1xyXG4qIEZpeCBkZWNpbWFsIGRpc3BsYXkgZm9yIGV1cm8gY3VycmVuY3kgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTM3XHJcbiogRW5hYmxlIHVwZGF0aW5nIEFjY291bnQ6OkVudHJ5I2Ftb3VudCBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTQyXHJcbiogRml4IGJ1ZyB3aGVyZSB0cmFuc2FjdGlvbnMgd2VyZSBkdXBsaWNhdGVkIGluIGltcG9ydCBjb25maXJtIGJ5IEBsamh1cnN0IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTQxXHJcbiogRW5xdWUgYWNjb3VudCBzeW5jIGpvYiBhZnRlciBjcmVhdGluZyB0cmFuc2ZlciBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTQ2XHJcbiogRW5hYmxlIHN5bmNpbmcgYWxsIGFjY291bnRzIGluIG9uZSBjbGljayBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTQ4XHJcbiogVXBkYXRlIGRvY2tlciBjb21wb3NlIGV4YW1wbGUgd2l0aCBmaXhlZCBzdG9yYWdlIHZvbHVtZSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85NTBcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBAbGpodXJzdCBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzk0MVxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjEuMC1hbHBoYS44Li4udjAuMS4wLWFscGhhLjkiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2NDE5ODg0OC9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6OSwiKzEiOjksIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5IjowLCJjb25mdXNlZCI6MCwiaGVhcnQiOjAsInJvY2tldCI6MCwiZXllcyI6MH0sIm1lbnRpb25zX2NvdW50IjozfSx7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjMwNjU4NDQiLCJhc3NldHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2MzA2NTg0NC9hc3NldHMiLCJ1cGxvYWRfdXJsIjoiaHR0cHM6Ly91cGxvYWRzLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjMwNjU4NDQvYXNzZXRzez9uYW1lLGxhYmVsfSIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvdGFnL3YwLjEuMC1hbHBoYS44IiwiaWQiOjE2MzA2NTg0NCwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwidXNlcl92aWV3X3R5cGUiOiJwdWJsaWMiLCJzaXRlX2FkbWluIjpmYWxzZX0sIm5vZGVfaWQiOiJSRV9rd0RPS190eEhNNEp1Q18wIiwidGFnX25hbWUiOiJ2MC4xLjAtYWxwaGEuOCIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjEuMC1hbHBoYS44IiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wNi0yOFQyMToyMjoyNloiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA2LTI4VDIxOjIzOjMzWiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjgiLCJ6aXBiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS96aXBiYWxsL3YwLjEuMC1hbHBoYS44IiwiYm9keSI6IiMjIFdoYXQncyBDaGFuZ2VkXHJcbiogVW5pZnkgcHJpbWFyeSBidXR0b24gc3R5bGVzIGFuZCBjaGFuZ2UgY3Vyc29yIG9uIGFjY291bnQgZ3JvdXAgYnkgQGpha3Via290dG5hdWVyIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTA1XHJcbiogRml4IGlzc3VlICM4NjE6IENvcnJlY3QgaGVhZGVyIHNlbGVjdGlvbiBsb2dpYyBpbiBnZXRfc2VsZWN0ZWRfaGVhZGVyX2Zvcl9maWVsZCBtZXRob2QgYnkgQGlnb3JjYXJ2YWxoaCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzkxOFxyXG4qIEZpeCAjOTEwIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85MTdcclxuKiBBY2NvdW50IG5hbWVzcGFjZSB1cGRhdGVzOiBwYXJ0IDYgKHRyYW5zYWN0aW9ucykgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTA0XHJcbiogaW1wcm92ZW1lbnQvIzg5MC9jbGVhbl91cF90b2FzdF9ub3RpZmljYXRpb25fc3R5bGVzX2FuZF9hbGxvd191c2VyX3RvX2Nsb3NlX29uLWRlbWFuZCBieSBAZXZhbmdlbG9zLWNvbSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzkxOVxyXG4qIEZpeCAjOTIxIGJ5IEB0b255dmluY2UgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85MjJcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBAaWdvcmNhcnZhbGhoIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTE4XHJcbiogQGV2YW5nZWxvcy1jb20gbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85MTlcclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4xLjAtYWxwaGEuNy4uLnYwLjEuMC1hbHBoYS44IiwicmVhY3Rpb25zIjp7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjMwNjU4NDQvcmVhY3Rpb25zIiwidG90YWxfY291bnQiOjEyLCIrMSI6OSwiLTEiOjAsImxhdWdoIjowLCJob29yYXkiOjAsImNvbmZ1c2VkIjowLCJoZWFydCI6MSwicm9ja2V0IjoyLCJleWVzIjowfSwibWVudGlvbnNfY291bnQiOjV9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2MTc0MjEzMCIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTYxNzQyMTMwL2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2MTc0MjEzMC9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wLWFscGhhLjciLCJpZCI6MTYxNzQyMTMwLCJhdXRob3IiOnsibG9naW4iOiJ6YWNoZ29sbCIsImlkIjoxNjY3NjE1Nywibm9kZV9pZCI6Ik1EUTZWWE5sY2pFMk5qYzJNVFUzIiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xNjY3NjE1Nz92PTQiLCJncmF2YXRhcl9pZCI6IiIsInVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwiLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS96YWNoZ29sbCIsImZvbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2VycyIsImZvbGxvd2luZ191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2dpc3Rzey9naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdGFycmVkey9vd25lcn17L3JlcG99Iiwic3Vic2NyaXB0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N1YnNjcmlwdGlvbnMiLCJvcmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVwb3MiLCJldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRfZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJ1c2VyX3ZpZXdfdHlwZSI6InB1YmxpYyIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00Sm9fMHkiLCJ0YWdfbmFtZSI6InYwLjEuMC1hbHBoYS43IiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjciLCJkcmFmdCI6ZmFsc2UsInByZXJlbGVhc2UiOnRydWUsImNyZWF0ZWRfYXQiOiIyMDI0LTA2LTIxVDIxOjA0OjU5WiIsInB1Ymxpc2hlZF9hdCI6IjIwMjQtMDYtMjFUMjE6MDg6MDRaIiwiYXNzZXRzIjpbXSwidGFyYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvdGFyYmFsbC92MC4xLjAtYWxwaGEuNyIsInppcGJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3ppcGJhbGwvdjAuMS4wLWFscGhhLjciLCJib2R5IjoiIyMgV2hhdCdzIENoYW5nZWRcclxuXHJcbk1heWJlIG5vdyBzdXBwb3J0cyB0cmFuc2ZlciBtYXRjaGluZyDwn6WzIPCfjK4gXHJcblxyXG5UaGlzIHNpZ25pZmljYW50bHkgaW1wcm92ZXMgaW5jb21lIGFuZCBleHBlbnNlIGNhbGN1bGF0aW9ucyBieSBleGNsdWRpbmcgdHJhbnNmZXJzIGZyb20gdGhlIGNhbGN1bGF0aW9uOlxyXG5cclxuaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvYXNzZXRzLzE2Njc2MTU3L2ExY2RlNDk0LWI4OWYtNDY2YS04Yzc0LWRhMTk0OTM0Mjg4YVxyXG5cclxuKiBBZGQgbWVyY2hhbnQgc2VsZWN0IHdoZW4gZWRpdGluZyB0cmFuc2FjdGlvbiBieSBAamFrdWJrb3R0bmF1ZXIgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84ODVcclxuKiBUcmFuc2FjdGlvbiB0cmFuc2ZlcnMsIHBheW1lbnRzLCBhbmQgbWF0Y2hpbmcgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODgzXHJcbiogRW5zdXJlIGNvcnJlY3QgZm9ybSdzIGhpZGRlbiBpbnB1dCBmb3Igc2VsZWN0ZWRJZHMgYnkgQHppcmFxeW91bmcgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84OTFcclxuKiBBY2NvdW50IG5hbWVzcGFjZSB1cGRhdGVzOiBwYXJ0IDEgKGFjY291bnRhYmxlIHR5cGVzKSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84OTNcclxuKiBBY2NvdW50IG5hbWVzcGFjZSB1cGRhdGVzOiBwYXJ0IDIgKGNhdGVnb3JpZXMpIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg5NFxyXG4qIEFjY291bnQgbmFtZXNwYWNlIHVwZGF0ZXM6IHBhcnQgMyAobWVyY2hhbnRzKSBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84OTVcclxuKiBBY2NvdW50IG5hbWVzcGFjZSB1cGRhdGVzOiBwYXJ0IDQgKHRyYW5zZmVycywgc2luZ3VsYXIgbmFtZXNwYWNpbmcpIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg5NlxyXG4qIEZpeCBCdWc6IGFmdGVyIGVkaXRpbmcgYW4gYWNjb3VudCBoaXN0b3J5IHZhbHVlLCBpdCByZXF1aXJlcyAyIGNsaWNrcyB0byBjbG9zZSB0aGUgbWVudSBieSBAdG9ueXZpbmNlIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTAwXHJcbiogZmVhdDogVHJhbnNhY3Rpb24gcGFnaW5hdGlvbiBJbXByb3ZlbWVudHMgYnkgQGthcmFua2lyaSBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg3M1xyXG4qIEZpeCB0cmFuc2ZlciBub3RlIG92ZXJmbG93IHN0eWxlIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzkwMlxyXG4qIEFjY291bnQgbmFtZXNwYWNlIHVwZGF0ZXM6IHBhcnQgNSAodmFsdWF0aW9ucykgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvOTAxXHJcbiogQWxsb3cgdHJhbnNmZXJzIGJhc2VkIG9uIHRyYW5zYWN0aW9ucyBpbiBkaWZmZXJlbnQgY3VycmVuY2llcyBieSBAamFrdWJrb3R0bmF1ZXIgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85MDNcclxuXHJcbiMjIE5ldyBDb250cmlidXRvcnNcclxuKiBAemlyYXF5b3VuZyBtYWRlIHRoZWlyIGZpcnN0IGNvbnRyaWJ1dGlvbiBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg5MVxyXG4qIEB0b255dmluY2UgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC85MDBcclxuKiBAa2FyYW5raXJpIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODczXHJcblxyXG4qKkZ1bGwgQ2hhbmdlbG9nKio6IGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL2NvbXBhcmUvdjAuMS4wLWFscGhhLjYuLi52MC4xLjAtYWxwaGEuNyIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTYxNzQyMTMwL3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjoyNiwiKzEiOjAsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5Ijo5LCJjb25mdXNlZCI6MCwiaGVhcnQiOjcsInJvY2tldCI6OSwiZXllcyI6MX0sIm1lbnRpb25zX2NvdW50Ijo1fSx7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjA1NjY0NzEiLCJhc3NldHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE2MDU2NjQ3MS9hc3NldHMiLCJ1cGxvYWRfdXJsIjoiaHR0cHM6Ly91cGxvYWRzLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNjA1NjY0NzEvYXNzZXRzez9uYW1lLGxhYmVsfSIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvdGFnL3YwLjEuMC1hbHBoYS42IiwiaWQiOjE2MDU2NjQ3MSwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwidXNlcl92aWV3X3R5cGUiOiJwdWJsaWMiLCJzaXRlX2FkbWluIjpmYWxzZX0sIm5vZGVfaWQiOiJSRV9rd0RPS190eEhNNEprZ3pIIiwidGFnX25hbWUiOiJ2MC4xLjAtYWxwaGEuNiIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjEuMC1hbHBoYS42IiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wNi0xNFQyMDo1MDowOFoiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA2LTE0VDIxOjAxOjA0WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjYiLCJ6aXBiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS96aXBiYWxsL3YwLjEuMC1hbHBoYS42IiwiYm9keSI6IiMjIFdoYXQncyBDaGFuZ2VkXHJcblxyXG5UaGlzIHJlbGVhc2UgY29tZXMgd2l0aCBhIGNvbXBsZXRlIG92ZXJoYXVsIG9mIG91ciBEb2NrZXIgc2V0dXAgZ3VpZGUsIGJldHRlciBpbnRlcm5hbCBuYXZpZ2F0aW9uLCBhbmQgdGhlIGFiaWxpdHkgdG8gZ3JvdXAgeW91ciBhY2NvdW50cyBieSBmaW5hbmNpYWwgaW5zdGl0dXRpb24hXHJcblxyXG5odHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9hc3NldHMvMTY2NzYxNTcvYmU0ZDFjZTgtNzA1NS00ZjcxLTljM2YtYzliYmM5Y2I0NTFjXHJcblxyXG4qIEltcHJvdmUgYWNjb3VudCBpbnRlcm5hbCBsaW5raW5nIGFuZCByZWRpcmVjdCBiZWhhdmlvciBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84NjRcclxuKiBBbGxvdyBmb3Igb3B0aW9uYWwgc3RhcnQgZGF0ZSBvbiBhY2NvdW50IGNyZWF0aW9uIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg2NlxyXG4qIEFkZCBpbnN0aXR1dGlvbiBtYW5hZ2VtZW50IGFuZCBhY2NvdW50IGVkaXRpbmcgY29udHJvbHMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODY4XHJcbiogTmV3IERvY2tlciBDb21wb3NlIFNlbGYgSG9zdGluZyBHdWlkZSArIFVJIEZpeGVzIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg3MFxyXG4qIFNpbXBsaWZ5IGFjY291bnQgc3luYyBsb2dpYyBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84NzFcclxuKiBDaGFuZ2Vsb2cgcGFnZSB0aGF0IHB1bGxzIGZyb20gR2l0aHViIFJlbGVhc2Ugbm90ZXMgYnkgQG1hdHRpYS1tYWxuaXMgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84NjdcclxuXHJcblxyXG4qKkZ1bGwgQ2hhbmdlbG9nKio6IGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL2NvbXBhcmUvdjAuMS4wLWFscGhhLjUuLi52MC4xLjAtYWxwaGEuNiIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTYwNTY2NDcxL3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjozNiwiKzEiOjI3LCItMSI6MCwibGF1Z2giOjAsImhvb3JheSI6MCwiY29uZnVzZWQiOjAsImhlYXJ0Ijo5LCJyb2NrZXQiOjAsImV5ZXMiOjB9LCJtZW50aW9uc19jb3VudCI6Mn0seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU5NDUyMjQ0IiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTk0NTIyNDQvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU5NDUyMjQ0L2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC4xLjAtYWxwaGEuNSIsImlkIjoxNTk0NTIyNDQsImF1dGhvciI6eyJsb2dpbiI6InphY2hnb2xsIiwiaWQiOjE2Njc2MTU3LCJub2RlX2lkIjoiTURRNlZYTmxjakUyTmpjMk1UVTMiLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE2Njc2MTU3P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbCIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL3phY2hnb2xsIiwiZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93aW5ney9vdGhlcl91c2VyfSIsImdpc3RzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZ2lzdHN7L2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N0YXJyZWR7L293bmVyfXsvcmVwb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3Vic2NyaXB0aW9ucyIsIm9yZ2FuaXphdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZXBvcyIsImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2V2ZW50c3svcHJpdmFjeX0iLCJyZWNlaXZlZF9ldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIsInVzZXJfdmlld190eXBlIjoicHVibGljIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRKZ1F4VSIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjUiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC4xLjAtYWxwaGEuNSIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMDYtMDdUMjM6Mjk6MDFaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0wNi0wN1QyMzozNDoxMVoiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjEuMC1hbHBoYS41IiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuNSIsImJvZHkiOiIjIyBXaGF0J3MgQ2hhbmdlZFxyXG5cclxuQnVsayB0cmFuc2FjdGlvbiBlZGl0aW5nIGFuZCBkZWxldGlvbiBpcyBoZXJlISAgQ2hlY2sgb3V0IHRoZSBkZW1vIGJlbG93OlxyXG5cclxuaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvYXNzZXRzLzE2Njc2MTU3L2Q4YTNhZTM5LTQ5MzEtNDc3NS04Yzc2LTIxMWQ2ZGM5NmJmNlxyXG5cclxuKiBBZGQgYnVsayBzZWxlY3Rpb24gVUkgY29udHJvbHMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODQwXHJcbiogQnVsayB0cmFuc2FjdGlvbiBkZWxldGlvbiBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84NDVcclxuKiBCdWxrIGVkaXRpbmcgb2YgdHJhbnNhY3Rpb25zIGJ5IEB6YWNoZ29sbCBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzg0NlxyXG5cclxuKipGdWxsIENoYW5nZWxvZyoqOiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9jb21wYXJlL3YwLjEuMC1hbHBoYS40Li4udjAuMS4wLWFscGhhLjUiLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE1OTQ1MjI0NC9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6NDgsIisxIjozNSwiLTEiOjAsImxhdWdoIjowLCJob29yYXkiOjYsImNvbmZ1c2VkIjowLCJoZWFydCI6Nywicm9ja2V0IjowLCJleWVzIjowfSwibWVudGlvbnNfY291bnQiOjF9LHsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE1ODM5MDY3MSIsImFzc2V0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU4MzkwNjcxL2Fzc2V0cyIsInVwbG9hZF91cmwiOiJodHRwczovL3VwbG9hZHMuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE1ODM5MDY3MS9hc3NldHN7P25hbWUsbGFiZWx9IiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy90YWcvdjAuMS4wLWFscGhhLjQiLCJpZCI6MTU4MzkwNjcxLCJhdXRob3IiOnsibG9naW4iOiJ6YWNoZ29sbCIsImlkIjoxNjY3NjE1Nywibm9kZV9pZCI6Ik1EUTZWWE5sY2pFMk5qYzJNVFUzIiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xNjY3NjE1Nz92PTQiLCJncmF2YXRhcl9pZCI6IiIsInVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwiLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS96YWNoZ29sbCIsImZvbGxvd2Vyc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2VycyIsImZvbGxvd2luZ191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn0iLCJnaXN0c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2dpc3Rzey9naXN0X2lkfSIsInN0YXJyZWRfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdGFycmVkey9vd25lcn17L3JlcG99Iiwic3Vic2NyaXB0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N1YnNjcmlwdGlvbnMiLCJvcmdhbml6YXRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvb3JncyIsInJlcG9zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVwb3MiLCJldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9ldmVudHN7L3ByaXZhY3l9IiwicmVjZWl2ZWRfZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvcmVjZWl2ZWRfZXZlbnRzIiwidHlwZSI6IlVzZXIiLCJ1c2VyX3ZpZXdfdHlwZSI6InB1YmxpYyIsInNpdGVfYWRtaW4iOmZhbHNlfSwibm9kZV9pZCI6IlJFX2t3RE9LX3R4SE00SmNObVAiLCJ0YWdfbmFtZSI6InYwLjEuMC1hbHBoYS40IiwidGFyZ2V0X2NvbW1pdGlzaCI6Im1haW4iLCJuYW1lIjoidjAuMS4wLWFscGhhLjQiLCJkcmFmdCI6ZmFsc2UsInByZXJlbGVhc2UiOnRydWUsImNyZWF0ZWRfYXQiOiIyMDI0LTA1LTMxVDE4OjA5OjEyWiIsInB1Ymxpc2hlZF9hdCI6IjIwMjQtMDUtMzFUMTg6MTA6MzFaIiwiYXNzZXRzIjpbXSwidGFyYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvdGFyYmFsbC92MC4xLjAtYWxwaGEuNCIsInppcGJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3ppcGJhbGwvdjAuMS4wLWFscGhhLjQiLCJib2R5IjoiIyMgV2hhdCdzIENoYW5nZWRcclxuKiBmaXg6IHBuZyBmaWxlIGNhbiBiZSBzZWxlY3RlZCBhcyBwcm9maWxlIGltYWdlcyBieSBAcGVhLXN5cyBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzgwOVxyXG4qIFZhbGlkYXRlIHRyYW5zYWN0aW9uIGZpbHRlcmluZyBwYXJhbXMgYnkgQGpha3Via290dG5hdWVyIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODEwXHJcbiogRml4IGZvcmVpZ24gYWNjb3VudCBzeW5jIGNyYXNoIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzc5NFxyXG4qIFNvcnQgYWNjb3VudHMgaW4gdGhlIHNpZGViYXIgYnkgQGpha3Via290dG5hdWVyIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODE1XHJcbiogUmV1c2FibGUgQ0kgd29ya2Zsb3cgZm9yIEdIIGFjdGlvbnMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODE5XHJcbiogVHJhbnNhY3Rpb25zIGNsZWFudXAgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODE3XHJcbiogU3luYyBhY2NvdW50IGFmdGVyIHRyYW5zYWN0aW9uIGltcG9ydCBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84MjBcclxuKiBGaXggb3ZlcmZsb3cgZXJyb3Igb24gYWNjb3VudCB2YWx1ZSBpbnB1dHMgYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvODIxXHJcblxyXG4jIyBOZXcgQ29udHJpYnV0b3JzXHJcbiogQHBlYS1zeXMgbWFkZSB0aGVpciBmaXJzdCBjb250cmlidXRpb24gaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC84MDlcclxuXHJcbioqRnVsbCBDaGFuZ2Vsb2cqKjogaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvY29tcGFyZS92MC4xLjAtYWxwaGEuMy4uLnYwLjEuMC1hbHBoYS40IiwicmVhY3Rpb25zIjp7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTgzOTA2NzEvcmVhY3Rpb25zIiwidG90YWxfY291bnQiOjIxLCIrMSI6MCwiLTEiOjAsImxhdWdoIjowLCJob29yYXkiOjIxLCJjb25mdXNlZCI6MCwiaGVhcnQiOjAsInJvY2tldCI6MCwiZXllcyI6MH0sIm1lbnRpb25zX2NvdW50IjozfSx7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTczOTcwOTEiLCJhc3NldHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE1NzM5NzA5MS9hc3NldHMiLCJ1cGxvYWRfdXJsIjoiaHR0cHM6Ly91cGxvYWRzLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTczOTcwOTEvYXNzZXRzez9uYW1lLGxhYmVsfSIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvdGFnL3YwLjEuMC1hbHBoYS4zIiwiaWQiOjE1NzM5NzA5MSwiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwidXNlcl92aWV3X3R5cGUiOiJwdWJsaWMiLCJzaXRlX2FkbWluIjpmYWxzZX0sIm5vZGVfaWQiOiJSRV9rd0RPS190eEhNNEpZYkJqIiwidGFnX25hbWUiOiJ2MC4xLjAtYWxwaGEuMyIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjEuMC1hbHBoYS4zIiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wNS0yNFQxODoyNDowM1oiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA1LTI0VDE4OjI1OjM3WiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjMiLCJ6aXBiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS96aXBiYWxsL3YwLjEuMC1hbHBoYS4zIiwiYm9keSI6IiMjIFdoYXQncyBDaGFuZ2VkXHJcbiogRml4IGN1cnJlbmN5IHdoZW4gaW1wb3J0aW5nIHRvIGZvcmVpZ24gYWNjb3VudHMgYnkgQGpha3Via290dG5hdWVyIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvNzYyXHJcbiogU2hvdyBhbiBlcnJvciBub3RpZmljYXRpb24gaWYgYWNjb3VudCBjYW5ub3QgYmUgbWFudWFsbHkgc3luY2VkIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzc2MVxyXG4qIEFkZCBtaWdyYXRpb24gdG8gbWFrZSBhbGwgZXhpc3RpbmcgdXNlcnMgYWRtaW5zIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzc3MFxyXG4qIEZpeCBpc3N1ZSB3aXRoIHN0YXJ0X2RhdGUgbm90IGJlaW5nIHNldCBpbiBhY2NvdW50IGNyZWF0aW9uIGJ5IEBzY3ViYW1hZ2dvIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvNzgxXHJcbiogRml4IGltcG9ydCBjcmFzaCB3aXRoIGVtcHR5IHRyYW5zYWN0aW9uIG5hbWUgYnkgQGpha3Via290dG5hdWVyIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvNzgzXHJcbiogTW92ZSBjYXRlZ29yeSBkcm9wZG93biBtZW51IGNvbnRlbnQgaW50byBhIHR1cmJvIGZyYW1lIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzc4MlxyXG4qIElnbm9yZSBlbXB0eSBjYXRlZ29yaWVzIHdoaWxlIGltcG9ydGluZyBieSBAamFrdWJrb3R0bmF1ZXIgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC83ODlcclxuKiBGaXggZHVwbGljYXRlIGNhdGVnb3J5IGNyZWF0aW9uIG9uIGltcG9ydCBieSBAemFjaGdvbGwgaW4gaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcHVsbC83OTFcclxuKiBDcmVhdGUgdGFnZ2luZyBzeXN0ZW0gYnkgQHphY2hnb2xsIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvNzkyXHJcbiogQWRkIHRhZyBwcmV2aWV3IHdoZW4gaW1wb3J0aW5nIGFuZCBmaXggZW1wdHkgY2F0ZWdvcnkgYnVnIGJ5IEBqYWt1YmtvdHRuYXVlciBpbiBodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9wdWxsLzgwMFxyXG5cclxuIyMgTmV3IENvbnRyaWJ1dG9yc1xyXG4qIEBzY3ViYW1hZ2dvIG1hZGUgdGhlaXIgZmlyc3QgY29udHJpYnV0aW9uIGluIGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3B1bGwvNzgxXHJcblxyXG4qKkZ1bGwgQ2hhbmdlbG9nKio6IGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL2NvbXBhcmUvdjAuMS4wLWFscGhhLjIuLi52MC4xLjAtYWxwaGEuMyIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU3Mzk3MDkxL3JlYWN0aW9ucyIsInRvdGFsX2NvdW50IjozMSwiKzEiOjAsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5IjowLCJjb25mdXNlZCI6MCwiaGVhcnQiOjUsInJvY2tldCI6MjYsImV5ZXMiOjB9LCJtZW50aW9uc19jb3VudCI6M30seyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU2MzQyMTUyIiwiYXNzZXRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTYzNDIxNTIvYXNzZXRzIiwidXBsb2FkX3VybCI6Imh0dHBzOi8vdXBsb2Fkcy5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU2MzQyMTUyL2Fzc2V0c3s/bmFtZSxsYWJlbH0iLCJodG1sX3VybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzL3RhZy92MC4xLjAtYWxwaGEuMiIsImlkIjoxNTYzNDIxNTIsImF1dGhvciI6eyJsb2dpbiI6InphY2hnb2xsIiwiaWQiOjE2Njc2MTU3LCJub2RlX2lkIjoiTURRNlZYTmxjakUyTmpjMk1UVTMiLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzE2Njc2MTU3P3Y9NCIsImdyYXZhdGFyX2lkIjoiIiwidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbCIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL3phY2hnb2xsIiwiZm9sbG93ZXJzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93ZXJzIiwiZm9sbG93aW5nX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZm9sbG93aW5ney9vdGhlcl91c2VyfSIsImdpc3RzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZ2lzdHN7L2dpc3RfaWR9Iiwic3RhcnJlZF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3N0YXJyZWR7L293bmVyfXsvcmVwb30iLCJzdWJzY3JpcHRpb25zX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3Vic2NyaXB0aW9ucyIsIm9yZ2FuaXphdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9vcmdzIiwicmVwb3NfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZXBvcyIsImV2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL2V2ZW50c3svcHJpdmFjeX0iLCJyZWNlaXZlZF9ldmVudHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9yZWNlaXZlZF9ldmVudHMiLCJ0eXBlIjoiVXNlciIsInVzZXJfdmlld190eXBlIjoicHVibGljIiwic2l0ZV9hZG1pbiI6ZmFsc2V9LCJub2RlX2lkIjoiUkVfa3dET0tfdHhITTRKVVplSSIsInRhZ19uYW1lIjoidjAuMS4wLWFscGhhLjIiLCJ0YXJnZXRfY29tbWl0aXNoIjoibWFpbiIsIm5hbWUiOiJ2MC4xLjAtYWxwaGEuMiIsImRyYWZ0IjpmYWxzZSwicHJlcmVsZWFzZSI6dHJ1ZSwiY3JlYXRlZF9hdCI6IjIwMjQtMDUtMTdUMjI6MjA6MTlaIiwicHVibGlzaGVkX2F0IjoiMjAyNC0wNS0xN1QyMjozMzoyNFoiLCJhc3NldHMiOltdLCJ0YXJiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS90YXJiYWxsL3YwLjEuMC1hbHBoYS4yIiwiemlwYmFsbF91cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvemlwYmFsbC92MC4xLjAtYWxwaGEuMiIsImJvZHkiOiJTZWNvbmQgYWxwaGEgcmVsZWFzZSBvZiBNYXliZSDwn46JIPCfjK5cclxuXHJcbiMjIE5ldyBmZWF0dXJlc1xyXG5cclxuLSBTZWxmIGhvc3Rpbmcgd2l0aCBEb2NrZXIg8J+QsyAoW3NldHVwIGd1aWRlXShodHRwczovL2dpdGh1Yi5jb20vbWF5YmUtZmluYW5jZS9tYXliZS9ibG9iL21haW4vZG9jcy9ob3N0aW5nL2RvY2tlci5tZCkpXHJcbi0gQ1NWIHRyYW5zYWN0aW9uIGltcG9ydHNcclxuLSBUcmFuc2FjdGlvbiBtYW5hZ2VtZW50IHdpdGggbWVyY2hhbnRzIGFuZCBjYXRlZ29yaWVzXHJcbi0gRnJlc2ggZGVzaWduIG9mIHVzZXIgc2V0dGluZ3NcclxuLSBSZS1kZXNpZ25lZCBkYXNoYm9hcmQgKyBhY2NvdW50cyBzdW1tYXJ5XHJcbi0gQWRtaW4gYWNjb3VudHMsIGFiaWxpdHkgdG8gZGVsZXRlIGFuZCBwdXJnZSBkYXRhXHJcblxyXG4qKkZ1bGwgQ2hhbmdlbG9nKio6IGh0dHBzOi8vZ2l0aHViLmNvbS9tYXliZS1maW5hbmNlL21heWJlL2NvbXBhcmUvdjAuMS4wLWFscGhhLjEuLi52MC4xLjAtYWxwaGEuMiIsInJlYWN0aW9ucyI6eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvMTU2MzQyMTUyL3JlYWN0aW9ucyIsInRvdGFsX2NvdW50Ijo1MCwiKzEiOjAsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5Ijo0OCwiY29uZnVzZWQiOjAsImhlYXJ0IjoyLCJyb2NrZXQiOjAsImV5ZXMiOjB9fSx7InVybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTEwMjgxMjMiLCJhc3NldHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE1MTAyODEyMy9hc3NldHMiLCJ1cGxvYWRfdXJsIjoiaHR0cHM6Ly91cGxvYWRzLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS9yZWxlYXNlcy8xNTEwMjgxMjMvYXNzZXRzez9uYW1lLGxhYmVsfSIsImh0bWxfdXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL21heWJlLWZpbmFuY2UvbWF5YmUvcmVsZWFzZXMvdGFnL3YwLjEuMC1hbHBoYS4xIiwiaWQiOjE1MTAyODEyMywiYXV0aG9yIjp7ImxvZ2luIjoiemFjaGdvbGwiLCJpZCI6MTY2NzYxNTcsIm5vZGVfaWQiOiJNRFE2VlhObGNqRTJOamMyTVRVMyIsImF2YXRhcl91cmwiOiJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvMTY2NzYxNTc/dj00IiwiZ3JhdmF0YXJfaWQiOiIiLCJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsIiwiaHRtbF91cmwiOiJodHRwczovL2dpdGh1Yi5jb20vemFjaGdvbGwiLCJmb2xsb3dlcnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dlcnMiLCJmb2xsb3dpbmdfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9mb2xsb3dpbmd7L290aGVyX3VzZXJ9IiwiZ2lzdHNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9naXN0c3svZ2lzdF9pZH0iLCJzdGFycmVkX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfSIsInN1YnNjcmlwdGlvbnNfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy96YWNoZ29sbC9zdWJzY3JpcHRpb25zIiwib3JnYW5pemF0aW9uc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL29yZ3MiLCJyZXBvc191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlcG9zIiwiZXZlbnRzX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvemFjaGdvbGwvZXZlbnRzey9wcml2YWN5fSIsInJlY2VpdmVkX2V2ZW50c191cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL3phY2hnb2xsL3JlY2VpdmVkX2V2ZW50cyIsInR5cGUiOiJVc2VyIiwidXNlcl92aWV3X3R5cGUiOiJwdWJsaWMiLCJzaXRlX2FkbWluIjpmYWxzZX0sIm5vZGVfaWQiOiJSRV9rd0RPS190eEhNNEpBSUdiIiwidGFnX25hbWUiOiJ2MC4xLjAtYWxwaGEuMSIsInRhcmdldF9jb21taXRpc2giOiJtYWluIiwibmFtZSI6InYwLjEuMC1hbHBoYS4xIiwiZHJhZnQiOmZhbHNlLCJwcmVyZWxlYXNlIjp0cnVlLCJjcmVhdGVkX2F0IjoiMjAyNC0wNC0xM1QxMzoyODo0NVoiLCJwdWJsaXNoZWRfYXQiOiIyMDI0LTA0LTEzVDEzOjQ5OjAzWiIsImFzc2V0cyI6W10sInRhcmJhbGxfdXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3RhcmJhbGwvdjAuMS4wLWFscGhhLjEiLCJ6aXBiYWxsX3VybCI6Imh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvbWF5YmUtZmluYW5jZS9tYXliZS96aXBiYWxsL3YwLjEuMC1hbHBoYS4xIiwiYm9keSI6IkluaXRpYWwgYWxwaGEgdmVyc2lvbiBvZiBNYXliZSDwn46JIPCfjK4iLCJyZWFjdGlvbnMiOnsidXJsIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9tYXliZS1maW5hbmNlL21heWJlL3JlbGVhc2VzLzE1MTAyODEyMy9yZWFjdGlvbnMiLCJ0b3RhbF9jb3VudCI6MTY1LCIrMSI6NTAsIi0xIjowLCJsYXVnaCI6MCwiaG9vcmF5Ijo0NiwiY29uZnVzZWQiOjAsImhlYXJ0IjozNSwicm9ja2V0IjoyMywiZXllcyI6MTF9fV0=\n  recorded_at: Wed, 19 Mar 2025 12:40:58 GMT\n- request:\n    method: post\n    uri: https://api.github.com/markdown\n    body:\n      encoding: UTF-8\n      string: '{\"mode\":\"gfm\",\"context\":\"maybe-finance/maybe\",\"text\":\"## Data resets,\n        offline investment trades, and miscellaneous stability improvements\\r\\n\\r\\nThis\n        release comes with a wide mix of stability improvements and quality of life\n        updates; particularly for self hosted apps, which can now be \\\"reset\\\" in\n        user settings.  If your data looks wrong or you want a \\\"clean slate\\\" to\n        work from, we''ve added the ability for you to easily perform these resets\n        without writing SQL or manually deleting records.\\r\\n\\r\\nThis release also\n        comes with a much clearer UI surrounding the Synth data provider.  New self\n        hosted users will now see a prominent warning message if they have missing\n        data as a result of a misconfigured or absent data provider.\\r\\n\\r\\n## What''s\n        Changed\\r\\n* Add new category flow by @syedbarimanjan in https://github.com/maybe-finance/maybe/pull/1857\\r\\n*\n        Fix parent category sums in budget by @zachgoll in https://github.com/maybe-finance/maybe/pull/1894\\r\\n*\n        Add breadcrumbs support across application by @Shpigford in https://github.com/maybe-finance/maybe/pull/1897\\r\\n*\n        Dashboard design fixes by @zachgoll in https://github.com/maybe-finance/maybe/pull/1898\\r\\n*\n        Allow account balance to dynamically use currency format on preference page\n        by @Harry-kp in https://github.com/maybe-finance/maybe/pull/1910\\r\\n* Feat:\n        Data \\\"reset\\\" button by @tonyvince in https://github.com/maybe-finance/maybe/pull/1913\\r\\n*\n        Fix: Make Tags selection scrollable by @tonyvince in https://github.com/maybe-finance/maybe/pull/1921\\r\\n*\n        Fix value wrapping on account balance in sidebar by @zachgoll in https://github.com/maybe-finance/maybe/pull/1922\\r\\n*\n        Fix import configuration form so number format is applied by @zachgoll in\n        https://github.com/maybe-finance/maybe/pull/1923\\r\\n* Add transitions to buttons\n        and other common design system elements by @zachgoll in https://github.com/maybe-finance/maybe/pull/1924\\r\\n*\n        Allow offline trade tickers by @zachgoll in https://github.com/maybe-finance/maybe/pull/1925\\r\\n*\n        fix: Don''t show Billings on settings navbar when self-hosted by @tonyvince\n        in https://github.com/maybe-finance/maybe/pull/1912\\r\\n* Show UI warning to\n        user when they need provider data but have not setup Synth yet by @zachgoll\n        in https://github.com/maybe-finance/maybe/pull/1926\\r\\n* Invert liability\n        graphs to have correct signage by @zachgoll in https://github.com/maybe-finance/maybe/pull/1928\\r\\n*\n        Escape quotations in CSV imports properly by @zachgoll in https://github.com/maybe-finance/maybe/pull/1929\\r\\n\\r\\n##\n        New Contributors\\r\\n* @syedbarimanjan made their first contribution in https://github.com/maybe-finance/maybe/pull/1857\\r\\n\\r\\n**Full\n        Changelog**: https://github.com/maybe-finance/maybe/compare/v0.4.2...v0.4.3\"}'\n    headers:\n      Accept:\n      - application/vnd.github.raw\n      User-Agent:\n      - Octokit Ruby Gem 9.2.0\n      Content-Type:\n      - application/json\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Wed, 19 Mar 2025 12:40:58 GMT\n      Content-Type:\n      - text/html;charset=utf-8\n      X-Commonmarker-Version:\n      - 0.23.10\n      Cache-Control:\n      - public, max-age=60, s-maxage=60\n      Vary:\n      - Accept,Accept-Encoding, Accept, X-Requested-With\n      Etag:\n      - W/\"bff98cc65001c41f8dd63749dc92891772179ad935cf7a314a5e9a289fd17557\"\n      X-Github-Media-Type:\n      - github.v3; param=raw\n      X-Github-Api-Version-Selected:\n      - '2022-11-28'\n      Access-Control-Expose-Headers:\n      - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining,\n        X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes,\n        X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO,\n        X-GitHub-Request-Id, Deprecation, Sunset\n      Access-Control-Allow-Origin:\n      - \"*\"\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubdomains; preload\n      X-Frame-Options:\n      - deny\n      X-Content-Type-Options:\n      - nosniff\n      X-Xss-Protection:\n      - '0'\n      Referrer-Policy:\n      - origin-when-cross-origin, strict-origin-when-cross-origin\n      Content-Security-Policy:\n      - default-src 'none'\n      Server:\n      - github.com\n      X-Ratelimit-Limit:\n      - '60'\n      X-Ratelimit-Remaining:\n      - '58'\n      X-Ratelimit-Reset:\n      - '1742391658'\n      X-Ratelimit-Resource:\n      - core\n      X-Ratelimit-Used:\n      - '2'\n      Content-Length:\n      - '11567'\n      X-Github-Request-Id:\n      - DDEE:10ABF0:184826:30A90D:67DABB5A\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        <h2 dir=\"auto\">Data resets, offline investment trades, and miscellaneous stability improvements</h2>\n        <p dir=\"auto\">This release comes with a wide mix of stability improvements and quality of life updates; particularly for self hosted apps, which can now be \"reset\" in user settings.  If your data looks wrong or you want a \"clean slate\" to work from, we've added the ability for you to easily perform these resets without writing SQL or manually deleting records.</p>\n        <p dir=\"auto\">This release also comes with a much clearer UI surrounding the Synth data provider.  New self hosted users will now see a prominent warning message if they have missing data as a result of a misconfigured or absent data provider.</p>\n        <h2 dir=\"auto\">What's Changed</h2>\n        <ul dir=\"auto\">\n        <li>Add new category flow by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/syedbarimanjan/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/syedbarimanjan\">@syedbarimanjan</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2851271813\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1857\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1857/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1857\">#1857</a></li>\n        <li>Fix parent category sums in budget by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/zachgoll/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/zachgoll\">@zachgoll</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2875715226\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1894\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1894/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1894\">#1894</a></li>\n        <li>Add breadcrumbs support across application by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/Shpigford/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/Shpigford\">@Shpigford</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2876669162\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1897\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1897/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1897\">#1897</a></li>\n        <li>Dashboard design fixes by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/zachgoll/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/zachgoll\">@zachgoll</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2878684037\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1898\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1898/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1898\">#1898</a></li>\n        <li>Allow account balance to dynamically use currency format on preference page by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/Harry-kp/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/Harry-kp\">@Harry-kp</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2882416377\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1910\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1910/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1910\">#1910</a></li>\n        <li>Feat: Data \"reset\" button by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/tonyvince/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/tonyvince\">@tonyvince</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2884371634\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1913\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1913/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1913\">#1913</a></li>\n        <li>Fix: Make Tags selection scrollable by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/tonyvince/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/tonyvince\">@tonyvince</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2886838108\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1921\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1921/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1921\">#1921</a></li>\n        <li>Fix value wrapping on account balance in sidebar by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/zachgoll/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/zachgoll\">@zachgoll</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2887107646\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1922\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1922/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1922\">#1922</a></li>\n        <li>Fix import configuration form so number format is applied by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/zachgoll/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/zachgoll\">@zachgoll</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2887166618\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1923\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1923/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1923\">#1923</a></li>\n        <li>Add transitions to buttons and other common design system elements by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/zachgoll/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/zachgoll\">@zachgoll</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2887226476\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1924\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1924/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1924\">#1924</a></li>\n        <li>Allow offline trade tickers by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/zachgoll/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/zachgoll\">@zachgoll</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2887293141\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1925\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1925/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1925\">#1925</a></li>\n        <li>fix: Don't show Billings on settings navbar when self-hosted by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/tonyvince/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/tonyvince\">@tonyvince</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2883665500\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1912\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1912/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1912\">#1912</a></li>\n        <li>Show UI warning to user when they need provider data but have not setup Synth yet by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/zachgoll/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/zachgoll\">@zachgoll</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2887589859\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1926\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1926/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1926\">#1926</a></li>\n        <li>Invert liability graphs to have correct signage by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/zachgoll/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/zachgoll\">@zachgoll</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2887629144\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1928\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1928/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1928\">#1928</a></li>\n        <li>Escape quotations in CSV imports properly by <a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/zachgoll/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/zachgoll\">@zachgoll</a> in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2887688665\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1929\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1929/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1929\">#1929</a></li>\n        </ul>\n        <h2 dir=\"auto\">New Contributors</h2>\n        <ul dir=\"auto\">\n        <li><a class=\"user-mention notranslate\" data-hovercard-type=\"user\" data-hovercard-url=\"/users/syedbarimanjan/hovercard\" data-octo-click=\"hovercard-link-click\" data-octo-dimensions=\"link_type:self\" href=\"https://github.com/syedbarimanjan\">@syedbarimanjan</a> made their first contribution in <a class=\"issue-link js-issue-link\" data-error-text=\"Failed to load title\" data-id=\"2851271813\" data-permission-text=\"Title is private\" data-url=\"https://github.com/maybe-finance/maybe/issues/1857\" data-hovercard-type=\"pull_request\" data-hovercard-url=\"/maybe-finance/maybe/pull/1857/hovercard\" href=\"https://github.com/maybe-finance/maybe/pull/1857\">#1857</a></li>\n        </ul>\n        <p dir=\"auto\"><strong>Full Changelog</strong>: <a class=\"commit-link\" href=\"https://github.com/maybe-finance/maybe/compare/v0.4.2...v0.4.3\"><tt>v0.4.2...v0.4.3</tt></a></p>\n  recorded_at: Wed, 19 Mar 2025 12:40:58 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/openai/auto_categorize.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://api.openai.com/v1/responses\n    body:\n      encoding: UTF-8\n      string: '{\"model\":\"gpt-4.1-mini\",\"input\":[{\"role\":\"developer\",\"content\":\"Here\n        are the user''s available categories in JSON format:\\n\\n```json\\n[{\\\"id\\\":\\\"shopping_id\\\",\\\"name\\\":\\\"Shopping\\\",\\\"is_subcategory\\\":false,\\\"parent_id\\\":null,\\\"classification\\\":\\\"expense\\\"},{\\\"id\\\":\\\"subscriptions_id\\\",\\\"name\\\":\\\"Subscriptions\\\",\\\"is_subcategory\\\":true,\\\"parent_id\\\":null,\\\"classification\\\":\\\"expense\\\"},{\\\"id\\\":\\\"restaurants_id\\\",\\\"name\\\":\\\"Restaurants\\\",\\\"is_subcategory\\\":false,\\\"parent_id\\\":null,\\\"classification\\\":\\\"expense\\\"},{\\\"id\\\":\\\"fast_food_id\\\",\\\"name\\\":\\\"Fast\n        Food\\\",\\\"is_subcategory\\\":true,\\\"parent_id\\\":\\\"restaurants_id\\\",\\\"classification\\\":\\\"expense\\\"},{\\\"id\\\":\\\"income_id\\\",\\\"name\\\":\\\"Income\\\",\\\"is_subcategory\\\":false,\\\"parent_id\\\":null,\\\"classification\\\":\\\"income\\\"}]\\n```\\n\\nUse\n        the available categories to auto-categorize the following transactions:\\n\\n```json\\n[{\\\"id\\\":\\\"1\\\",\\\"name\\\":\\\"McDonalds\\\",\\\"amount\\\":20,\\\"classification\\\":\\\"expense\\\",\\\"merchant\\\":\\\"McDonalds\\\",\\\"hint\\\":\\\"Fast\n        Food\\\"},{\\\"id\\\":\\\"2\\\",\\\"name\\\":\\\"Amazon purchase\\\",\\\"amount\\\":100,\\\"classification\\\":\\\"expense\\\",\\\"merchant\\\":\\\"Amazon\\\"},{\\\"id\\\":\\\"3\\\",\\\"name\\\":\\\"Netflix\n        subscription\\\",\\\"amount\\\":10,\\\"classification\\\":\\\"expense\\\",\\\"merchant\\\":\\\"Netflix\\\",\\\"hint\\\":\\\"Subscriptions\\\"},{\\\"id\\\":\\\"4\\\",\\\"name\\\":\\\"paycheck\\\",\\\"amount\\\":3000,\\\"classification\\\":\\\"income\\\"},{\\\"id\\\":\\\"5\\\",\\\"name\\\":\\\"Italian\n        dinner with friends\\\",\\\"amount\\\":100,\\\"classification\\\":\\\"expense\\\"},{\\\"id\\\":\\\"6\\\",\\\"name\\\":\\\"1212XXXBCaaa\n        charge\\\",\\\"amount\\\":2.99,\\\"classification\\\":\\\"expense\\\"}]\\n```\\n\"}],\"text\":{\"format\":{\"type\":\"json_schema\",\"name\":\"auto_categorize_personal_finance_transactions\",\"strict\":true,\"schema\":{\"type\":\"object\",\"properties\":{\"categorizations\":{\"type\":\"array\",\"description\":\"An\n        array of auto-categorizations for each transaction\",\"items\":{\"type\":\"object\",\"properties\":{\"transaction_id\":{\"type\":\"string\",\"description\":\"The\n        internal ID of the original transaction\",\"enum\":[\"1\",\"2\",\"3\",\"4\",\"5\",\"6\"]},\"category_name\":{\"type\":\"string\",\"description\":\"The\n        matched category name of the transaction, or null if no match\",\"enum\":[\"Shopping\",\"Subscriptions\",\"Restaurants\",\"Fast\n        Food\",\"Income\",\"null\"]}},\"required\":[\"transaction_id\",\"category_name\"],\"additionalProperties\":false}}},\"required\":[\"categorizations\"],\"additionalProperties\":false}}},\"instructions\":\"You\n        are an assistant to a consumer personal finance app.  You will be provided\n        a list\\nof the user''s transactions and a list of the user''s categories.  Your\n        job is to auto-categorize\\neach transaction.\\n\\nClosely follow ALL the rules\n        below while auto-categorizing:\\n\\n- Return 1 result per transaction\\n- Correlate\n        each transaction by ID (transaction_id)\\n- Attempt to match the most specific\n        category possible (i.e. subcategory over parent category)\\n- Category and\n        transaction classifications should match (i.e. if transaction is an \\\"expense\\\",\n        the category must have classification of \\\"expense\\\")\\n- If you don''t know\n        the category, return \\\"null\\\"\\n  - You should always favor \\\"null\\\" over false\n        positives\\n  - Be slightly pessimistic.  Only match a category if you''re\n        60%+ confident it is the correct one.\\n- Each transaction has varying metadata\n        that can be used to determine the category\\n  - Note: \\\"hint\\\" comes from\n        3rd party aggregators and typically represents a category name that\\n    may\n        or may not match any of the user-supplied categories\\n\"}'\n    headers:\n      Content-Type:\n      - application/json\n      Authorization:\n      - Bearer <OPENAI_ACCESS_TOKEN>\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n      User-Agent:\n      - Ruby\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Wed, 16 Apr 2025 14:07:39 GMT\n      Content-Type:\n      - application/json\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Openai-Version:\n      - '2020-10-01'\n      Openai-Organization:\n      - user-r6cwd3mn6iv6gn748b2xoajx\n      X-Request-Id:\n      - req_01b869bd9eb7b994a80e79f6de92e5a2\n      Openai-Processing-Ms:\n      - '2173'\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      Cf-Cache-Status:\n      - DYNAMIC\n      Set-Cookie:\n      - __cf_bm=xGkX7L6XeEFLp6ZPB2Y.LLHD_YSpzTH28MUro6fQG7Y-1744812459-1.0.1.1-uy8WQsFzGblq3h.u6WFs2vld_HM.5fveVAFBsQ6y.Za22DSEa22k3NS7.GAUbgAvoVjGvSQlkm8LkSZyU3wZfN70cUpZrg27orQt0Nfq91U;\n        path=/; expires=Wed, 16-Apr-25 14:37:39 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=LicWzTMZxt1n1GLU6XQx3NnU0PbKnI0m97CH.p0895U-1744812459077-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      X-Content-Type-Options:\n      - nosniff\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 93143ffeffe8cf6b-CMH\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"id\": \"resp_67ffb9a8e530819290c5d3ec8aaf326d0e0f06e2ac13ae37\",\n          \"object\": \"response\",\n          \"created_at\": 1744812456,\n          \"status\": \"completed\",\n          \"error\": null,\n          \"incomplete_details\": null,\n          \"instructions\": \"You are an assistant to a consumer personal finance app.  You will be provided a list\\nof the user's transactions and a list of the user's categories.  Your job is to auto-categorize\\neach transaction.\\n\\nClosely follow ALL the rules below while auto-categorizing:\\n\\n- Return 1 result per transaction\\n- Correlate each transaction by ID (transaction_id)\\n- Attempt to match the most specific category possible (i.e. subcategory over parent category)\\n- Category and transaction classifications should match (i.e. if transaction is an \\\"expense\\\", the category must have classification of \\\"expense\\\")\\n- If you don't know the category, return \\\"null\\\"\\n  - You should always favor \\\"null\\\" over false positives\\n  - Be slightly pessimistic.  Only match a category if you're 60%+ confident it is the correct one.\\n- Each transaction has varying metadata that can be used to determine the category\\n  - Note: \\\"hint\\\" comes from 3rd party aggregators and typically represents a category name that\\n    may or may not match any of the user-supplied categories\\n\",\n          \"max_output_tokens\": null,\n          \"model\": \"gpt-4.1-mini-2025-04-14\",\n          \"output\": [\n            {\n              \"id\": \"msg_67ffb9a96b3c81928d9da130e889a9aa0e0f06e2ac13ae37\",\n              \"type\": \"message\",\n              \"status\": \"completed\",\n              \"content\": [\n                {\n                  \"type\": \"output_text\",\n                  \"annotations\": [],\n                  \"text\": \"{\\\"categorizations\\\":[{\\\"transaction_id\\\":\\\"1\\\",\\\"category_name\\\":\\\"Fast Food\\\"},{\\\"transaction_id\\\":\\\"2\\\",\\\"category_name\\\":\\\"Shopping\\\"},{\\\"transaction_id\\\":\\\"3\\\",\\\"category_name\\\":\\\"Subscriptions\\\"},{\\\"transaction_id\\\":\\\"4\\\",\\\"category_name\\\":\\\"Income\\\"},{\\\"transaction_id\\\":\\\"5\\\",\\\"category_name\\\":\\\"Restaurants\\\"},{\\\"transaction_id\\\":\\\"6\\\",\\\"category_name\\\":\\\"null\\\"}]}\"\n                }\n              ],\n              \"role\": \"assistant\"\n            }\n          ],\n          \"parallel_tool_calls\": true,\n          \"previous_response_id\": null,\n          \"reasoning\": {\n            \"effort\": null,\n            \"summary\": null\n          },\n          \"store\": true,\n          \"temperature\": 1.0,\n          \"text\": {\n            \"format\": {\n              \"type\": \"json_schema\",\n              \"description\": null,\n              \"name\": \"auto_categorize_personal_finance_transactions\",\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"categorizations\": {\n                    \"type\": \"array\",\n                    \"description\": \"An array of auto-categorizations for each transaction\",\n                    \"items\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"transaction_id\": {\n                          \"type\": \"string\",\n                          \"description\": \"The internal ID of the original transaction\",\n                          \"enum\": [\n                            \"1\",\n                            \"2\",\n                            \"3\",\n                            \"4\",\n                            \"5\",\n                            \"6\"\n                          ]\n                        },\n                        \"category_name\": {\n                          \"type\": \"string\",\n                          \"description\": \"The matched category name of the transaction, or null if no match\",\n                          \"enum\": [\n                            \"Shopping\",\n                            \"Subscriptions\",\n                            \"Restaurants\",\n                            \"Fast Food\",\n                            \"Income\",\n                            \"null\"\n                          ]\n                        }\n                      },\n                      \"required\": [\n                        \"transaction_id\",\n                        \"category_name\"\n                      ],\n                      \"additionalProperties\": false\n                    }\n                  }\n                },\n                \"required\": [\n                  \"categorizations\"\n                ],\n                \"additionalProperties\": false\n              },\n              \"strict\": true\n            }\n          },\n          \"tool_choice\": \"auto\",\n          \"tools\": [],\n          \"top_p\": 1.0,\n          \"truncation\": \"disabled\",\n          \"usage\": {\n            \"input_tokens\": 659,\n            \"input_tokens_details\": {\n              \"cached_tokens\": 0\n            },\n            \"output_tokens\": 70,\n            \"output_tokens_details\": {\n              \"reasoning_tokens\": 0\n            },\n            \"total_tokens\": 729\n          },\n          \"user\": null,\n          \"metadata\": {}\n        }\n  recorded_at: Wed, 16 Apr 2025 14:07:39 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/openai/auto_detect_merchants.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://api.openai.com/v1/responses\n    body:\n      encoding: UTF-8\n      string: '{\"model\":\"gpt-4.1-mini\",\"input\":[{\"role\":\"developer\",\"content\":\"Here\n        are the user''s available merchants in JSON format:\\n\\n```json\\n[{\\\"name\\\":\\\"Shooters\\\"}]\\n```\\n\\nUse\n        BOTH your knowledge AND the user-generated merchants to auto-detect the following\n        transactions:\\n\\n```json\\n[{\\\"id\\\":\\\"1\\\",\\\"name\\\":\\\"McDonalds\\\",\\\"amount\\\":20,\\\"classification\\\":\\\"expense\\\"},{\\\"id\\\":\\\"2\\\",\\\"name\\\":\\\"local\n        pub\\\",\\\"amount\\\":20,\\\"classification\\\":\\\"expense\\\"},{\\\"id\\\":\\\"3\\\",\\\"name\\\":\\\"WMT\n        purchases\\\",\\\"amount\\\":20,\\\"classification\\\":\\\"expense\\\"},{\\\"id\\\":\\\"4\\\",\\\"name\\\":\\\"amzn\n        123 abc\\\",\\\"amount\\\":20,\\\"classification\\\":\\\"expense\\\"},{\\\"id\\\":\\\"5\\\",\\\"name\\\":\\\"chaseX1231\\\",\\\"amount\\\":2000,\\\"classification\\\":\\\"income\\\"},{\\\"id\\\":\\\"6\\\",\\\"name\\\":\\\"check\n        deposit 022\\\",\\\"amount\\\":200,\\\"classification\\\":\\\"income\\\"},{\\\"id\\\":\\\"7\\\",\\\"name\\\":\\\"shooters\n        bar and grill\\\",\\\"amount\\\":200,\\\"classification\\\":\\\"expense\\\"},{\\\"id\\\":\\\"8\\\",\\\"name\\\":\\\"Microsoft\n        Office subscription\\\",\\\"amount\\\":200,\\\"classification\\\":\\\"expense\\\"}]\\n```\\n\\nReturn\n        \\\"null\\\" if you are not 80%+ confident in your answer.\\n\"}],\"text\":{\"format\":{\"type\":\"json_schema\",\"name\":\"auto_detect_personal_finance_merchants\",\"strict\":true,\"schema\":{\"type\":\"object\",\"properties\":{\"merchants\":{\"type\":\"array\",\"description\":\"An\n        array of auto-detected merchant businesses for each transaction\",\"items\":{\"type\":\"object\",\"properties\":{\"transaction_id\":{\"type\":\"string\",\"description\":\"The\n        internal ID of the original transaction\",\"enum\":[\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\"]},\"business_name\":{\"type\":[\"string\",\"null\"],\"description\":\"The\n        detected business name of the transaction, or `null` if uncertain\"},\"business_url\":{\"type\":[\"string\",\"null\"],\"description\":\"The\n        URL of the detected business, or `null` if uncertain\"}},\"required\":[\"transaction_id\",\"business_name\",\"business_url\"],\"additionalProperties\":false}}},\"required\":[\"merchants\"],\"additionalProperties\":false}}},\"instructions\":\"You\n        are an assistant to a consumer personal finance app.\\n\\nClosely follow ALL\n        the rules below while auto-detecting business names and website URLs:\\n\\n-\n        Return 1 result per transaction\\n- Correlate each transaction by ID (transaction_id)\\n-\n        Do not include the subdomain in the business_url (i.e. \\\"amazon.com\\\" not\n        \\\"www.amazon.com\\\")\\n- User merchants are considered \\\"manual\\\" user-generated\n        merchants and should only be used in 100% clear cases\\n- Be slightly pessimistic.  We\n        favor returning \\\"null\\\" over returning a false positive.\\n- NEVER return\n        a name or URL for generic transaction names (e.g. \\\"Paycheck\\\", \\\"Laundromat\\\",\n        \\\"Grocery store\\\", \\\"Local diner\\\")\\n\\nDetermining a value:\\n\\n- First attempt\n        to determine the name + URL from your knowledge of global businesses\\n- If\n        no certain match, attempt to match one of the user-provided merchants\\n- If\n        no match, return \\\"null\\\"\\n\\nExample 1 (known business):\\n\\n```\\nTransaction\n        name: \\\"Some Amazon purchases\\\"\\n\\nResult:\\n- business_name: \\\"Amazon\\\"\\n-\n        business_url: \\\"amazon.com\\\"\\n```\\n\\nExample 2 (generic business):\\n\\n```\\nTransaction\n        name: \\\"local diner\\\"\\n\\nResult:\\n- business_name: null\\n- business_url: null\\n```\\n\"}'\n    headers:\n      Content-Type:\n      - application/json\n      Authorization:\n      - Bearer <OPENAI_ACCESS_TOKEN>\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n      User-Agent:\n      - Ruby\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Wed, 16 Apr 2025 15:41:50 GMT\n      Content-Type:\n      - application/json\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Openai-Version:\n      - '2020-10-01'\n      Openai-Organization:\n      - user-r6cwd3mn6iv6gn748b2xoajx\n      X-Request-Id:\n      - req_77a41d32ae2c3dbd9081b34bc5e4ce61\n      Openai-Processing-Ms:\n      - '2152'\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      Cf-Cache-Status:\n      - DYNAMIC\n      Set-Cookie:\n      - __cf_bm=hCFJRspk322ZVvRasJGcux5mYDyfa5aO7EQOCAbnhjM-1744818110-1.0.1.1-.fRz_SYTG_PqZ3VCSDju7YeDaZwCyf5OGVvDvaN.h3aegNTlYtdPwbnZ5NNFxLRJhWFRY4vwHYkHm1DGTarK5NQ6UjA1sOrRpmS5eZ.zabw;\n        path=/; expires=Wed, 16-Apr-25 16:11:50 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=At3dVxwug2seJ3Oa02PSnIoKhVSEvt6IPCLfhkULvac-1744818110064-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      X-Content-Type-Options:\n      - nosniff\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 9314c9f5cef5efe9-CMH\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"id\": \"resp_67ffcfbbddb48192a251a3c0f341941a04d20b39fa51ef90\",\n          \"object\": \"response\",\n          \"created_at\": 1744818107,\n          \"status\": \"completed\",\n          \"error\": null,\n          \"incomplete_details\": null,\n          \"instructions\": \"You are an assistant to a consumer personal finance app.\\n\\nClosely follow ALL the rules below while auto-detecting business names and website URLs:\\n\\n- Return 1 result per transaction\\n- Correlate each transaction by ID (transaction_id)\\n- Do not include the subdomain in the business_url (i.e. \\\"amazon.com\\\" not \\\"www.amazon.com\\\")\\n- User merchants are considered \\\"manual\\\" user-generated merchants and should only be used in 100% clear cases\\n- Be slightly pessimistic.  We favor returning \\\"null\\\" over returning a false positive.\\n- NEVER return a name or URL for generic transaction names (e.g. \\\"Paycheck\\\", \\\"Laundromat\\\", \\\"Grocery store\\\", \\\"Local diner\\\")\\n\\nDetermining a value:\\n\\n- First attempt to determine the name + URL from your knowledge of global businesses\\n- If no certain match, attempt to match one of the user-provided merchants\\n- If no match, return \\\"null\\\"\\n\\nExample 1 (known business):\\n\\n```\\nTransaction name: \\\"Some Amazon purchases\\\"\\n\\nResult:\\n- business_name: \\\"Amazon\\\"\\n- business_url: \\\"amazon.com\\\"\\n```\\n\\nExample 2 (generic business):\\n\\n```\\nTransaction name: \\\"local diner\\\"\\n\\nResult:\\n- business_name: null\\n- business_url: null\\n```\\n\",\n          \"max_output_tokens\": null,\n          \"model\": \"gpt-4.1-mini-2025-04-14\",\n          \"output\": [\n            {\n              \"id\": \"msg_67ffcfbc58bc8192bbcf4dc54759837c04d20b39fa51ef90\",\n              \"type\": \"message\",\n              \"status\": \"completed\",\n              \"content\": [\n                {\n                  \"type\": \"output_text\",\n                  \"annotations\": [],\n                  \"text\": \"{\\\"merchants\\\":[{\\\"transaction_id\\\":\\\"1\\\",\\\"business_name\\\":\\\"McDonald's\\\",\\\"business_url\\\":\\\"mcdonalds.com\\\"},{\\\"transaction_id\\\":\\\"2\\\",\\\"business_name\\\":null,\\\"business_url\\\":null},{\\\"transaction_id\\\":\\\"3\\\",\\\"business_name\\\":\\\"Walmart\\\",\\\"business_url\\\":\\\"walmart.com\\\"},{\\\"transaction_id\\\":\\\"4\\\",\\\"business_name\\\":\\\"Amazon\\\",\\\"business_url\\\":\\\"amazon.com\\\"},{\\\"transaction_id\\\":\\\"5\\\",\\\"business_name\\\":null,\\\"business_url\\\":null},{\\\"transaction_id\\\":\\\"6\\\",\\\"business_name\\\":null,\\\"business_url\\\":null},{\\\"transaction_id\\\":\\\"7\\\",\\\"business_name\\\":\\\"Shooters\\\",\\\"business_url\\\":null},{\\\"transaction_id\\\":\\\"8\\\",\\\"business_name\\\":\\\"Microsoft\\\",\\\"business_url\\\":\\\"microsoft.com\\\"}]}\"\n                }\n              ],\n              \"role\": \"assistant\"\n            }\n          ],\n          \"parallel_tool_calls\": true,\n          \"previous_response_id\": null,\n          \"reasoning\": {\n            \"effort\": null,\n            \"summary\": null\n          },\n          \"store\": true,\n          \"temperature\": 1.0,\n          \"text\": {\n            \"format\": {\n              \"type\": \"json_schema\",\n              \"description\": null,\n              \"name\": \"auto_detect_personal_finance_merchants\",\n              \"schema\": {\n                \"type\": \"object\",\n                \"properties\": {\n                  \"merchants\": {\n                    \"type\": \"array\",\n                    \"description\": \"An array of auto-detected merchant businesses for each transaction\",\n                    \"items\": {\n                      \"type\": \"object\",\n                      \"properties\": {\n                        \"transaction_id\": {\n                          \"type\": \"string\",\n                          \"description\": \"The internal ID of the original transaction\",\n                          \"enum\": [\n                            \"1\",\n                            \"2\",\n                            \"3\",\n                            \"4\",\n                            \"5\",\n                            \"6\",\n                            \"7\",\n                            \"8\"\n                          ]\n                        },\n                        \"business_name\": {\n                          \"type\": [\n                            \"string\",\n                            \"null\"\n                          ],\n                          \"description\": \"The detected business name of the transaction, or `null` if uncertain\"\n                        },\n                        \"business_url\": {\n                          \"type\": [\n                            \"string\",\n                            \"null\"\n                          ],\n                          \"description\": \"The URL of the detected business, or `null` if uncertain\"\n                        }\n                      },\n                      \"required\": [\n                        \"transaction_id\",\n                        \"business_name\",\n                        \"business_url\"\n                      ],\n                      \"additionalProperties\": false\n                    }\n                  }\n                },\n                \"required\": [\n                  \"merchants\"\n                ],\n                \"additionalProperties\": false\n              },\n              \"strict\": true\n            }\n          },\n          \"tool_choice\": \"auto\",\n          \"tools\": [],\n          \"top_p\": 1.0,\n          \"truncation\": \"disabled\",\n          \"usage\": {\n            \"input_tokens\": 635,\n            \"input_tokens_details\": {\n              \"cached_tokens\": 0\n            },\n            \"output_tokens\": 140,\n            \"output_tokens_details\": {\n              \"reasoning_tokens\": 0\n            },\n            \"total_tokens\": 775\n          },\n          \"user\": null,\n          \"metadata\": {}\n        }\n  recorded_at: Wed, 16 Apr 2025 15:41:50 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/openai/chat/basic_response.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://api.openai.com/v1/responses\n    body:\n      encoding: UTF-8\n      string: '{\"model\":\"gpt-4.1\",\"input\":[{\"role\":\"user\",\"content\":\"This is a chat\n        test.  If it''s working, respond with a single word: Yes\"}],\"instructions\":null,\"tools\":[],\"previous_response_id\":null,\"stream\":null}'\n    headers:\n      Content-Type:\n      - application/json\n      Authorization:\n      - Bearer <OPENAI_ACCESS_TOKEN>\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n      User-Agent:\n      - Ruby\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Mon, 31 Mar 2025 20:38:55 GMT\n      Content-Type:\n      - application/json\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Openai-Version:\n      - '2020-10-01'\n      Openai-Organization:\n      - \"<OPENAI_ORGANIZATION_ID>\"\n      X-Request-Id:\n      - req_f99033a5841a7d9357ee08d301ad634e\n      Openai-Processing-Ms:\n      - '713'\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      Cf-Cache-Status:\n      - DYNAMIC\n      Set-Cookie:\n      - __cf_bm=UOaolWyAE3WXhLfg9c3KmO4d_Nq6t9cedTfZ6hznYEE-1743453535-1.0.1.1-GyQq_xeRpsyxxp8QQja5Bvo2XqUGfXHNGehtQoPV.BIgyLbERSIqJAK0IEKcYgpuLCyvQdlMNGqtdBHB6r5XMPHjOSMN1bTQYJHLsvlD5Z4;\n        path=/; expires=Mon, 31-Mar-25 21:08:55 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=_zDj2dj75eLeGSzZxpBpzHxYg4gJpEfQpcnT9aCJXqM-1743453535930-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      X-Content-Type-Options:\n      - nosniff\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 9292a7325d09cf53-CMH\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"id\": \"resp_67eafd5f2b7c81928d6834e7f4d26deb0bfadc995fda2b45\",\n          \"object\": \"response\",\n          \"created_at\": 1743453535,\n          \"status\": \"completed\",\n          \"error\": null,\n          \"incomplete_details\": null,\n          \"instructions\": null,\n          \"max_output_tokens\": null,\n          \"model\": \"gpt-4.1-2024-08-06\",\n          \"output\": [\n            {\n              \"type\": \"message\",\n              \"id\": \"msg_67eafd5fba44819287b79107821a818b0bfadc995fda2b45\",\n              \"status\": \"completed\",\n              \"role\": \"assistant\",\n              \"content\": [\n                {\n                  \"type\": \"output_text\",\n                  \"text\": \"Yes\",\n                  \"annotations\": []\n                }\n              ]\n            }\n          ],\n          \"parallel_tool_calls\": true,\n          \"previous_response_id\": null,\n          \"reasoning\": {\n            \"effort\": null,\n            \"generate_summary\": null\n          },\n          \"store\": true,\n          \"temperature\": 1.0,\n          \"text\": {\n            \"format\": {\n              \"type\": \"text\"\n            }\n          },\n          \"tool_choice\": \"auto\",\n          \"tools\": [],\n          \"top_p\": 1.0,\n          \"truncation\": \"disabled\",\n          \"usage\": {\n            \"input_tokens\": 25,\n            \"input_tokens_details\": {\n              \"cached_tokens\": 0\n            },\n            \"output_tokens\": 2,\n            \"output_tokens_details\": {\n              \"reasoning_tokens\": 0\n            },\n            \"total_tokens\": 27\n          },\n          \"user\": null,\n          \"metadata\": {}\n        }\n  recorded_at: Mon, 31 Mar 2025 20:38:55 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/openai/chat/basic_streaming_response.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://api.openai.com/v1/responses\n    body:\n      encoding: UTF-8\n      string: '{\"model\":\"gpt-4.1\",\"input\":[{\"role\":\"user\",\"content\":\"This is a chat\n        test.  If it''s working, respond with a single word: Yes\"}],\"instructions\":null,\"tools\":[],\"previous_response_id\":null,\"stream\":true}'\n    headers:\n      Content-Type:\n      - application/json\n      Authorization:\n      - Bearer <OPENAI_ACCESS_TOKEN>\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n      User-Agent:\n      - Ruby\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Mon, 31 Mar 2025 20:38:55 GMT\n      Content-Type:\n      - text/event-stream; charset=utf-8\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Openai-Version:\n      - '2020-10-01'\n      Openai-Organization:\n      - \"<OPENAI_ORGANIZATION_ID>\"\n      X-Request-Id:\n      - req_d88b2a28252a098fe9f6e1223baebad8\n      Openai-Processing-Ms:\n      - '124'\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      Cf-Cache-Status:\n      - DYNAMIC\n      Set-Cookie:\n      - __cf_bm=wP2ENU9eOGUSzQ8wOjb31UiZAZVX021QgA1NuYcfKeo-1743453535-1.0.1.1-d08X7zX7cf._5LTGrF6qL17AtdgsKpEWLWnZ0dl5KgPWXEK.oqoDgoQ_pa8j5rKYZkeZUDxMhcpP266z9tJpPJ2ZPX8bkZYAjlnlcOa5.JM;\n        path=/; expires=Mon, 31-Mar-25 21:08:55 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=F6OIQe1fgGYxb6xer0VjBA1aHrf6osX7wJU6adYsMy0-1743453535321-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      X-Content-Type-Options:\n      - nosniff\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 9292a7324c3dcf78-CMH\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n    body:\n      encoding: UTF-8\n      string: |+\n        event: response.created\n        data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_67eafd5f2b90819288af54361ff81a100e51d01dbd4ed330\",\"object\":\"response\",\"created_at\":1743453535,\"status\":\"in_progress\",\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"model\":\"gpt-4.1-2024-08-06\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"reasoning\":{\"effort\":null,\"generate_summary\":null},\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[],\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}}}\n\n        event: response.in_progress\n        data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_67eafd5f2b90819288af54361ff81a100e51d01dbd4ed330\",\"object\":\"response\",\"created_at\":1743453535,\"status\":\"in_progress\",\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"model\":\"gpt-4.1-2024-08-06\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"reasoning\":{\"effort\":null,\"generate_summary\":null},\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[],\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}}}\n\n        event: response.output_item.added\n        data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\",\"id\":\"msg_67eafd5f7c048192a24ce545ebfd908a0e51d01dbd4ed330\",\"status\":\"in_progress\",\"role\":\"assistant\",\"content\":[]}}\n\n        event: response.content_part.added\n        data: {\"type\":\"response.content_part.added\",\"item_id\":\"msg_67eafd5f7c048192a24ce545ebfd908a0e51d01dbd4ed330\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"\",\"annotations\":[]}}\n\n        event: response.output_text.delta\n        data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_67eafd5f7c048192a24ce545ebfd908a0e51d01dbd4ed330\",\"output_index\":0,\"content_index\":0,\"delta\":\"Yes\"}\n\n        event: response.output_text.done\n        data: {\"type\":\"response.output_text.done\",\"item_id\":\"msg_67eafd5f7c048192a24ce545ebfd908a0e51d01dbd4ed330\",\"output_index\":0,\"content_index\":0,\"text\":\"Yes\"}\n\n        event: response.content_part.done\n        data: {\"type\":\"response.content_part.done\",\"item_id\":\"msg_67eafd5f7c048192a24ce545ebfd908a0e51d01dbd4ed330\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"Yes\",\"annotations\":[]}}\n\n        event: response.output_item.done\n        data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"message\",\"id\":\"msg_67eafd5f7c048192a24ce545ebfd908a0e51d01dbd4ed330\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Yes\",\"annotations\":[]}]}}\n\n        event: response.completed\n        data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_67eafd5f2b90819288af54361ff81a100e51d01dbd4ed330\",\"object\":\"response\",\"created_at\":1743453535,\"status\":\"completed\",\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"model\":\"gpt-4.1-2024-08-06\",\"output\":[{\"type\":\"message\",\"id\":\"msg_67eafd5f7c048192a24ce545ebfd908a0e51d01dbd4ed330\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Yes\",\"annotations\":[]}]}],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"reasoning\":{\"effort\":null,\"generate_summary\":null},\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[],\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":25,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":2,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":27},\"user\":null,\"metadata\":{}}}\n\n  recorded_at: Mon, 31 Mar 2025 20:38:55 GMT\nrecorded_with: VCR 6.3.1\n...\n"
  },
  {
    "path": "test/vcr_cassettes/openai/chat/error.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://api.openai.com/v1/responses\n    body:\n      encoding: UTF-8\n      string: '{\"model\":\"invalid-model-that-will-trigger-api-error\",\"input\":[{\"role\":\"user\",\"content\":\"Test\"}],\"instructions\":null,\"tools\":[],\"previous_response_id\":null,\"stream\":null}'\n    headers:\n      Content-Type:\n      - application/json\n      Authorization:\n      - Bearer <OPENAI_ACCESS_TOKEN>\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n      User-Agent:\n      - Ruby\n  response:\n    status:\n      code: 400\n      message: Bad Request\n    headers:\n      Date:\n      - Mon, 31 Mar 2025 20:38:55 GMT\n      Content-Type:\n      - application/json\n      Content-Length:\n      - '207'\n      Connection:\n      - keep-alive\n      Openai-Version:\n      - '2020-10-01'\n      Openai-Organization:\n      - \"<OPENAI_ORGANIZATION_ID>\"\n      X-Request-Id:\n      - req_3981f27aa18db734b3dd530fa2929b95\n      Openai-Processing-Ms:\n      - '113'\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      Cf-Cache-Status:\n      - DYNAMIC\n      Set-Cookie:\n      - __cf_bm=8KUMK_Gp4f97KLactyy3QniUZbNmN9Zwbx9WowYCc98-1743453535-1.0.1.1-opjT17tCwi9U0AukBXoHrpPEcC4Z.GIyEt.AjjrzRWln62SWPIvggY4L19JabZu09.9cmxfyrwAFHmvDeCVxSWqAVf88PAZwwRICkZZUut0;\n        path=/; expires=Mon, 31-Mar-25 21:08:55 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=uZB07768IynyRRP6oxwcnC4Rfn.lGT1yRhzzGvNw0kc-1743453535322-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      X-Content-Type-Options:\n      - nosniff\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 9292a7327d5161d6-ORD\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n    body:\n      encoding: UTF-8\n      string: |-\n        {\n          \"error\": {\n            \"message\": \"The requested model 'invalid-model-that-will-trigger-api-error' does not exist.\",\n            \"type\": \"invalid_request_error\",\n            \"param\": \"model\",\n            \"code\": \"model_not_found\"\n          }\n        }\n  recorded_at: Mon, 31 Mar 2025 20:38:55 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/openai/chat/function_calls.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://api.openai.com/v1/responses\n    body:\n      encoding: UTF-8\n      string: '{\"model\":\"gpt-4.1\",\"input\":[{\"role\":\"user\",\"content\":\"What is my net\n        worth?\"}],\"instructions\":\"Use the tools available to you to answer the user''s\n        question.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_net_worth\",\"description\":\"Gets\n        a user''s net worth\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"required\":[],\"additionalProperties\":false},\"strict\":true}],\"previous_response_id\":null,\"stream\":null}'\n    headers:\n      Content-Type:\n      - application/json\n      Authorization:\n      - Bearer <OPENAI_ACCESS_TOKEN>\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n      User-Agent:\n      - Ruby\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Mon, 31 Mar 2025 20:38:55 GMT\n      Content-Type:\n      - application/json\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Openai-Version:\n      - '2020-10-01'\n      Openai-Organization:\n      - \"<OPENAI_ORGANIZATION_ID>\"\n      X-Request-Id:\n      - req_a179c8964589756af0d4b5af864a29a7\n      Openai-Processing-Ms:\n      - '761'\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      Cf-Cache-Status:\n      - DYNAMIC\n      Set-Cookie:\n      - __cf_bm=niiWOEhogNgWfxuZanJKipOlIrWGEPtp7bUpqDAp9Lo-1743453535-1.0.1.1-ytL9wC5t5fjY2v90vscRJLokIeZyVY2hmBqFuWbA_BOvZaw9aPFmtQDKhDD3WcLQryEtXiEGAyOANHnaeItCR0J_sXu7Jy4wdpJ4EMShQxU;\n        path=/; expires=Mon, 31-Mar-25 21:08:55 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=kKjDNYSJJidsRTyFQWUgt6xlnqW_DkveNOUYxpBe9EE-1743453535972-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      X-Content-Type-Options:\n      - nosniff\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 9292a732598dcf52-CMH\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"id\": \"resp_67eafd5f2d1881928f10551839e8219102a5ebf5f2a599ef\",\n          \"object\": \"response\",\n          \"created_at\": 1743453535,\n          \"status\": \"completed\",\n          \"error\": null,\n          \"incomplete_details\": null,\n          \"instructions\": \"Use the tools available to you to answer the user's question.\",\n          \"max_output_tokens\": null,\n          \"model\": \"gpt-4.1-2024-08-06\",\n          \"output\": [\n            {\n              \"type\": \"function_call\",\n              \"id\": \"fc_67eafd5f9c88819286afe92f08354f7302a5ebf5f2a599ef\",\n              \"call_id\": \"call_KrFORr53UBxdwZ9SQ6fkpU0F\",\n              \"name\": \"get_net_worth\",\n              \"arguments\": \"{}\",\n              \"status\": \"completed\"\n            }\n          ],\n          \"parallel_tool_calls\": true,\n          \"previous_response_id\": null,\n          \"reasoning\": {\n            \"effort\": null,\n            \"generate_summary\": null\n          },\n          \"store\": true,\n          \"temperature\": 1.0,\n          \"text\": {\n            \"format\": {\n              \"type\": \"text\"\n            }\n          },\n          \"tool_choice\": \"auto\",\n          \"tools\": [\n            {\n              \"type\": \"function\",\n              \"description\": \"Gets a user's net worth\",\n              \"name\": \"get_net_worth\",\n              \"parameters\": {\n                \"type\": \"object\",\n                \"properties\": {},\n                \"required\": [],\n                \"additionalProperties\": false\n              },\n              \"strict\": true\n            }\n          ],\n          \"top_p\": 1.0,\n          \"truncation\": \"disabled\",\n          \"usage\": {\n            \"input_tokens\": 55,\n            \"input_tokens_details\": {\n              \"cached_tokens\": 0\n            },\n            \"output_tokens\": 13,\n            \"output_tokens_details\": {\n              \"reasoning_tokens\": 0\n            },\n            \"total_tokens\": 68\n          },\n          \"user\": null,\n          \"metadata\": {}\n        }\n  recorded_at: Mon, 31 Mar 2025 20:38:55 GMT\n- request:\n    method: post\n    uri: https://api.openai.com/v1/responses\n    body:\n      encoding: UTF-8\n      string: '{\"model\":\"gpt-4.1\",\"input\":[{\"role\":\"user\",\"content\":\"What is my net\n        worth?\"},{\"type\":\"function_call_output\",\"call_id\":\"call_KrFORr53UBxdwZ9SQ6fkpU0F\",\"output\":\"\\\"{\\\\\\\"amount\\\\\\\":10000,\\\\\\\"currency\\\\\\\":\\\\\\\"USD\\\\\\\"}\\\"\"}],\"instructions\":null,\"tools\":[],\"previous_response_id\":\"resp_67eafd5f2d1881928f10551839e8219102a5ebf5f2a599ef\",\"stream\":null}'\n    headers:\n      Content-Type:\n      - application/json\n      Authorization:\n      - Bearer <OPENAI_ACCESS_TOKEN>\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n      User-Agent:\n      - Ruby\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Mon, 31 Mar 2025 20:38:56 GMT\n      Content-Type:\n      - application/json\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Openai-Version:\n      - '2020-10-01'\n      Openai-Organization:\n      - \"<OPENAI_ORGANIZATION_ID>\"\n      X-Request-Id:\n      - req_edd5bafc982bae46e92d0cd79e594779\n      Openai-Processing-Ms:\n      - '805'\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      Cf-Cache-Status:\n      - DYNAMIC\n      Set-Cookie:\n      - __cf_bm=jOZGEPyAByXhGrQIvKzbj_6TEODdZWw_S0BZsxbsuDc-1743453536-1.0.1.1-YpxHv.vmXVdwzQV5dMTB0I851tQSlDf.NboFddRq_aLDM1CnQW143gRcYbfPpCREij9SDqhnluZ4kxCuD3eaarhmFn2liMVHHRYUgMsUhck;\n        path=/; expires=Mon, 31-Mar-25 21:08:56 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=1BoPw7WORdkfBQmal3sGAXdHGiJiFkXK8HXhWPWf7Vw-1743453536967-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      X-Content-Type-Options:\n      - nosniff\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 9292a7385ec6cf62-CMH\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"id\": \"resp_67eafd6023488192b382acd64a514ff002a5ebf5f2a599ef\",\n          \"object\": \"response\",\n          \"created_at\": 1743453536,\n          \"status\": \"completed\",\n          \"error\": null,\n          \"incomplete_details\": null,\n          \"instructions\": null,\n          \"max_output_tokens\": null,\n          \"model\": \"gpt-4.1-2024-08-06\",\n          \"output\": [\n            {\n              \"type\": \"message\",\n              \"id\": \"msg_67eafd60a42c8192906eb4d48f8970de02a5ebf5f2a599ef\",\n              \"status\": \"completed\",\n              \"role\": \"assistant\",\n              \"content\": [\n                {\n                  \"type\": \"output_text\",\n                  \"text\": \"Your net worth is $10,000 USD.\",\n                  \"annotations\": []\n                }\n              ]\n            }\n          ],\n          \"parallel_tool_calls\": true,\n          \"previous_response_id\": \"resp_67eafd5f2d1881928f10551839e8219102a5ebf5f2a599ef\",\n          \"reasoning\": {\n            \"effort\": null,\n            \"generate_summary\": null\n          },\n          \"store\": true,\n          \"temperature\": 1.0,\n          \"text\": {\n            \"format\": {\n              \"type\": \"text\"\n            }\n          },\n          \"tool_choice\": \"auto\",\n          \"tools\": [],\n          \"top_p\": 1.0,\n          \"truncation\": \"disabled\",\n          \"usage\": {\n            \"input_tokens\": 58,\n            \"input_tokens_details\": {\n              \"cached_tokens\": 0\n            },\n            \"output_tokens\": 11,\n            \"output_tokens_details\": {\n              \"reasoning_tokens\": 0\n            },\n            \"total_tokens\": 69\n          },\n          \"user\": null,\n          \"metadata\": {}\n        }\n  recorded_at: Mon, 31 Mar 2025 20:38:57 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/openai/chat/streaming_function_calls.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://api.openai.com/v1/responses\n    body:\n      encoding: UTF-8\n      string: '{\"model\":\"gpt-4.1\",\"input\":[{\"role\":\"user\",\"content\":\"What is my net\n        worth?\"}],\"instructions\":\"Use the tools available to you to answer the user''s\n        question.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_net_worth\",\"description\":\"Gets\n        a user''s net worth\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"required\":[],\"additionalProperties\":false},\"strict\":true}],\"previous_response_id\":null,\"stream\":true}'\n    headers:\n      Content-Type:\n      - application/json\n      Authorization:\n      - Bearer <OPENAI_ACCESS_TOKEN>\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n      User-Agent:\n      - Ruby\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Mon, 31 Mar 2025 20:38:55 GMT\n      Content-Type:\n      - text/event-stream; charset=utf-8\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Openai-Version:\n      - '2020-10-01'\n      Openai-Organization:\n      - \"<OPENAI_ORGANIZATION_ID>\"\n      X-Request-Id:\n      - req_8c4d6f0ad0ae3095353a5c19fd128c56\n      Openai-Processing-Ms:\n      - '129'\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      Cf-Cache-Status:\n      - DYNAMIC\n      Set-Cookie:\n      - __cf_bm=5yRGSo0Y69GvEK51Bq2.Np0DSg9DmAJKNqvE3_XgKBg-1743453535-1.0.1.1-sH1YR42zmznwvKlaBUM.bPKvJl_PiebfNBKhREMO.sSa5gvFEkpcKaCG4x3XUdZ19XGTEF0CbRII3mqtcPJhxFzX3uVLGuVsyjz6odYDisM;\n        path=/; expires=Mon, 31-Mar-25 21:08:55 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=tblnBnP9s7yFkSzbYy9zuzuDkxS9i_n7hk3XdiiGui8-1743453535332-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      X-Content-Type-Options:\n      - nosniff\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 9292a7324dfbcf46-CMH\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n    body:\n      encoding: UTF-8\n      string: |+\n        event: response.created\n        data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_67eafd5f2ef0819290ec6bbbc5f27c8e0aa8698ee903b906\",\"object\":\"response\",\"created_at\":1743453535,\"status\":\"in_progress\",\"error\":null,\"incomplete_details\":null,\"instructions\":\"Use the tools available to you to answer the user's question.\",\"max_output_tokens\":null,\"model\":\"gpt-4.1-2024-08-06\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"reasoning\":{\"effort\":null,\"generate_summary\":null},\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Gets a user's net worth\",\"name\":\"get_net_worth\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"required\":[],\"additionalProperties\":false},\"strict\":true}],\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}}}\n\n        event: response.in_progress\n        data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_67eafd5f2ef0819290ec6bbbc5f27c8e0aa8698ee903b906\",\"object\":\"response\",\"created_at\":1743453535,\"status\":\"in_progress\",\"error\":null,\"incomplete_details\":null,\"instructions\":\"Use the tools available to you to answer the user's question.\",\"max_output_tokens\":null,\"model\":\"gpt-4.1-2024-08-06\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"reasoning\":{\"effort\":null,\"generate_summary\":null},\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Gets a user's net worth\",\"name\":\"get_net_worth\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"required\":[],\"additionalProperties\":false},\"strict\":true}],\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}}}\n\n        event: response.output_item.added\n        data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"fc_67eafd5fa714819287b2bff8c76935690aa8698ee903b906\",\"call_id\":\"call_7EY6rF7mkfNyMIz3HQmrYIOq\",\"name\":\"get_net_worth\",\"arguments\":\"\",\"status\":\"in_progress\"}}\n\n        event: response.function_call_arguments.delta\n        data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_67eafd5fa714819287b2bff8c76935690aa8698ee903b906\",\"output_index\":0,\"delta\":\"{}\"}\n\n        event: response.function_call_arguments.done\n        data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_67eafd5fa714819287b2bff8c76935690aa8698ee903b906\",\"output_index\":0,\"arguments\":\"{}\"}\n\n        event: response.output_item.done\n        data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"fc_67eafd5fa714819287b2bff8c76935690aa8698ee903b906\",\"call_id\":\"call_7EY6rF7mkfNyMIz3HQmrYIOq\",\"name\":\"get_net_worth\",\"arguments\":\"{}\",\"status\":\"completed\"}}\n\n        event: response.completed\n        data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_67eafd5f2ef0819290ec6bbbc5f27c8e0aa8698ee903b906\",\"object\":\"response\",\"created_at\":1743453535,\"status\":\"completed\",\"error\":null,\"incomplete_details\":null,\"instructions\":\"Use the tools available to you to answer the user's question.\",\"max_output_tokens\":null,\"model\":\"gpt-4.1-2024-08-06\",\"output\":[{\"type\":\"function_call\",\"id\":\"fc_67eafd5fa714819287b2bff8c76935690aa8698ee903b906\",\"call_id\":\"call_7EY6rF7mkfNyMIz3HQmrYIOq\",\"name\":\"get_net_worth\",\"arguments\":\"{}\",\"status\":\"completed\"}],\"parallel_tool_calls\":true,\"previous_response_id\":null,\"reasoning\":{\"effort\":null,\"generate_summary\":null},\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Gets a user's net worth\",\"name\":\"get_net_worth\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"required\":[],\"additionalProperties\":false},\"strict\":true}],\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":55,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":13,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":68},\"user\":null,\"metadata\":{}}}\n\n  recorded_at: Mon, 31 Mar 2025 20:38:55 GMT\n- request:\n    method: post\n    uri: https://api.openai.com/v1/responses\n    body:\n      encoding: UTF-8\n      string: '{\"model\":\"gpt-4.1\",\"input\":[{\"role\":\"user\",\"content\":\"What is my net\n        worth?\"},{\"type\":\"function_call_output\",\"call_id\":\"call_7EY6rF7mkfNyMIz3HQmrYIOq\",\"output\":\"{\\\"amount\\\":10000,\\\"currency\\\":\\\"USD\\\"}\"}],\"instructions\":null,\"tools\":[],\"previous_response_id\":\"resp_67eafd5f2ef0819290ec6bbbc5f27c8e0aa8698ee903b906\",\"stream\":true}'\n    headers:\n      Content-Type:\n      - application/json\n      Authorization:\n      - Bearer <OPENAI_ACCESS_TOKEN>\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n      User-Agent:\n      - Ruby\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Mon, 31 Mar 2025 20:38:56 GMT\n      Content-Type:\n      - text/event-stream; charset=utf-8\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Openai-Version:\n      - '2020-10-01'\n      Openai-Organization:\n      - \"<OPENAI_ORGANIZATION_ID>\"\n      X-Request-Id:\n      - req_be9f30124a3a4cdae2d3b038692f6699\n      Openai-Processing-Ms:\n      - '177'\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      Cf-Cache-Status:\n      - DYNAMIC\n      Set-Cookie:\n      - __cf_bm=gNS8vmdzyz2jct__mfjLZGkJhCxddarRy62IkzSIFWM-1743453536-1.0.1.1-ufcPPmSzEaEysjhkRUozTfCIriRWy5iyeXCaVqeFDaJDWT4lc8ate4JhryV0fVQSZBi6pRN8zYh9dkLyYuXoSqYDCsZTN1uk6vO84nX1qGo;\n        path=/; expires=Mon, 31-Mar-25 21:08:56 GMT; domain=.api.openai.com; HttpOnly;\n        Secure; SameSite=None\n      - _cfuvid=3D41ZgFle.u0ER2Ehnm.bsdnSGlCXVArPa7bx9zumYU-1743453536171-0.0.1.1-604800000;\n        path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None\n      X-Content-Type-Options:\n      - nosniff\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 9292a7376f5acf4e-CMH\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n    body:\n      encoding: UTF-8\n      string: |+\n        event: response.created\n        data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_67eafd5ff7448192af7cd9e9dde90f5e0aa8698ee903b906\",\"object\":\"response\",\"created_at\":1743453536,\"status\":\"in_progress\",\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"model\":\"gpt-4.1-2024-08-06\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":\"resp_67eafd5f2ef0819290ec6bbbc5f27c8e0aa8698ee903b906\",\"reasoning\":{\"effort\":null,\"generate_summary\":null},\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[],\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}}}\n\n        event: response.in_progress\n        data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_67eafd5ff7448192af7cd9e9dde90f5e0aa8698ee903b906\",\"object\":\"response\",\"created_at\":1743453536,\"status\":\"in_progress\",\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"model\":\"gpt-4.1-2024-08-06\",\"output\":[],\"parallel_tool_calls\":true,\"previous_response_id\":\"resp_67eafd5f2ef0819290ec6bbbc5f27c8e0aa8698ee903b906\",\"reasoning\":{\"effort\":null,\"generate_summary\":null},\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[],\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}}}\n\n        event: response.output_item.added\n        data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\",\"id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"status\":\"in_progress\",\"role\":\"assistant\",\"content\":[]}}\n\n        event: response.content_part.added\n        data: {\"type\":\"response.content_part.added\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"\",\"annotations\":[]}}\n\n        event: response.output_text.delta\n        data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"delta\":\"Your\"}\n\n        event: response.output_text.delta\n        data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"delta\":\" net\"}\n\n        event: response.output_text.delta\n        data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"delta\":\" worth\"}\n\n        event: response.output_text.delta\n        data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"delta\":\" is\"}\n\n        event: response.output_text.delta\n        data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"delta\":\" $\"}\n\n        event: response.output_text.delta\n        data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"delta\":\"10\"}\n\n        event: response.output_text.delta\n        data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"delta\":\",\"}\n\n        event: response.output_text.delta\n        data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"delta\":\"000\"}\n\n        event: response.output_text.delta\n        data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"delta\":\" USD\"}\n\n        event: response.output_text.delta\n        data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"delta\":\".\"}\n\n        event: response.output_text.done\n        data: {\"type\":\"response.output_text.done\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"text\":\"Your net worth is $10,000 USD.\"}\n\n        event: response.content_part.done\n        data: {\"type\":\"response.content_part.done\",\"item_id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"Your net worth is $10,000 USD.\",\"annotations\":[]}}\n\n        event: response.output_item.done\n        data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"message\",\"id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Your net worth is $10,000 USD.\",\"annotations\":[]}]}}\n\n        event: response.completed\n        data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_67eafd5ff7448192af7cd9e9dde90f5e0aa8698ee903b906\",\"object\":\"response\",\"created_at\":1743453536,\"status\":\"completed\",\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"model\":\"gpt-4.1-2024-08-06\",\"output\":[{\"type\":\"message\",\"id\":\"msg_67eafd6084ec81929a5132414ef713180aa8698ee903b906\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Your net worth is $10,000 USD.\",\"annotations\":[]}]}],\"parallel_tool_calls\":true,\"previous_response_id\":\"resp_67eafd5f2ef0819290ec6bbbc5f27c8e0aa8698ee903b906\",\"reasoning\":{\"effort\":null,\"generate_summary\":null},\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"}},\"tool_choice\":\"auto\",\"tools\":[],\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":56,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":11,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":67},\"user\":null,\"metadata\":{}}}\n\n  recorded_at: Mon, 31 Mar 2025 20:38:58 GMT\nrecorded_with: VCR 6.3.1\n...\n"
  },
  {
    "path": "test/vcr_cassettes/plaid/access_token.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://sandbox.plaid.com/sandbox/public_token/create\n    body:\n      encoding: UTF-8\n      string: '{\"institution_id\":\"ins_109508\",\"initial_products\":[\"transactions\",\"investments\",\"liabilities\"],\"options\":{\"override_username\":\"custom_test\"}}'\n    headers:\n      Content-Type:\n      - application/json\n      User-Agent:\n      - Plaid Ruby v38.0.0\n      Accept:\n      - application/json\n      Plaid-Client-Id:\n      - \"<PLAID_CLIENT_ID>\"\n      Plaid-Version:\n      - '2020-09-14'\n      Plaid-Secret:\n      - \"<PLAID_SECRET>\"\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 19 May 2025 17:24:03 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Content-Length:\n      - '110'\n      Connection:\n      - keep-alive\n      Plaid-Version:\n      - '2020-09-14'\n      Vary:\n      - Accept-Encoding\n      X-Envoy-Upstream-Service-Time:\n      - '2892'\n      X-Envoy-Decorator-Operation:\n      - default.svc-apiv2:8080/*\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - DENY\n      X-Xss-Protection:\n      - 1; mode=block\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"public_token\": \"public-sandbox-0463cb9d-8bdb-4e01-9b33-243e1370623c\",\n          \"request_id\": \"FaSopSLAyHsZM9O\"\n        }\n  recorded_at: Mon, 19 May 2025 17:24:03 GMT\n- request:\n    method: post\n    uri: https://sandbox.plaid.com/item/public_token/exchange\n    body:\n      encoding: UTF-8\n      string: '{\"public_token\":\"public-sandbox-0463cb9d-8bdb-4e01-9b33-243e1370623c\"}'\n    headers:\n      Content-Type:\n      - application/json\n      User-Agent:\n      - Plaid Ruby v38.0.0\n      Accept:\n      - application/json\n      Plaid-Client-Id:\n      - \"<PLAID_CLIENT_ID>\"\n      Plaid-Version:\n      - '2020-09-14'\n      Plaid-Secret:\n      - \"<PLAID_SECRET>\"\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 19 May 2025 17:24:03 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Content-Length:\n      - '164'\n      Connection:\n      - keep-alive\n      Plaid-Version:\n      - '2020-09-14'\n      Vary:\n      - Accept-Encoding\n      X-Envoy-Upstream-Service-Time:\n      - '171'\n      X-Envoy-Decorator-Operation:\n      - default.svc-apiv2:8080/*\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - DENY\n      X-Xss-Protection:\n      - 1; mode=block\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"access_token\": \"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648\",\n          \"item_id\": \"n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP\",\n          \"request_id\": \"2e1nOnm2CoOXVcH\"\n        }\n  recorded_at: Mon, 19 May 2025 17:24:03 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/plaid/exchange_public_token.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://sandbox.plaid.com/sandbox/public_token/create\n    body:\n      encoding: UTF-8\n      string: '{\"institution_id\":\"ins_109508\",\"initial_products\":[\"transactions\",\"investments\",\"liabilities\"],\"options\":{\"override_username\":\"custom_test\"}}'\n    headers:\n      Content-Type:\n      - application/json\n      User-Agent:\n      - Plaid Ruby v38.0.0\n      Accept:\n      - application/json\n      Plaid-Client-Id:\n      - \"<PLAID_CLIENT_ID>\"\n      Plaid-Version:\n      - '2020-09-14'\n      Plaid-Secret:\n      - \"<PLAID_SECRET>\"\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 19 May 2025 17:24:09 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Content-Length:\n      - '110'\n      Connection:\n      - keep-alive\n      Plaid-Version:\n      - '2020-09-14'\n      Vary:\n      - Accept-Encoding\n      X-Envoy-Upstream-Service-Time:\n      - '3086'\n      X-Envoy-Decorator-Operation:\n      - default.svc-apiv2:8080/*\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - DENY\n      X-Xss-Protection:\n      - 1; mode=block\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"public_token\": \"public-sandbox-29a5644f-001d-4bf5-abae-d26ecf8ee211\",\n          \"request_id\": \"6dz2Xo7zoyT9W9R\"\n        }\n  recorded_at: Mon, 19 May 2025 17:24:09 GMT\n- request:\n    method: post\n    uri: https://sandbox.plaid.com/item/public_token/exchange\n    body:\n      encoding: UTF-8\n      string: '{\"public_token\":\"public-sandbox-29a5644f-001d-4bf5-abae-d26ecf8ee211\"}'\n    headers:\n      Content-Type:\n      - application/json\n      User-Agent:\n      - Plaid Ruby v38.0.0\n      Accept:\n      - application/json\n      Plaid-Client-Id:\n      - \"<PLAID_CLIENT_ID>\"\n      Plaid-Version:\n      - '2020-09-14'\n      Plaid-Secret:\n      - \"<PLAID_SECRET>\"\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 19 May 2025 17:24:09 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Content-Length:\n      - '164'\n      Connection:\n      - keep-alive\n      Plaid-Version:\n      - '2020-09-14'\n      Vary:\n      - Accept-Encoding\n      X-Envoy-Upstream-Service-Time:\n      - '152'\n      X-Envoy-Decorator-Operation:\n      - default.svc-apiv2:8080/*\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - DENY\n      X-Xss-Protection:\n      - 1; mode=block\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"access_token\": \"access-sandbox-fb7bb5da-e3e2-464e-8644-4eeafbf6541f\",\n          \"item_id\": \"bd9d3lAbjqhWyRz7bl61s9R7npPJ87HVzAyvn\",\n          \"request_id\": \"GqA99rziFZduKYg\"\n        }\n  recorded_at: Mon, 19 May 2025 17:24:09 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/plaid/get_item.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://sandbox.plaid.com/item/get\n    body:\n      encoding: UTF-8\n      string: '{\"access_token\":\"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648\"}'\n    headers:\n      Content-Type:\n      - application/json\n      User-Agent:\n      - Plaid Ruby v38.0.0\n      Accept:\n      - application/json\n      Plaid-Client-Id:\n      - \"<PLAID_CLIENT_ID>\"\n      Plaid-Version:\n      - '2020-09-14'\n      Plaid-Secret:\n      - \"<PLAID_SECRET>\"\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 19 May 2025 17:24:03 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Content-Length:\n      - '1050'\n      Connection:\n      - keep-alive\n      Plaid-Version:\n      - '2020-09-14'\n      Vary:\n      - Accept-Encoding\n      X-Envoy-Upstream-Service-Time:\n      - '157'\n      X-Envoy-Decorator-Operation:\n      - default.svc-apiv2:8080/*\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - DENY\n      X-Xss-Protection:\n      - 1; mode=block\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"item\": {\n            \"available_products\": [\n              \"assets\",\n              \"auth\",\n              \"balance\",\n              \"credit_details\",\n              \"identity\",\n              \"identity_match\",\n              \"income\",\n              \"income_verification\",\n              \"recurring_transactions\",\n              \"signal\",\n              \"statements\"\n            ],\n            \"billed_products\": [\n              \"investments\",\n              \"liabilities\",\n              \"transactions\"\n            ],\n            \"consent_expiration_time\": null,\n            \"created_at\": \"2025-05-19T17:24:00Z\",\n            \"error\": null,\n            \"institution_id\": \"ins_109508\",\n            \"institution_name\": \"First Platypus Bank\",\n            \"item_id\": \"n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP\",\n            \"products\": [\n              \"investments\",\n              \"liabilities\",\n              \"transactions\"\n            ],\n            \"update_type\": \"background\",\n            \"webhook\": \"\"\n          },\n          \"request_id\": \"dpcY8geAZ93oxJm\",\n          \"status\": {\n            \"investments\": {\n              \"last_failed_update\": null,\n              \"last_successful_update\": \"2025-05-19T17:24:01.861Z\"\n            },\n            \"last_webhook\": null,\n            \"transactions\": {\n              \"last_failed_update\": null,\n              \"last_successful_update\": null\n            }\n          }\n        }\n  recorded_at: Mon, 19 May 2025 17:24:03 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/plaid/get_item_accounts.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://sandbox.plaid.com/accounts/get\n    body:\n      encoding: UTF-8\n      string: '{\"access_token\":\"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648\"}'\n    headers:\n      Content-Type:\n      - application/json\n      User-Agent:\n      - Plaid Ruby v38.0.0\n      Accept:\n      - application/json\n      Plaid-Client-Id:\n      - \"<PLAID_CLIENT_ID>\"\n      Plaid-Version:\n      - '2020-09-14'\n      Plaid-Secret:\n      - \"<PLAID_SECRET>\"\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 19 May 2025 17:24:04 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Content-Length:\n      - '2578'\n      Connection:\n      - keep-alive\n      Plaid-Version:\n      - '2020-09-14'\n      Vary:\n      - Accept-Encoding\n      X-Envoy-Upstream-Service-Time:\n      - '191'\n      X-Envoy-Decorator-Operation:\n      - default.svc-apiv2:8080/*\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - DENY\n      X-Xss-Protection:\n      - 1; mode=block\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"accounts\": [\n            {\n              \"account_id\": \"vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe\",\n              \"balances\": {\n                \"available\": 8000,\n                \"current\": 10000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": null,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"1122\",\n              \"name\": \"Test Brokerage Account\",\n              \"official_name\": \"Plaid brokerage\",\n              \"subtype\": \"brokerage\",\n              \"type\": \"investment\"\n            },\n            {\n              \"account_id\": \"RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp\",\n              \"balances\": {\n                \"available\": 9372.38,\n                \"current\": 1000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": 10500,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"1219\",\n              \"name\": \"Test Credit Card Account\",\n              \"official_name\": \"Plaid credit card\",\n              \"subtype\": \"credit card\",\n              \"type\": \"credit\"\n            },\n            {\n              \"account_id\": \"9mvxVZRW7LUD67QbEBm1CPZ6XlqkmkF4oGNBo\",\n              \"balances\": {\n                \"available\": 10000,\n                \"current\": 10000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": null,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"4243\",\n              \"name\": \"Test Depository Account\",\n              \"official_name\": \"Plaid checking\",\n              \"subtype\": \"checking\",\n              \"type\": \"depository\"\n            },\n            {\n              \"account_id\": \"6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7\",\n              \"balances\": {\n                \"available\": 15000,\n                \"current\": 15000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": null,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"9572\",\n              \"name\": \"Test Student Loan Account\",\n              \"official_name\": \"Plaid student\",\n              \"subtype\": \"student\",\n              \"type\": \"loan\"\n            }\n          ],\n          \"item\": {\n            \"available_products\": [\n              \"assets\",\n              \"auth\",\n              \"balance\",\n              \"credit_details\",\n              \"identity\",\n              \"identity_match\",\n              \"income\",\n              \"income_verification\",\n              \"recurring_transactions\",\n              \"signal\",\n              \"statements\"\n            ],\n            \"billed_products\": [\n              \"investments\",\n              \"liabilities\",\n              \"transactions\"\n            ],\n            \"consent_expiration_time\": null,\n            \"error\": null,\n            \"institution_id\": \"ins_109508\",\n            \"institution_name\": \"First Platypus Bank\",\n            \"item_id\": \"n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP\",\n            \"products\": [\n              \"investments\",\n              \"liabilities\",\n              \"transactions\"\n            ],\n            \"update_type\": \"background\",\n            \"webhook\": \"\"\n          },\n          \"request_id\": \"EWD5MMMYV0o9cZ0\"\n        }\n  recorded_at: Mon, 19 May 2025 17:24:04 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/plaid/get_item_investments.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://sandbox.plaid.com/investments/holdings/get\n    body:\n      encoding: UTF-8\n      string: '{\"access_token\":\"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648\"}'\n    headers:\n      Content-Type:\n      - application/json\n      User-Agent:\n      - Plaid Ruby v38.0.0\n      Accept:\n      - application/json\n      Plaid-Client-Id:\n      - \"<PLAID_CLIENT_ID>\"\n      Plaid-Version:\n      - '2020-09-14'\n      Plaid-Secret:\n      - \"<PLAID_SECRET>\"\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 19 May 2025 17:24:05 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Content-Length:\n      - '6199'\n      Connection:\n      - keep-alive\n      Plaid-Version:\n      - '2020-09-14'\n      Vary:\n      - Accept-Encoding\n      X-Envoy-Upstream-Service-Time:\n      - '324'\n      X-Envoy-Decorator-Operation:\n      - default.svc-apiv2:8080/*\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - DENY\n      X-Xss-Protection:\n      - 1; mode=block\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"accounts\": [\n            {\n              \"account_id\": \"vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe\",\n              \"balances\": {\n                \"available\": 8000,\n                \"current\": 10000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": null,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"1122\",\n              \"name\": \"Test Brokerage Account\",\n              \"official_name\": \"Plaid brokerage\",\n              \"subtype\": \"brokerage\",\n              \"type\": \"investment\"\n            },\n            {\n              \"account_id\": \"RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp\",\n              \"balances\": {\n                \"available\": 9372.38,\n                \"current\": 1000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": 10500,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"1219\",\n              \"name\": \"Test Credit Card Account\",\n              \"official_name\": \"Plaid credit card\",\n              \"subtype\": \"credit card\",\n              \"type\": \"credit\"\n            },\n            {\n              \"account_id\": \"9mvxVZRW7LUD67QbEBm1CPZ6XlqkmkF4oGNBo\",\n              \"balances\": {\n                \"available\": 10000,\n                \"current\": 10000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": null,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"4243\",\n              \"name\": \"Test Depository Account\",\n              \"official_name\": \"Plaid checking\",\n              \"subtype\": \"checking\",\n              \"type\": \"depository\"\n            },\n            {\n              \"account_id\": \"6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7\",\n              \"balances\": {\n                \"available\": 15000,\n                \"current\": 15000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": null,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"9572\",\n              \"name\": \"Test Student Loan Account\",\n              \"official_name\": \"Plaid student\",\n              \"subtype\": \"student\",\n              \"type\": \"loan\"\n            }\n          ],\n          \"holdings\": [\n            {\n              \"account_id\": \"vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe\",\n              \"cost_basis\": 2000,\n              \"institution_price\": 100,\n              \"institution_price_as_of\": \"2025-05-08\",\n              \"institution_price_datetime\": null,\n              \"institution_value\": 2000,\n              \"iso_currency_code\": \"USD\",\n              \"quantity\": 20,\n              \"security_id\": \"xnL3QM3Ax4fP9lVmNLblTDaMkVMqN3fmxrXRd\",\n              \"unofficial_currency_code\": null,\n              \"vested_quantity\": null,\n              \"vested_value\": null\n            },\n            {\n              \"account_id\": \"vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe\",\n              \"cost_basis\": 3000,\n              \"institution_price\": 1,\n              \"institution_price_as_of\": \"2025-05-08\",\n              \"institution_price_datetime\": null,\n              \"institution_value\": 3000,\n              \"iso_currency_code\": \"USD\",\n              \"quantity\": 3000,\n              \"security_id\": \"EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX\",\n              \"unofficial_currency_code\": null,\n              \"vested_quantity\": null,\n              \"vested_value\": null\n            },\n            {\n              \"account_id\": \"vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe\",\n              \"cost_basis\": 5000,\n              \"institution_price\": 1,\n              \"institution_price_as_of\": \"2025-05-08\",\n              \"institution_price_datetime\": null,\n              \"institution_value\": 5000,\n              \"iso_currency_code\": \"USD\",\n              \"quantity\": 5000,\n              \"security_id\": \"7Dv19k16PZtEaexk6EZyFxP95o9ynrF4REalG\",\n              \"unofficial_currency_code\": null,\n              \"vested_quantity\": null,\n              \"vested_value\": null\n            }\n          ],\n          \"item\": {\n            \"available_products\": [\n              \"assets\",\n              \"auth\",\n              \"balance\",\n              \"credit_details\",\n              \"identity\",\n              \"identity_match\",\n              \"income\",\n              \"income_verification\",\n              \"recurring_transactions\",\n              \"signal\",\n              \"statements\"\n            ],\n            \"billed_products\": [\n              \"investments\",\n              \"liabilities\",\n              \"transactions\"\n            ],\n            \"consent_expiration_time\": null,\n            \"error\": null,\n            \"institution_id\": \"ins_109508\",\n            \"institution_name\": \"First Platypus Bank\",\n            \"item_id\": \"n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP\",\n            \"products\": [\n              \"investments\",\n              \"liabilities\",\n              \"transactions\"\n            ],\n            \"update_type\": \"background\",\n            \"webhook\": \"\"\n          },\n          \"request_id\": \"uRzq5c4Y37RCNNj\",\n          \"securities\": [\n            {\n              \"close_price\": 1,\n              \"close_price_as_of\": \"2025-04-28\",\n              \"cusip\": null,\n              \"fixed_income\": null,\n              \"industry\": \"Investment Trusts or Mutual Funds\",\n              \"institution_id\": null,\n              \"institution_security_id\": null,\n              \"is_cash_equivalent\": true,\n              \"isin\": null,\n              \"iso_currency_code\": \"USD\",\n              \"market_identifier_code\": null,\n              \"name\": \"Vanguard Money Market Reserves - Federal Money Market Fd USD MNT\",\n              \"option_contract\": null,\n              \"proxy_security_id\": null,\n              \"sector\": \"Miscellaneous\",\n              \"security_id\": \"7Dv19k16PZtEaexk6EZyFxP95o9ynrF4REalG\",\n              \"sedol\": \"2571678\",\n              \"ticker_symbol\": \"VMFXX\",\n              \"type\": \"mutual fund\",\n              \"unofficial_currency_code\": null,\n              \"update_datetime\": null\n            },\n            {\n              \"close_price\": 1,\n              \"close_price_as_of\": \"2025-05-18\",\n              \"cusip\": null,\n              \"fixed_income\": null,\n              \"industry\": null,\n              \"institution_id\": null,\n              \"institution_security_id\": null,\n              \"is_cash_equivalent\": true,\n              \"isin\": null,\n              \"iso_currency_code\": \"USD\",\n              \"market_identifier_code\": null,\n              \"name\": \"U S Dollar\",\n              \"option_contract\": null,\n              \"proxy_security_id\": null,\n              \"sector\": null,\n              \"security_id\": \"EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX\",\n              \"sedol\": null,\n              \"ticker_symbol\": \"CUR:USD\",\n              \"type\": \"cash\",\n              \"unofficial_currency_code\": null,\n              \"update_datetime\": null\n            },\n            {\n              \"close_price\": 211.26,\n              \"close_price_as_of\": \"2025-05-16\",\n              \"cusip\": null,\n              \"fixed_income\": null,\n              \"industry\": \"Telecommunications Equipment\",\n              \"institution_id\": null,\n              \"institution_security_id\": null,\n              \"is_cash_equivalent\": false,\n              \"isin\": null,\n              \"iso_currency_code\": \"USD\",\n              \"market_identifier_code\": \"XNAS\",\n              \"name\": \"Apple Inc\",\n              \"option_contract\": null,\n              \"proxy_security_id\": null,\n              \"sector\": \"Electronic Technology\",\n              \"security_id\": \"xnL3QM3Ax4fP9lVmNLblTDaMkVMqN3fmxrXRd\",\n              \"sedol\": \"2046251\",\n              \"ticker_symbol\": \"AAPL\",\n              \"type\": \"equity\",\n              \"unofficial_currency_code\": null,\n              \"update_datetime\": null\n            }\n          ]\n        }\n  recorded_at: Mon, 19 May 2025 17:24:05 GMT\n- request:\n    method: post\n    uri: https://sandbox.plaid.com/investments/transactions/get\n    body:\n      encoding: UTF-8\n      string: '{\"access_token\":\"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648\",\"start_date\":\"2023-05-20\",\"end_date\":\"2025-05-19\",\"options\":{\"offset\":0}}'\n    headers:\n      Content-Type:\n      - application/json\n      User-Agent:\n      - Plaid Ruby v38.0.0\n      Accept:\n      - application/json\n      Plaid-Client-Id:\n      - \"<PLAID_CLIENT_ID>\"\n      Plaid-Version:\n      - '2020-09-14'\n      Plaid-Secret:\n      - \"<PLAID_SECRET>\"\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 19 May 2025 17:24:05 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Content-Length:\n      - '6964'\n      Connection:\n      - keep-alive\n      Plaid-Version:\n      - '2020-09-14'\n      Vary:\n      - Accept-Encoding\n      X-Envoy-Upstream-Service-Time:\n      - '334'\n      X-Envoy-Decorator-Operation:\n      - default.svc-apiv2:8080/*\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - DENY\n      X-Xss-Protection:\n      - 1; mode=block\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"accounts\": [\n            {\n              \"account_id\": \"vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe\",\n              \"balances\": {\n                \"available\": 8000,\n                \"current\": 10000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": null,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"1122\",\n              \"name\": \"Test Brokerage Account\",\n              \"official_name\": \"Plaid brokerage\",\n              \"subtype\": \"brokerage\",\n              \"type\": \"investment\"\n            },\n            {\n              \"account_id\": \"RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp\",\n              \"balances\": {\n                \"available\": 9372.38,\n                \"current\": 1000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": 10500,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"1219\",\n              \"name\": \"Test Credit Card Account\",\n              \"official_name\": \"Plaid credit card\",\n              \"subtype\": \"credit card\",\n              \"type\": \"credit\"\n            },\n            {\n              \"account_id\": \"9mvxVZRW7LUD67QbEBm1CPZ6XlqkmkF4oGNBo\",\n              \"balances\": {\n                \"available\": 10000,\n                \"current\": 10000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": null,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"4243\",\n              \"name\": \"Test Depository Account\",\n              \"official_name\": \"Plaid checking\",\n              \"subtype\": \"checking\",\n              \"type\": \"depository\"\n            },\n            {\n              \"account_id\": \"6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7\",\n              \"balances\": {\n                \"available\": 15000,\n                \"current\": 15000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": null,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"9572\",\n              \"name\": \"Test Student Loan Account\",\n              \"official_name\": \"Plaid student\",\n              \"subtype\": \"student\",\n              \"type\": \"loan\"\n            }\n          ],\n          \"investment_transactions\": [\n            {\n              \"account_id\": \"vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe\",\n              \"amount\": -5000,\n              \"cancel_transaction_id\": null,\n              \"date\": \"2025-05-03\",\n              \"fees\": 0,\n              \"investment_transaction_id\": \"eBqoazM4XkiXx5gZbmD7UKRZ3jE3ABUreq4R1\",\n              \"iso_currency_code\": \"USD\",\n              \"name\": \"retirement contribution\",\n              \"price\": 1,\n              \"quantity\": -5000,\n              \"security_id\": \"EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX\",\n              \"subtype\": \"contribution\",\n              \"type\": \"cash\",\n              \"unofficial_currency_code\": null\n            },\n            {\n              \"account_id\": \"vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe\",\n              \"amount\": 5000,\n              \"cancel_transaction_id\": null,\n              \"date\": \"2025-05-03\",\n              \"fees\": 0,\n              \"investment_transaction_id\": \"QLeKVkpQM4ck1qMRGp6PUPp7obKowGtwRN547\",\n              \"iso_currency_code\": \"USD\",\n              \"name\": \"buy money market shares with contribution cash\",\n              \"price\": 1,\n              \"quantity\": 5000,\n              \"security_id\": \"7Dv19k16PZtEaexk6EZyFxP95o9ynrF4REalG\",\n              \"subtype\": \"contribution\",\n              \"type\": \"buy\",\n              \"unofficial_currency_code\": null\n            },\n            {\n              \"account_id\": \"vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe\",\n              \"amount\": 2000,\n              \"cancel_transaction_id\": null,\n              \"date\": \"2025-05-02\",\n              \"fees\": 0,\n              \"investment_transaction_id\": \"ZnxNgJEwM1ig5476JqZxUKeJLXNLnMUe9o6Al\",\n              \"iso_currency_code\": \"USD\",\n              \"name\": \"buy AAPL stock\",\n              \"price\": 100,\n              \"quantity\": 20,\n              \"security_id\": \"xnL3QM3Ax4fP9lVmNLblTDaMkVMqN3fmxrXRd\",\n              \"subtype\": \"buy\",\n              \"type\": \"buy\",\n              \"unofficial_currency_code\": null\n            },\n            {\n              \"account_id\": \"vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe\",\n              \"amount\": -5000,\n              \"cancel_transaction_id\": null,\n              \"date\": \"2025-05-01\",\n              \"fees\": 0,\n              \"investment_transaction_id\": \"MQ1Awmg943IKyWlQjRXgUqXrxD6xo3CLGjJw1\",\n              \"iso_currency_code\": \"USD\",\n              \"name\": \"Deposit cash into brokerage account\",\n              \"price\": 1,\n              \"quantity\": -5000,\n              \"security_id\": \"EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX\",\n              \"subtype\": \"deposit\",\n              \"type\": \"cash\",\n              \"unofficial_currency_code\": null\n            }\n          ],\n          \"item\": {\n            \"available_products\": [\n              \"assets\",\n              \"auth\",\n              \"balance\",\n              \"credit_details\",\n              \"identity\",\n              \"identity_match\",\n              \"income\",\n              \"income_verification\",\n              \"recurring_transactions\",\n              \"signal\",\n              \"statements\"\n            ],\n            \"billed_products\": [\n              \"investments\",\n              \"liabilities\",\n              \"transactions\"\n            ],\n            \"consent_expiration_time\": null,\n            \"error\": null,\n            \"institution_id\": \"ins_109508\",\n            \"institution_name\": \"First Platypus Bank\",\n            \"item_id\": \"n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP\",\n            \"products\": [\n              \"investments\",\n              \"liabilities\",\n              \"transactions\"\n            ],\n            \"update_type\": \"background\",\n            \"webhook\": \"\"\n          },\n          \"request_id\": \"dTc49uKiBZWzxHS\",\n          \"securities\": [\n            {\n              \"close_price\": 1,\n              \"close_price_as_of\": \"2025-04-28\",\n              \"cusip\": null,\n              \"fixed_income\": null,\n              \"industry\": \"Investment Trusts or Mutual Funds\",\n              \"institution_id\": null,\n              \"institution_security_id\": null,\n              \"is_cash_equivalent\": true,\n              \"isin\": null,\n              \"iso_currency_code\": \"USD\",\n              \"market_identifier_code\": null,\n              \"name\": \"Vanguard Money Market Reserves - Federal Money Market Fd USD MNT\",\n              \"option_contract\": null,\n              \"proxy_security_id\": null,\n              \"sector\": \"Miscellaneous\",\n              \"security_id\": \"7Dv19k16PZtEaexk6EZyFxP95o9ynrF4REalG\",\n              \"sedol\": \"2571678\",\n              \"ticker_symbol\": \"VMFXX\",\n              \"type\": \"mutual fund\",\n              \"unofficial_currency_code\": null,\n              \"update_datetime\": null\n            },\n            {\n              \"close_price\": 1,\n              \"close_price_as_of\": \"2025-05-18\",\n              \"cusip\": null,\n              \"fixed_income\": null,\n              \"industry\": null,\n              \"institution_id\": null,\n              \"institution_security_id\": null,\n              \"is_cash_equivalent\": true,\n              \"isin\": null,\n              \"iso_currency_code\": \"USD\",\n              \"market_identifier_code\": null,\n              \"name\": \"U S Dollar\",\n              \"option_contract\": null,\n              \"proxy_security_id\": null,\n              \"sector\": null,\n              \"security_id\": \"EDRxkXxj7mtj8EzBBxllUqpy7KyNDBugoZrMX\",\n              \"sedol\": null,\n              \"ticker_symbol\": \"CUR:USD\",\n              \"type\": \"cash\",\n              \"unofficial_currency_code\": null,\n              \"update_datetime\": null\n            },\n            {\n              \"close_price\": 211.26,\n              \"close_price_as_of\": \"2025-05-16\",\n              \"cusip\": null,\n              \"fixed_income\": null,\n              \"industry\": \"Telecommunications Equipment\",\n              \"institution_id\": null,\n              \"institution_security_id\": null,\n              \"is_cash_equivalent\": false,\n              \"isin\": null,\n              \"iso_currency_code\": \"USD\",\n              \"market_identifier_code\": \"XNAS\",\n              \"name\": \"Apple Inc\",\n              \"option_contract\": null,\n              \"proxy_security_id\": null,\n              \"sector\": \"Electronic Technology\",\n              \"security_id\": \"xnL3QM3Ax4fP9lVmNLblTDaMkVMqN3fmxrXRd\",\n              \"sedol\": \"2046251\",\n              \"ticker_symbol\": \"AAPL\",\n              \"type\": \"equity\",\n              \"unofficial_currency_code\": null,\n              \"update_datetime\": null\n            }\n          ],\n          \"total_investment_transactions\": 4\n        }\n  recorded_at: Mon, 19 May 2025 17:24:05 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/plaid/get_item_liabilities.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://sandbox.plaid.com/liabilities/get\n    body:\n      encoding: UTF-8\n      string: '{\"access_token\":\"access-sandbox-0af2c971-41a6-4fc5-a97d-f2b27ab0a648\"}'\n    headers:\n      Content-Type:\n      - application/json\n      User-Agent:\n      - Plaid Ruby v38.0.0\n      Accept:\n      - application/json\n      Plaid-Client-Id:\n      - \"<PLAID_CLIENT_ID>\"\n      Plaid-Version:\n      - '2020-09-14'\n      Plaid-Secret:\n      - \"<PLAID_SECRET>\"\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 19 May 2025 17:24:04 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Content-Length:\n      - '4907'\n      Connection:\n      - keep-alive\n      Plaid-Version:\n      - '2020-09-14'\n      Vary:\n      - Accept-Encoding\n      X-Envoy-Upstream-Service-Time:\n      - '253'\n      X-Envoy-Decorator-Operation:\n      - default.svc-apiv2:8080/*\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - DENY\n      X-Xss-Protection:\n      - 1; mode=block\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"accounts\": [\n            {\n              \"account_id\": \"vor45kgbDjfqa1BMD8QRU4om8adWNWtqbzQJe\",\n              \"balances\": {\n                \"available\": 8000,\n                \"current\": 10000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": null,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"1122\",\n              \"name\": \"Test Brokerage Account\",\n              \"official_name\": \"Plaid brokerage\",\n              \"subtype\": \"brokerage\",\n              \"type\": \"investment\"\n            },\n            {\n              \"account_id\": \"RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp\",\n              \"balances\": {\n                \"available\": 9372.38,\n                \"current\": 1000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": 10500,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"1219\",\n              \"name\": \"Test Credit Card Account\",\n              \"official_name\": \"Plaid credit card\",\n              \"subtype\": \"credit card\",\n              \"type\": \"credit\"\n            },\n            {\n              \"account_id\": \"9mvxVZRW7LUD67QbEBm1CPZ6XlqkmkF4oGNBo\",\n              \"balances\": {\n                \"available\": 10000,\n                \"current\": 10000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": null,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"4243\",\n              \"name\": \"Test Depository Account\",\n              \"official_name\": \"Plaid checking\",\n              \"subtype\": \"checking\",\n              \"type\": \"depository\"\n            },\n            {\n              \"account_id\": \"6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7\",\n              \"balances\": {\n                \"available\": 15000,\n                \"current\": 15000,\n                \"iso_currency_code\": \"USD\",\n                \"limit\": null,\n                \"unofficial_currency_code\": null\n              },\n              \"holder_category\": \"personal\",\n              \"mask\": \"9572\",\n              \"name\": \"Test Student Loan Account\",\n              \"official_name\": \"Plaid student\",\n              \"subtype\": \"student\",\n              \"type\": \"loan\"\n            }\n          ],\n          \"item\": {\n            \"available_products\": [\n              \"assets\",\n              \"auth\",\n              \"balance\",\n              \"credit_details\",\n              \"identity\",\n              \"identity_match\",\n              \"income\",\n              \"income_verification\",\n              \"recurring_transactions\",\n              \"signal\",\n              \"statements\"\n            ],\n            \"billed_products\": [\n              \"investments\",\n              \"liabilities\",\n              \"transactions\"\n            ],\n            \"consent_expiration_time\": null,\n            \"error\": null,\n            \"institution_id\": \"ins_109508\",\n            \"institution_name\": \"First Platypus Bank\",\n            \"item_id\": \"n7XKpjRmDkHENymaBw7rU71wxQnrW4i6DDrQP\",\n            \"products\": [\n              \"investments\",\n              \"liabilities\",\n              \"transactions\"\n            ],\n            \"update_type\": \"background\",\n            \"webhook\": \"\"\n          },\n          \"liabilities\": {\n            \"credit\": [\n              {\n                \"account_id\": \"RperV9wJMNiDWKljGMkPCkwDGJb7q7FaNlVMp\",\n                \"aprs\": [\n                  {\n                    \"apr_percentage\": 12.5,\n                    \"apr_type\": \"purchase_apr\",\n                    \"balance_subject_to_apr\": null,\n                    \"interest_charge_amount\": null\n                  },\n                  {\n                    \"apr_percentage\": 27.95,\n                    \"apr_type\": \"cash_apr\",\n                    \"balance_subject_to_apr\": null,\n                    \"interest_charge_amount\": null\n                  }\n                ],\n                \"is_overdue\": false,\n                \"last_payment_amount\": null,\n                \"last_payment_date\": \"2025-04-24\",\n                \"last_statement_balance\": 1000,\n                \"last_statement_issue_date\": \"2025-05-19\",\n                \"minimum_payment_amount\": 50,\n                \"next_payment_due_date\": \"2025-06-19\"\n              }\n            ],\n            \"mortgage\": null,\n            \"student\": [\n              {\n                \"account_id\": \"6Gwzm7nJ6ZU4VbqEyKzZszyPQ8keRet8Q97k7\",\n                \"account_number\": \"3117529572\",\n                \"disbursement_dates\": [\n                  \"2023-05-01\"\n                ],\n                \"expected_payoff_date\": \"2036-05-01\",\n                \"guarantor\": \"DEPT OF ED\",\n                \"interest_rate_percentage\": 5.25,\n                \"is_overdue\": false,\n                \"last_payment_amount\": null,\n                \"last_payment_date\": null,\n                \"last_statement_balance\": 16577.16,\n                \"last_statement_issue_date\": \"2025-05-01\",\n                \"loan_name\": \"Consolidation\",\n                \"loan_status\": {\n                  \"end_date\": null,\n                  \"type\": \"in school\"\n                },\n                \"minimum_payment_amount\": 25,\n                \"next_payment_due_date\": \"2025-06-01\",\n                \"origination_date\": \"2023-05-01\",\n                \"origination_principal_amount\": 15000,\n                \"outstanding_interest_amount\": 1577.16,\n                \"payment_reference_number\": \"3117529572\",\n                \"pslf_status\": {\n                  \"estimated_eligibility_date\": null,\n                  \"payments_made\": null,\n                  \"payments_remaining\": null\n                },\n                \"repayment_plan\": {\n                  \"description\": \"Standard Repayment\",\n                  \"type\": \"standard\"\n                },\n                \"sequence_number\": \"1\",\n                \"servicer_address\": {\n                  \"city\": \"San Matias\",\n                  \"country\": \"US\",\n                  \"postal_code\": \"99415\",\n                  \"region\": \"CA\",\n                  \"street\": \"123 Relaxation Road\"\n                },\n                \"ytd_interest_paid\": 0,\n                \"ytd_principal_paid\": 0\n              }\n            ]\n          },\n          \"request_id\": \"nFlL291sKIy1LkJ\"\n        }\n  recorded_at: Mon, 19 May 2025 17:24:04 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/plaid/link_token.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://sandbox.plaid.com/link/token/create\n    body:\n      encoding: UTF-8\n      string: '{\"client_name\":\"Maybe Finance\",\"language\":\"en\",\"country_codes\":[\"US\",\"CA\"],\"user\":{\"client_user_id\":\"test-user-id\"},\"products\":[\"transactions\"],\"additional_consented_products\":[\"investments\",\"liabilities\"],\"webhook\":\"https://example.com/webhooks\",\"redirect_uri\":\"http://localhost:3000/accounts\",\"transactions\":{\"days_requested\":730}}'\n    headers:\n      Content-Type:\n      - application/json\n      User-Agent:\n      - Plaid Ruby v38.0.0\n      Accept:\n      - application/json\n      Plaid-Client-Id:\n      - \"<PLAID_CLIENT_ID>\"\n      Plaid-Version:\n      - '2020-09-14'\n      Plaid-Secret:\n      - \"<PLAID_SECRET>\"\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 19 May 2025 17:24:04 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Content-Length:\n      - '146'\n      Connection:\n      - keep-alive\n      Plaid-Version:\n      - '2020-09-14'\n      Vary:\n      - Accept-Encoding\n      X-Envoy-Upstream-Service-Time:\n      - '70'\n      X-Envoy-Decorator-Operation:\n      - default.svc-apiv2:8080/*\n      Strict-Transport-Security:\n      - max-age=31536000; includeSubDomains; preload\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - DENY\n      X-Xss-Protection:\n      - 1; mode=block\n    body:\n      encoding: ASCII-8BIT\n      string: |-\n        {\n          \"expiration\": \"2025-05-19T21:24:04Z\",\n          \"link_token\": \"link-sandbox-33432e02-32e2-415d-8f00-e626c6f4c6a6\",\n          \"request_id\": \"Gys5pGY7tIPDrlL\"\n        }\n  recorded_at: Mon, 19 May 2025 17:24:04 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/stripe/checkout_session.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: get\n    uri: https://api.stripe.com/v1/checkout/sessions/cs_test_b1RD8r6DAkSA8vrQ3grBC2QVgR5zUJ7QQFuVHZkcKoSYaEOQgCMPMOCOM5\n    body:\n      encoding: US-ASCII\n      string: ''\n    headers:\n      User-Agent:\n      - Stripe/v1 RubyBindings/15.1.0\n      Authorization:\n      - Bearer <STRIPE_SECRET_KEY>\n      Stripe-Version:\n      - 2025-04-30.basil\n      X-Stripe-Client-User-Agent:\n      - '{\"bindings_version\":\"15.1.0\",\"lang\":\"ruby\",\"lang_version\":\"3.4.1 p0 (2024-12-25)\",\"platform\":\"arm64-darwin24\",\"engine\":\"ruby\",\"publisher\":\"stripe\",\"uname\":\"Darwin\n        Zachs-MacBook-Pro.local 24.3.0 Darwin Kernel Version 24.3.0: Thu Jan  2 20:24:16\n        PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6000 arm64\",\"hostname\":\"Zachs-MacBook-Pro.local\"}'\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 05 May 2025 16:09:23 GMT\n      Content-Type:\n      - application/json\n      Content-Length:\n      - '2667'\n      Connection:\n      - keep-alive\n      Access-Control-Allow-Credentials:\n      - 'true'\n      Access-Control-Allow-Methods:\n      - GET, HEAD, PUT, PATCH, POST, DELETE\n      Access-Control-Allow-Origin:\n      - \"*\"\n      Access-Control-Expose-Headers:\n      - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required,\n        X-Stripe-Privileged-Session-Required\n      Access-Control-Max-Age:\n      - '300'\n      Cache-Control:\n      - no-cache, no-store\n      Content-Security-Policy:\n      - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none';\n        img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src\n        'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=7L6NHIm4wk05H5wi0PfH951BH62utb5j2ZImtzEXvcfJgdc1v5juGoNb0oSAXIHhGQtWiGOiCmz3UG1W\n      Request-Id:\n      - req_c2n4M98HkgTk63\n      Stripe-Version:\n      - 2025-04-30.basil\n      Vary:\n      - Origin\n      X-Stripe-Priority-Routing-Enabled:\n      - 'true'\n      X-Stripe-Routing-Context-Priority-Tier:\n      - api-testmode\n      X-Wc:\n      - ABGHI\n      Strict-Transport-Security:\n      - max-age=63072000; includeSubDomains; preload\n    body:\n      encoding: UTF-8\n      string: |-\n        {\n          \"id\": \"cs_test_b1RD8r6DAkSA8vrQ3grBC2QVgR5zUJ7QQFuVHZkcKoSYaEOQgCMPMOCOM5\",\n          \"object\": \"checkout.session\",\n          \"adaptive_pricing\": null,\n          \"after_expiration\": null,\n          \"allow_promotion_codes\": true,\n          \"amount_subtotal\": 900,\n          \"amount_total\": 900,\n          \"automatic_tax\": {\n            \"enabled\": false,\n            \"liability\": null,\n            \"provider\": null,\n            \"status\": null\n          },\n          \"billing_address_collection\": null,\n          \"cancel_url\": \"http://localhost:3000/subscription/upgrade?plan=monthly\",\n          \"client_reference_id\": null,\n          \"client_secret\": null,\n          \"collected_information\": {\n            \"shipping_details\": null\n          },\n          \"consent\": null,\n          \"consent_collection\": null,\n          \"created\": 1746281950,\n          \"currency\": \"usd\",\n          \"currency_conversion\": null,\n          \"custom_fields\": [],\n          \"custom_text\": {\n            \"after_submit\": null,\n            \"shipping_address\": null,\n            \"submit\": null,\n            \"terms_of_service_acceptance\": null\n          },\n          \"customer\": \"cus_SFBH32Bf5lsggB\",\n          \"customer_creation\": \"always\",\n          \"customer_details\": {\n            \"address\": {\n              \"city\": null,\n              \"country\": \"US\",\n              \"line1\": null,\n              \"line2\": null,\n              \"postal_code\": \"12345\",\n              \"state\": null\n            },\n            \"email\": \"user@maybe.local\",\n            \"name\": \"Test Checkout User\",\n            \"phone\": null,\n            \"tax_exempt\": \"none\",\n            \"tax_ids\": []\n          },\n          \"customer_email\": \"user@maybe.local\",\n          \"discounts\": [],\n          \"expires_at\": 1746368350,\n          \"invoice\": \"in_1RKguoQT2jbOS8G0PuBVklxw\",\n          \"invoice_creation\": null,\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_configuration_details\": {\n            \"id\": \"pmc_1RJyv5QT2jbOS8G0PDwTVBar\",\n            \"parent\": null\n          },\n          \"payment_method_options\": {\n            \"card\": {\n              \"request_three_d_secure\": \"automatic\"\n            }\n          },\n          \"payment_method_types\": [\n            \"card\",\n            \"link\",\n            \"cashapp\",\n            \"amazon_pay\"\n          ],\n          \"payment_status\": \"paid\",\n          \"permissions\": null,\n          \"phone_number_collection\": {\n            \"enabled\": false\n          },\n          \"recovered_from\": null,\n          \"saved_payment_method_options\": {\n            \"allow_redisplay_filters\": [\n              \"always\"\n            ],\n            \"payment_method_remove\": null,\n            \"payment_method_save\": null\n          },\n          \"setup_intent\": null,\n          \"shipping_address_collection\": null,\n          \"shipping_cost\": null,\n          \"shipping_options\": [],\n          \"status\": \"complete\",\n          \"submit_type\": null,\n          \"subscription\": \"sub_1RKguoQT2jbOS8G0Zih79ix9\",\n          \"success_url\": \"http://localhost:3000/subscription/success?session_id={CHECKOUT_SESSION_ID}\",\n          \"total_details\": {\n            \"amount_discount\": 0,\n            \"amount_shipping\": 0,\n            \"amount_tax\": 0\n          },\n          \"ui_mode\": \"hosted\",\n          \"url\": null,\n          \"wallet_options\": null\n        }\n  recorded_at: Mon, 05 May 2025 16:09:23 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/stripe/create_checkout_session.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: post\n    uri: https://api.stripe.com/v1/customers\n    body:\n      encoding: UTF-8\n      string: email=test%40example.com&metadata[family_id]=1\n    headers:\n      User-Agent:\n      - Stripe/v1 RubyBindings/15.1.0\n      Authorization:\n      - Bearer <STRIPE_SECRET_KEY>\n      Idempotency-Key:\n      - 7e129de1-324e-456a-8bd7-44382f6b4fa7\n      Stripe-Version:\n      - 2025-04-30.basil\n      X-Stripe-Client-User-Agent:\n      - '{\"bindings_version\":\"15.1.0\",\"lang\":\"ruby\",\"lang_version\":\"3.4.1 p0 (2024-12-25)\",\"platform\":\"arm64-darwin24\",\"engine\":\"ruby\",\"publisher\":\"stripe\",\"uname\":\"Darwin\n        Zachs-MacBook-Pro.local 24.3.0 Darwin Kernel Version 24.3.0: Thu Jan  2 20:24:16\n        PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6000 arm64\",\"hostname\":\"Zachs-MacBook-Pro.local\"}'\n      Content-Type:\n      - application/x-www-form-urlencoded\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 05 May 2025 16:09:23 GMT\n      Content-Type:\n      - application/json\n      Content-Length:\n      - '652'\n      Connection:\n      - keep-alive\n      Access-Control-Allow-Credentials:\n      - 'true'\n      Access-Control-Allow-Methods:\n      - GET, HEAD, PUT, PATCH, POST, DELETE\n      Access-Control-Allow-Origin:\n      - \"*\"\n      Access-Control-Expose-Headers:\n      - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required,\n        X-Stripe-Privileged-Session-Required\n      Access-Control-Max-Age:\n      - '300'\n      Cache-Control:\n      - no-cache, no-store\n      Content-Security-Policy:\n      - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none';\n        img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src\n        'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=o27rhODPrC4hNe4D-N6JT0tcY2dQoSKNy5hNsSJNdH_SL3lHJ8eS2959Oc-3UnIGMeyex9H3Qo_613Zp\n      Idempotency-Key:\n      - 7e129de1-324e-456a-8bd7-44382f6b4fa7\n      Original-Request:\n      - req_LIU5SlUlOEkoOg\n      Request-Id:\n      - req_LIU5SlUlOEkoOg\n      Stripe-Should-Retry:\n      - 'false'\n      Stripe-Version:\n      - 2025-04-30.basil\n      Vary:\n      - Origin\n      X-Stripe-Priority-Routing-Enabled:\n      - 'true'\n      X-Stripe-Routing-Context-Priority-Tier:\n      - api-testmode\n      X-Wc:\n      - ABGHI\n      Strict-Transport-Security:\n      - max-age=63072000; includeSubDomains; preload\n    body:\n      encoding: UTF-8\n      string: |-\n        {\n          \"id\": \"cus_SFxVsSZ9enVBNC\",\n          \"object\": \"customer\",\n          \"address\": null,\n          \"balance\": 0,\n          \"created\": 1746461363,\n          \"currency\": null,\n          \"default_source\": null,\n          \"delinquent\": false,\n          \"description\": null,\n          \"discount\": null,\n          \"email\": \"test@example.com\",\n          \"invoice_prefix\": \"JPHCOASK\",\n          \"invoice_settings\": {\n            \"custom_fields\": null,\n            \"default_payment_method\": null,\n            \"footer\": null,\n            \"rendering_options\": null\n          },\n          \"livemode\": false,\n          \"metadata\": {\n            \"family_id\": \"1\"\n          },\n          \"name\": null,\n          \"next_invoice_sequence\": 1,\n          \"phone\": null,\n          \"preferred_locales\": [],\n          \"shipping\": null,\n          \"tax_exempt\": \"none\",\n          \"test_clock\": null\n        }\n  recorded_at: Mon, 05 May 2025 16:09:23 GMT\n- request:\n    method: post\n    uri: https://api.stripe.com/v1/checkout/sessions\n    body:\n      encoding: UTF-8\n      string: customer=cus_SFxVsSZ9enVBNC&line_items[0][price]=price_1RJz6KQT2jbOS8G0Otv3qD01&line_items[0][quantity]=1&mode=subscription&allow_promotion_codes=true&success_url=http%3A%2F%2Flocalhost%3A3000%2Fsubscription%2Fsuccess%3Fsession_id%3D%7BCHECKOUT_SESSION_ID%7D&cancel_url=http%3A%2F%2Flocalhost%3A3000%2Fsubscription%2Fupgrade\n    headers:\n      User-Agent:\n      - Stripe/v1 RubyBindings/15.1.0\n      Authorization:\n      - Bearer <STRIPE_SECRET_KEY>\n      X-Stripe-Client-Telemetry:\n      - '{\"last_request_metrics\":{\"request_id\":\"req_LIU5SlUlOEkoOg\",\"request_duration_ms\":307}}'\n      Idempotency-Key:\n      - d62353a3-199a-468d-9f78-a4fb10fa28bd\n      Stripe-Version:\n      - 2025-04-30.basil\n      X-Stripe-Client-User-Agent:\n      - '{\"bindings_version\":\"15.1.0\",\"lang\":\"ruby\",\"lang_version\":\"3.4.1 p0 (2024-12-25)\",\"platform\":\"arm64-darwin24\",\"engine\":\"ruby\",\"publisher\":\"stripe\",\"uname\":\"Darwin\n        Zachs-MacBook-Pro.local 24.3.0 Darwin Kernel Version 24.3.0: Thu Jan  2 20:24:16\n        PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6000 arm64\",\"hostname\":\"Zachs-MacBook-Pro.local\"}'\n      Content-Type:\n      - application/x-www-form-urlencoded\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Server:\n      - nginx\n      Date:\n      - Mon, 05 May 2025 16:09:24 GMT\n      Content-Type:\n      - application/json\n      Content-Length:\n      - '2850'\n      Connection:\n      - keep-alive\n      Access-Control-Allow-Credentials:\n      - 'true'\n      Access-Control-Allow-Methods:\n      - GET, HEAD, PUT, PATCH, POST, DELETE\n      Access-Control-Allow-Origin:\n      - \"*\"\n      Access-Control-Expose-Headers:\n      - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required,\n        X-Stripe-Privileged-Session-Required\n      Access-Control-Max-Age:\n      - '300'\n      Cache-Control:\n      - no-cache, no-store\n      Content-Security-Policy:\n      - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none';\n        img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src\n        'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=7L6NHIm4wk05H5wi0PfH951BH62utb5j2ZImtzEXvcfJgdc1v5juGoNb0oSAXIHhGQtWiGOiCmz3UG1W\n      Idempotency-Key:\n      - d62353a3-199a-468d-9f78-a4fb10fa28bd\n      Original-Request:\n      - req_8AIKuqTzBWcO76\n      Request-Id:\n      - req_8AIKuqTzBWcO76\n      Stripe-Should-Retry:\n      - 'false'\n      Stripe-Version:\n      - 2025-04-30.basil\n      Vary:\n      - Origin\n      X-Stripe-Priority-Routing-Enabled:\n      - 'true'\n      X-Stripe-Routing-Context-Priority-Tier:\n      - api-testmode\n      X-Wc:\n      - ABGHI\n      Strict-Transport-Security:\n      - max-age=63072000; includeSubDomains; preload\n    body:\n      encoding: UTF-8\n      string: |-\n        {\n          \"id\": \"cs_test_b1lPPmtTEFw5w9GpyzQYlxz2TAN4JeUTosGjyXzfjkx9ocP59UhcF0WgWf\",\n          \"object\": \"checkout.session\",\n          \"adaptive_pricing\": null,\n          \"after_expiration\": null,\n          \"allow_promotion_codes\": true,\n          \"amount_subtotal\": 900,\n          \"amount_total\": 900,\n          \"automatic_tax\": {\n            \"enabled\": false,\n            \"liability\": null,\n            \"provider\": null,\n            \"status\": null\n          },\n          \"billing_address_collection\": null,\n          \"cancel_url\": \"http://localhost:3000/subscription/upgrade\",\n          \"client_reference_id\": null,\n          \"client_secret\": null,\n          \"collected_information\": {\n            \"shipping_details\": null\n          },\n          \"consent\": null,\n          \"consent_collection\": null,\n          \"created\": 1746461364,\n          \"currency\": \"usd\",\n          \"currency_conversion\": null,\n          \"custom_fields\": [],\n          \"custom_text\": {\n            \"after_submit\": null,\n            \"shipping_address\": null,\n            \"submit\": null,\n            \"terms_of_service_acceptance\": null\n          },\n          \"customer\": \"cus_SFxVsSZ9enVBNC\",\n          \"customer_creation\": null,\n          \"customer_details\": {\n            \"address\": null,\n            \"email\": \"test@example.com\",\n            \"name\": null,\n            \"phone\": null,\n            \"tax_exempt\": \"none\",\n            \"tax_ids\": null\n          },\n          \"customer_email\": null,\n          \"discounts\": [],\n          \"expires_at\": 1746547763,\n          \"invoice\": null,\n          \"invoice_creation\": null,\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_configuration_details\": {\n            \"id\": \"pmc_1RJyv5QT2jbOS8G0PDwTVBar\",\n            \"parent\": null\n          },\n          \"payment_method_options\": {\n            \"card\": {\n              \"request_three_d_secure\": \"automatic\"\n            }\n          },\n          \"payment_method_types\": [\n            \"card\",\n            \"link\",\n            \"cashapp\",\n            \"amazon_pay\"\n          ],\n          \"payment_status\": \"unpaid\",\n          \"permissions\": null,\n          \"phone_number_collection\": {\n            \"enabled\": false\n          },\n          \"recovered_from\": null,\n          \"saved_payment_method_options\": {\n            \"allow_redisplay_filters\": [\n              \"always\"\n            ],\n            \"payment_method_remove\": null,\n            \"payment_method_save\": null\n          },\n          \"setup_intent\": null,\n          \"shipping_address_collection\": null,\n          \"shipping_cost\": null,\n          \"shipping_options\": [],\n          \"status\": \"open\",\n          \"submit_type\": null,\n          \"subscription\": null,\n          \"success_url\": \"http://localhost:3000/subscription/success?session_id={CHECKOUT_SESSION_ID}\",\n          \"total_details\": {\n            \"amount_discount\": 0,\n            \"amount_shipping\": 0,\n            \"amount_tax\": 0\n          },\n          \"ui_mode\": \"hosted\",\n          \"url\": \"https://checkout.stripe.com/c/pay/cs_test_b1lPPmtTEFw5w9GpyzQYlxz2TAN4JeUTosGjyXzfjkx9ocP59UhcF0WgWf#fid2cGd2ZndsdXFsamtQa2x0cGBrYHZ2QGtkZ2lgYSc%2FY2RpdmApJ2R1bE5gfCc%2FJ3VuWnFgdnFaMDRXT3xwYVRRN29nSlY9QjUzNUttakJibmcxXH11Z2M8R3UzYUh3dGtNNlA8M3ExMTRKd0Z3M2p%2FdXdkcTNHcmg8NWA1YUx%2FbTRdTEM1cXI8TXNIT0FTRkY1NU5RME9EcjBpJyknY3dqaFZgd3Ngdyc%2FcXdwYCknaWR8anBxUXx1YCc%2FJ2hwaXFsWmxxYGgnKSdga2RnaWBVaWRmYG1qaWFgd3YnP3F3cGB4JSUl\",\n          \"wallet_options\": null\n        }\n  recorded_at: Mon, 05 May 2025 16:09:24 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/synth/exchange_rate.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: get\n    uri: https://api.synthfinance.com/rates/historical?date=2024-01-01&from=USD&to=GBP\n    body:\n      encoding: US-ASCII\n      string: ''\n    headers:\n      Authorization:\n      - Bearer <SYNTH_API_KEY>\n      X-Source:\n      - maybe_app\n      X-Source-Type:\n      - managed\n      User-Agent:\n      - Faraday v2.13.1\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Fri, 16 May 2025 13:01:38 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Cache-Control:\n      - max-age=0, private, must-revalidate\n      Etag:\n      - W/\"0c93a67d0c68e6f206e2954a41aa2933\"\n      Referrer-Policy:\n      - strict-origin-when-cross-origin\n      Rndr-Id:\n      - 146e30b2-e03b-47e3\n      Strict-Transport-Security:\n      - max-age=63072000; includeSubDomains\n      Vary:\n      - Accept-Encoding\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - SAMEORIGIN\n      X-Permitted-Cross-Domain-Policies:\n      - none\n      X-Render-Origin-Server:\n      - Render\n      X-Request-Id:\n      - 3cf7ade1-8066-422a-97c7-5f8b99e24296\n      X-Runtime:\n      - '0.024284'\n      X-Xss-Protection:\n      - '0'\n      Cf-Cache-Status:\n      - DYNAMIC\n      Report-To:\n      - '{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=ih8sEFqAOWyINqAEtKGKPKO2lr1qAYSVeipyB5F8g2umPODXvCD4hN3G6wTTs2Q7H8CDWsqiOlYkmVvmr%2BWvl2ojOtBwO25Ahk9TbhlcgRO9nT6mEIXOSdVXJpzpRn5Ov%2FMGigpQ\"}],\"group\":\"cf-nel\",\"max_age\":604800}'\n      Nel:\n      - '{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}'\n      Speculation-Rules:\n      - '\"/cdn-cgi/speculation\"'\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 940b109b5df1a3d7-ORD\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n      Server-Timing:\n      - cfL4;desc=\"?proto=TCP&rtt=25865&min_rtt=25683&rtt_var=9996&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=922&delivery_rate=106690&cwnd=219&unsent_bytes=0&cid=e48ae188d1f86721&ts=190&x=0\"\n    body:\n      encoding: ASCII-8BIT\n      string: '{\"data\":{\"date\":\"2024-01-01\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785476}},\"meta\":{\"total_records\":1,\"credits_used\":1,\"credits_remaining\":249734,\"date\":\"2024-01-01\"}}'\n  recorded_at: Fri, 16 May 2025 13:01:38 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/synth/exchange_rates.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: get\n    uri: https://api.synthfinance.com/rates/historical-range?date_end=2024-07-31&date_start=2024-01-01&from=USD&page=1&to=GBP\n    body:\n      encoding: US-ASCII\n      string: ''\n    headers:\n      Authorization:\n      - Bearer <SYNTH_API_KEY>\n      X-Source:\n      - maybe_app\n      X-Source-Type:\n      - managed\n      User-Agent:\n      - Faraday v2.13.1\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Fri, 16 May 2025 13:01:35 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Cache-Control:\n      - max-age=0, private, must-revalidate\n      Etag:\n      - W/\"ad21b1fba71fe0b149fe37b483a60438\"\n      Referrer-Policy:\n      - strict-origin-when-cross-origin\n      Rndr-Id:\n      - 28bc6622-47b8-4aeb\n      Strict-Transport-Security:\n      - max-age=63072000; includeSubDomains\n      Vary:\n      - Accept-Encoding\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - SAMEORIGIN\n      X-Permitted-Cross-Domain-Policies:\n      - none\n      X-Render-Origin-Server:\n      - Render\n      X-Request-Id:\n      - fcf251a3-f850-4464-9592-ced9de5e0c86\n      X-Runtime:\n      - '0.080857'\n      X-Xss-Protection:\n      - '0'\n      Cf-Cache-Status:\n      - DYNAMIC\n      Report-To:\n      - '{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=622lysAXubNaj3TsuhR9RYZRXPc%2BgnyMWj52fxy%2BptvXoPr%2FxVJgJZ0g02mOUjCywdAymkMpawfWCaZVQOIaPVpocco3g4Y%2B0FB667ilf3UtCyiHwqCosUq0T99JabIsgFFJ%2FhP4\"}],\"group\":\"cf-nel\",\"max_age\":604800}'\n      Nel:\n      - '{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}'\n      Speculation-Rules:\n      - '\"/cdn-cgi/speculation\"'\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 940b108a2921607d-ORD\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n      Server-Timing:\n      - cfL4;desc=\"?proto=TCP&rtt=25729&min_rtt=25575&rtt_var=9899&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=961&delivery_rate=108019&cwnd=251&unsent_bytes=0&cid=ca574e4a637aba29&ts=241&x=0\"\n    body:\n      encoding: ASCII-8BIT\n      string: '{\"data\":[{\"date\":\"2024-01-01\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785476}},{\"date\":\"2024-01-02\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785644}},{\"date\":\"2024-01-03\",\"source\":\"USD\",\"rates\":{\"GBP\":0.792232}},{\"date\":\"2024-01-04\",\"source\":\"USD\",\"rates\":{\"GBP\":0.789053}},{\"date\":\"2024-01-05\",\"source\":\"USD\",\"rates\":{\"GBP\":0.788487}},{\"date\":\"2024-01-06\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785787}},{\"date\":\"2024-01-07\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785994}},{\"date\":\"2024-01-08\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786378}},{\"date\":\"2024-01-09\",\"source\":\"USD\",\"rates\":{\"GBP\":0.784775}},{\"date\":\"2024-01-10\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786769}},{\"date\":\"2024-01-11\",\"source\":\"USD\",\"rates\":{\"GBP\":0.784633}},{\"date\":\"2024-01-12\",\"source\":\"USD\",\"rates\":{\"GBP\":0.782576}},{\"date\":\"2024-01-13\",\"source\":\"USD\",\"rates\":{\"GBP\":0.78447}},{\"date\":\"2024-01-14\",\"source\":\"USD\",\"rates\":{\"GBP\":0.784423}},{\"date\":\"2024-01-15\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785204}},{\"date\":\"2024-01-16\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786438}},{\"date\":\"2024-01-17\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791264}},{\"date\":\"2024-01-18\",\"source\":\"USD\",\"rates\":{\"GBP\":0.788852}},{\"date\":\"2024-01-19\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786744}},{\"date\":\"2024-01-20\",\"source\":\"USD\",\"rates\":{\"GBP\":0.787186}},{\"date\":\"2024-01-21\",\"source\":\"USD\",\"rates\":{\"GBP\":0.787166}},{\"date\":\"2024-01-22\",\"source\":\"USD\",\"rates\":{\"GBP\":0.787487}},{\"date\":\"2024-01-23\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786985}},{\"date\":\"2024-01-24\",\"source\":\"USD\",\"rates\":{\"GBP\":0.787961}},{\"date\":\"2024-01-25\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786236}},{\"date\":\"2024-01-26\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786961}},{\"date\":\"2024-01-27\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786935}},{\"date\":\"2024-01-28\",\"source\":\"USD\",\"rates\":{\"GBP\":0.787014}},{\"date\":\"2024-01-29\",\"source\":\"USD\",\"rates\":{\"GBP\":0.78761}},{\"date\":\"2024-01-30\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786652}},{\"date\":\"2024-01-31\",\"source\":\"USD\",\"rates\":{\"GBP\":0.787736}},{\"date\":\"2024-02-01\",\"source\":\"USD\",\"rates\":{\"GBP\":0.788759}},{\"date\":\"2024-02-02\",\"source\":\"USD\",\"rates\":{\"GBP\":0.784546}},{\"date\":\"2024-02-03\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791634}},{\"date\":\"2024-02-04\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791637}},{\"date\":\"2024-02-05\",\"source\":\"USD\",\"rates\":{\"GBP\":0.792205}},{\"date\":\"2024-02-06\",\"source\":\"USD\",\"rates\":{\"GBP\":0.797836}},{\"date\":\"2024-02-07\",\"source\":\"USD\",\"rates\":{\"GBP\":0.79341}},{\"date\":\"2024-02-08\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791971}},{\"date\":\"2024-02-09\",\"source\":\"USD\",\"rates\":{\"GBP\":0.792371}},{\"date\":\"2024-02-10\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791997}},{\"date\":\"2024-02-11\",\"source\":\"USD\",\"rates\":{\"GBP\":0.792019}},{\"date\":\"2024-02-12\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791339}},{\"date\":\"2024-02-13\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791977}},{\"date\":\"2024-02-14\",\"source\":\"USD\",\"rates\":{\"GBP\":0.794262}},{\"date\":\"2024-02-15\",\"source\":\"USD\",\"rates\":{\"GBP\":0.795709}},{\"date\":\"2024-02-16\",\"source\":\"USD\",\"rates\":{\"GBP\":0.793714}},{\"date\":\"2024-02-17\",\"source\":\"USD\",\"rates\":{\"GBP\":0.793499}},{\"date\":\"2024-02-18\",\"source\":\"USD\",\"rates\":{\"GBP\":0.79367}},{\"date\":\"2024-02-19\",\"source\":\"USD\",\"rates\":{\"GBP\":0.792968}},{\"date\":\"2024-02-20\",\"source\":\"USD\",\"rates\":{\"GBP\":0.794437}},{\"date\":\"2024-02-21\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791988}},{\"date\":\"2024-02-22\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791262}},{\"date\":\"2024-02-23\",\"source\":\"USD\",\"rates\":{\"GBP\":0.789749}},{\"date\":\"2024-02-24\",\"source\":\"USD\",\"rates\":{\"GBP\":0.78886}},{\"date\":\"2024-02-25\",\"source\":\"USD\",\"rates\":{\"GBP\":0.789107}},{\"date\":\"2024-02-26\",\"source\":\"USD\",\"rates\":{\"GBP\":0.78917}},{\"date\":\"2024-02-27\",\"source\":\"USD\",\"rates\":{\"GBP\":0.788381}},{\"date\":\"2024-02-28\",\"source\":\"USD\",\"rates\":{\"GBP\":0.78861}},{\"date\":\"2024-02-29\",\"source\":\"USD\",\"rates\":{\"GBP\":0.789837}},{\"date\":\"2024-03-01\",\"source\":\"USD\",\"rates\":{\"GBP\":0.792028}},{\"date\":\"2024-03-02\",\"source\":\"USD\",\"rates\":{\"GBP\":0.790312}},{\"date\":\"2024-03-03\",\"source\":\"USD\",\"rates\":{\"GBP\":0.790258}},{\"date\":\"2024-03-04\",\"source\":\"USD\",\"rates\":{\"GBP\":0.789891}},{\"date\":\"2024-03-05\",\"source\":\"USD\",\"rates\":{\"GBP\":0.788025}},{\"date\":\"2024-03-06\",\"source\":\"USD\",\"rates\":{\"GBP\":0.787136}},{\"date\":\"2024-03-07\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785219}},{\"date\":\"2024-03-08\",\"source\":\"USD\",\"rates\":{\"GBP\":0.780438}},{\"date\":\"2024-03-09\",\"source\":\"USD\",\"rates\":{\"GBP\":0.777772}},{\"date\":\"2024-03-10\",\"source\":\"USD\",\"rates\":{\"GBP\":0.777884}},{\"date\":\"2024-03-11\",\"source\":\"USD\",\"rates\":{\"GBP\":0.77786}},{\"date\":\"2024-03-12\",\"source\":\"USD\",\"rates\":{\"GBP\":0.780067}},{\"date\":\"2024-03-13\",\"source\":\"USD\",\"rates\":{\"GBP\":0.781535}},{\"date\":\"2024-03-14\",\"source\":\"USD\",\"rates\":{\"GBP\":0.781184}},{\"date\":\"2024-03-15\",\"source\":\"USD\",\"rates\":{\"GBP\":0.784604}},{\"date\":\"2024-03-16\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785537}},{\"date\":\"2024-03-17\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785147}},{\"date\":\"2024-03-18\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785457}},{\"date\":\"2024-03-19\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785746}},{\"date\":\"2024-03-20\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786238}},{\"date\":\"2024-03-21\",\"source\":\"USD\",\"rates\":{\"GBP\":0.781351}},{\"date\":\"2024-03-22\",\"source\":\"USD\",\"rates\":{\"GBP\":0.789841}},{\"date\":\"2024-03-23\",\"source\":\"USD\",\"rates\":{\"GBP\":0.793659}},{\"date\":\"2024-03-24\",\"source\":\"USD\",\"rates\":{\"GBP\":0.793385}},{\"date\":\"2024-03-25\",\"source\":\"USD\",\"rates\":{\"GBP\":0.793673}},{\"date\":\"2024-03-26\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791344}},{\"date\":\"2024-03-27\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791899}},{\"date\":\"2024-03-28\",\"source\":\"USD\",\"rates\":{\"GBP\":0.792585}},{\"date\":\"2024-03-29\",\"source\":\"USD\",\"rates\":{\"GBP\":0.792205}},{\"date\":\"2024-03-30\",\"source\":\"USD\",\"rates\":{\"GBP\":0.792228}},{\"date\":\"2024-03-31\",\"source\":\"USD\",\"rates\":{\"GBP\":0.792057}},{\"date\":\"2024-04-01\",\"source\":\"USD\",\"rates\":{\"GBP\":0.79134}},{\"date\":\"2024-04-02\",\"source\":\"USD\",\"rates\":{\"GBP\":0.797058}},{\"date\":\"2024-04-03\",\"source\":\"USD\",\"rates\":{\"GBP\":0.795147}},{\"date\":\"2024-04-04\",\"source\":\"USD\",\"rates\":{\"GBP\":0.790398}},{\"date\":\"2024-04-05\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791151}},{\"date\":\"2024-04-06\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791314}},{\"date\":\"2024-04-07\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791273}},{\"date\":\"2024-04-08\",\"source\":\"USD\",\"rates\":{\"GBP\":0.792111}},{\"date\":\"2024-04-09\",\"source\":\"USD\",\"rates\":{\"GBP\":0.790047}},{\"date\":\"2024-04-10\",\"source\":\"USD\",\"rates\":{\"GBP\":0.788828}},{\"date\":\"2024-04-11\",\"source\":\"USD\",\"rates\":{\"GBP\":0.797646}},{\"date\":\"2024-04-12\",\"source\":\"USD\",\"rates\":{\"GBP\":0.796524}},{\"date\":\"2024-04-13\",\"source\":\"USD\",\"rates\":{\"GBP\":0.803024}},{\"date\":\"2024-04-14\",\"source\":\"USD\",\"rates\":{\"GBP\":0.802912}},{\"date\":\"2024-04-15\",\"source\":\"USD\",\"rates\":{\"GBP\":0.8025}},{\"date\":\"2024-04-16\",\"source\":\"USD\",\"rates\":{\"GBP\":0.80344}},{\"date\":\"2024-04-17\",\"source\":\"USD\",\"rates\":{\"GBP\":0.804505}},{\"date\":\"2024-04-18\",\"source\":\"USD\",\"rates\":{\"GBP\":0.80301}},{\"date\":\"2024-04-19\",\"source\":\"USD\",\"rates\":{\"GBP\":0.804145}},{\"date\":\"2024-04-20\",\"source\":\"USD\",\"rates\":{\"GBP\":0.80845}},{\"date\":\"2024-04-21\",\"source\":\"USD\",\"rates\":{\"GBP\":0.808199}},{\"date\":\"2024-04-22\",\"source\":\"USD\",\"rates\":{\"GBP\":0.808004}},{\"date\":\"2024-04-23\",\"source\":\"USD\",\"rates\":{\"GBP\":0.809734}},{\"date\":\"2024-04-24\",\"source\":\"USD\",\"rates\":{\"GBP\":0.802955}},{\"date\":\"2024-04-25\",\"source\":\"USD\",\"rates\":{\"GBP\":0.80264}},{\"date\":\"2024-04-26\",\"source\":\"USD\",\"rates\":{\"GBP\":0.799526}},{\"date\":\"2024-04-27\",\"source\":\"USD\",\"rates\":{\"GBP\":0.80053}},{\"date\":\"2024-04-28\",\"source\":\"USD\",\"rates\":{\"GBP\":0.800761}},{\"date\":\"2024-04-29\",\"source\":\"USD\",\"rates\":{\"GBP\":0.799397}},{\"date\":\"2024-04-30\",\"source\":\"USD\",\"rates\":{\"GBP\":0.796217}},{\"date\":\"2024-05-01\",\"source\":\"USD\",\"rates\":{\"GBP\":0.800703}},{\"date\":\"2024-05-02\",\"source\":\"USD\",\"rates\":{\"GBP\":0.797562}},{\"date\":\"2024-05-03\",\"source\":\"USD\",\"rates\":{\"GBP\":0.797457}},{\"date\":\"2024-05-04\",\"source\":\"USD\",\"rates\":{\"GBP\":0.797001}},{\"date\":\"2024-05-05\",\"source\":\"USD\",\"rates\":{\"GBP\":0.797107}},{\"date\":\"2024-05-06\",\"source\":\"USD\",\"rates\":{\"GBP\":0.797363}},{\"date\":\"2024-05-07\",\"source\":\"USD\",\"rates\":{\"GBP\":0.796218}},{\"date\":\"2024-05-08\",\"source\":\"USD\",\"rates\":{\"GBP\":0.799915}},{\"date\":\"2024-05-09\",\"source\":\"USD\",\"rates\":{\"GBP\":0.800422}},{\"date\":\"2024-05-10\",\"source\":\"USD\",\"rates\":{\"GBP\":0.798411}},{\"date\":\"2024-05-11\",\"source\":\"USD\",\"rates\":{\"GBP\":0.798489}},{\"date\":\"2024-05-12\",\"source\":\"USD\",\"rates\":{\"GBP\":0.798475}},{\"date\":\"2024-05-13\",\"source\":\"USD\",\"rates\":{\"GBP\":0.79853}},{\"date\":\"2024-05-14\",\"source\":\"USD\",\"rates\":{\"GBP\":0.796122}},{\"date\":\"2024-05-15\",\"source\":\"USD\",\"rates\":{\"GBP\":0.794614}},{\"date\":\"2024-05-16\",\"source\":\"USD\",\"rates\":{\"GBP\":0.78804}},{\"date\":\"2024-05-17\",\"source\":\"USD\",\"rates\":{\"GBP\":0.789188}},{\"date\":\"2024-05-18\",\"source\":\"USD\",\"rates\":{\"GBP\":0.787162}},{\"date\":\"2024-05-19\",\"source\":\"USD\",\"rates\":{\"GBP\":0.787194}},{\"date\":\"2024-05-20\",\"source\":\"USD\",\"rates\":{\"GBP\":0.787022}},{\"date\":\"2024-05-21\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786793}},{\"date\":\"2024-05-22\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786723}},{\"date\":\"2024-05-23\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786132}},{\"date\":\"2024-05-24\",\"source\":\"USD\",\"rates\":{\"GBP\":0.78778}},{\"date\":\"2024-05-25\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785013}},{\"date\":\"2024-05-26\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785081}},{\"date\":\"2024-05-27\",\"source\":\"USD\",\"rates\":{\"GBP\":0.78526}},{\"date\":\"2024-05-28\",\"source\":\"USD\",\"rates\":{\"GBP\":0.78296}},{\"date\":\"2024-05-29\",\"source\":\"USD\",\"rates\":{\"GBP\":0.783808}},{\"date\":\"2024-05-30\",\"source\":\"USD\",\"rates\":{\"GBP\":0.787552}},{\"date\":\"2024-05-31\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785599}},{\"date\":\"2024-06-01\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785113}},{\"date\":\"2024-06-02\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785019}},{\"date\":\"2024-06-03\",\"source\":\"USD\",\"rates\":{\"GBP\":0.784657}},{\"date\":\"2024-06-04\",\"source\":\"USD\",\"rates\":{\"GBP\":0.780649}},{\"date\":\"2024-06-05\",\"source\":\"USD\",\"rates\":{\"GBP\":0.782934}},{\"date\":\"2024-06-06\",\"source\":\"USD\",\"rates\":{\"GBP\":0.781631}},{\"date\":\"2024-06-07\",\"source\":\"USD\",\"rates\":{\"GBP\":0.781732}},{\"date\":\"2024-06-08\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785947}},{\"date\":\"2024-06-09\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785767}},{\"date\":\"2024-06-10\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785588}},{\"date\":\"2024-06-11\",\"source\":\"USD\",\"rates\":{\"GBP\":0.785791}},{\"date\":\"2024-06-12\",\"source\":\"USD\",\"rates\":{\"GBP\":0.784932}},{\"date\":\"2024-06-13\",\"source\":\"USD\",\"rates\":{\"GBP\":0.781472}},{\"date\":\"2024-06-14\",\"source\":\"USD\",\"rates\":{\"GBP\":0.784041}},{\"date\":\"2024-06-15\",\"source\":\"USD\",\"rates\":{\"GBP\":0.789096}},{\"date\":\"2024-06-16\",\"source\":\"USD\",\"rates\":{\"GBP\":0.788449}},{\"date\":\"2024-06-17\",\"source\":\"USD\",\"rates\":{\"GBP\":0.788479}},{\"date\":\"2024-06-18\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786542}},{\"date\":\"2024-06-19\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786916}},{\"date\":\"2024-06-20\",\"source\":\"USD\",\"rates\":{\"GBP\":0.786107}},{\"date\":\"2024-06-21\",\"source\":\"USD\",\"rates\":{\"GBP\":0.789875}},{\"date\":\"2024-06-22\",\"source\":\"USD\",\"rates\":{\"GBP\":0.79058}},{\"date\":\"2024-06-23\",\"source\":\"USD\",\"rates\":{\"GBP\":0.790546}},{\"date\":\"2024-06-24\",\"source\":\"USD\",\"rates\":{\"GBP\":0.791248}},{\"date\":\"2024-06-25\",\"source\":\"USD\",\"rates\":{\"GBP\":0.788496}},{\"date\":\"2024-06-26\",\"source\":\"USD\",\"rates\":{\"GBP\":0.788395}},{\"date\":\"2024-06-27\",\"source\":\"USD\",\"rates\":{\"GBP\":0.792298}},{\"date\":\"2024-06-28\",\"source\":\"USD\",\"rates\":{\"GBP\":0.79087}},{\"date\":\"2024-06-29\",\"source\":\"USD\",\"rates\":{\"GBP\":0.790726}},{\"date\":\"2024-06-30\",\"source\":\"USD\",\"rates\":{\"GBP\":0.790719}},{\"date\":\"2024-07-01\",\"source\":\"USD\",\"rates\":{\"GBP\":0.790622}},{\"date\":\"2024-07-02\",\"source\":\"USD\",\"rates\":{\"GBP\":0.790812}},{\"date\":\"2024-07-03\",\"source\":\"USD\",\"rates\":{\"GBP\":0.78816}},{\"date\":\"2024-07-04\",\"source\":\"USD\",\"rates\":{\"GBP\":0.784451}},{\"date\":\"2024-07-05\",\"source\":\"USD\",\"rates\":{\"GBP\":0.783992}},{\"date\":\"2024-07-06\",\"source\":\"USD\",\"rates\":{\"GBP\":0.780243}},{\"date\":\"2024-07-07\",\"source\":\"USD\",\"rates\":{\"GBP\":0.780594}},{\"date\":\"2024-07-08\",\"source\":\"USD\",\"rates\":{\"GBP\":0.780827}},{\"date\":\"2024-07-09\",\"source\":\"USD\",\"rates\":{\"GBP\":0.780333}},{\"date\":\"2024-07-10\",\"source\":\"USD\",\"rates\":{\"GBP\":0.781936}},{\"date\":\"2024-07-11\",\"source\":\"USD\",\"rates\":{\"GBP\":0.777992}},{\"date\":\"2024-07-12\",\"source\":\"USD\",\"rates\":{\"GBP\":0.773816}},{\"date\":\"2024-07-13\",\"source\":\"USD\",\"rates\":{\"GBP\":0.770374}},{\"date\":\"2024-07-14\",\"source\":\"USD\",\"rates\":{\"GBP\":0.770294}},{\"date\":\"2024-07-15\",\"source\":\"USD\",\"rates\":{\"GBP\":0.771174}},{\"date\":\"2024-07-16\",\"source\":\"USD\",\"rates\":{\"GBP\":0.771041}},{\"date\":\"2024-07-17\",\"source\":\"USD\",\"rates\":{\"GBP\":0.770574}},{\"date\":\"2024-07-18\",\"source\":\"USD\",\"rates\":{\"GBP\":0.768775}},{\"date\":\"2024-07-19\",\"source\":\"USD\",\"rates\":{\"GBP\":0.772195}},{\"date\":\"2024-07-20\",\"source\":\"USD\",\"rates\":{\"GBP\":0.774311}},{\"date\":\"2024-07-21\",\"source\":\"USD\",\"rates\":{\"GBP\":0.774096}},{\"date\":\"2024-07-22\",\"source\":\"USD\",\"rates\":{\"GBP\":0.773251}},{\"date\":\"2024-07-23\",\"source\":\"USD\",\"rates\":{\"GBP\":0.773304}},{\"date\":\"2024-07-24\",\"source\":\"USD\",\"rates\":{\"GBP\":0.775165}},{\"date\":\"2024-07-25\",\"source\":\"USD\",\"rates\":{\"GBP\":0.775289}},{\"date\":\"2024-07-26\",\"source\":\"USD\",\"rates\":{\"GBP\":0.777882}},{\"date\":\"2024-07-27\",\"source\":\"USD\",\"rates\":{\"GBP\":0.777203}},{\"date\":\"2024-07-28\",\"source\":\"USD\",\"rates\":{\"GBP\":0.776969}},{\"date\":\"2024-07-29\",\"source\":\"USD\",\"rates\":{\"GBP\":0.777176}},{\"date\":\"2024-07-30\",\"source\":\"USD\",\"rates\":{\"GBP\":0.777613}},{\"date\":\"2024-07-31\",\"source\":\"USD\",\"rates\":{\"GBP\":0.778999}}],\"paging\":{\"prev\":\"/rates/historical-range?date_end=2024-07-31\\u0026date_start=2024-01-01\\u0026from=USD\\u0026page=\\u0026to=GBP\",\"next\":\"/rates/historical-range?date_end=2024-07-31\\u0026date_start=2024-01-01\\u0026from=USD\\u0026page=\\u0026to=GBP\",\"total_records\":213,\"current_page\":1,\"per_page\":500,\"total_pages\":1},\"meta\":{\"credits_used\":1,\"credits_remaining\":249739,\"date_start\":\"2024-01-01\",\"date_end\":\"2024-07-31\"}}'\n  recorded_at: Fri, 16 May 2025 13:01:35 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/synth/health.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: get\n    uri: https://api.synthfinance.com/user\n    body:\n      encoding: US-ASCII\n      string: ''\n    headers:\n      Authorization:\n      - Bearer <SYNTH_API_KEY>\n      X-Source:\n      - maybe_app\n      X-Source-Type:\n      - managed\n      User-Agent:\n      - Faraday v2.13.1\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Fri, 16 May 2025 13:01:39 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Cache-Control:\n      - max-age=0, private, must-revalidate\n      Etag:\n      - W/\"c5c1d51b68b499d00936c9eb1e8bfdbb\"\n      Referrer-Policy:\n      - strict-origin-when-cross-origin\n      Rndr-Id:\n      - 3abc1256-5517-44a7\n      Strict-Transport-Security:\n      - max-age=63072000; includeSubDomains\n      Vary:\n      - Accept-Encoding\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - SAMEORIGIN\n      X-Permitted-Cross-Domain-Policies:\n      - none\n      X-Render-Origin-Server:\n      - Render\n      X-Request-Id:\n      - aaf85301-dd16-4b9b-a3a4-c4fbcf1d3f55\n      X-Runtime:\n      - '0.014386'\n      X-Xss-Protection:\n      - '0'\n      Cf-Cache-Status:\n      - DYNAMIC\n      Report-To:\n      - '{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=OaVSdNPSl6CQ8gbhnDzkCisX2ILOEWAwweMW3rXXP5rBKuxZoDT024srQWmHKGLsCEhpt4G9mqCthDwlHu2%2BuZ3AyTJQcnBONtE%2FNQ7fKT9x8nLz4mnqL8iyynLuRWQSUJ8SWMj5\"}],\"group\":\"cf-nel\",\"max_age\":604800}'\n      Nel:\n      - '{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}'\n      Speculation-Rules:\n      - '\"/cdn-cgi/speculation\"'\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 940b109d086eb4b8-ORD\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n      Server-Timing:\n      - cfL4;desc=\"?proto=TCP&rtt=32457&min_rtt=26792&rtt_var=14094&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2826&recv_bytes=878&delivery_rate=108091&cwnd=229&unsent_bytes=0&cid=a6f330e4d5f16682&ts=309&x=0\"\n    body:\n      encoding: ASCII-8BIT\n      string: '{\"id\":\"user_3208c49393f54b3e974795e4bea5b864\",\"email\":\"zach@maybe.co\",\"name\":\"Zach\n        Gollwitzer\",\"plan\":\"Business\",\"api_calls_remaining\":249733,\"api_limit\":250000,\"credits_reset_at\":\"2025-06-01T00:00:00.000-04:00\",\"current_period_start\":\"2025-05-01T00:00:00.000-04:00\"}'\n  recorded_at: Fri, 16 May 2025 13:01:39 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/synth/security_info.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: get\n    uri: https://api.synthfinance.com/tickers/AAPL?operating_mic=XNAS\n    body:\n      encoding: US-ASCII\n      string: ''\n    headers:\n      Authorization:\n      - Bearer <SYNTH_API_KEY>\n      X-Source:\n      - maybe_app\n      X-Source-Type:\n      - managed\n      User-Agent:\n      - Faraday v2.13.1\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Fri, 16 May 2025 13:01:37 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Cache-Control:\n      - max-age=0, private, must-revalidate\n      Etag:\n      - W/\"75f336ad88e262c72044e8b865265298\"\n      Referrer-Policy:\n      - strict-origin-when-cross-origin\n      Rndr-Id:\n      - ba973abf-7d96-4a9a\n      Strict-Transport-Security:\n      - max-age=63072000; includeSubDomains\n      Vary:\n      - Accept-Encoding\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - SAMEORIGIN\n      X-Permitted-Cross-Domain-Policies:\n      - none\n      X-Render-Origin-Server:\n      - Render\n      X-Request-Id:\n      - 76cb13a6-0d7e-4c36-8df9-bb63110d9e2a\n      X-Runtime:\n      - '0.099716'\n      X-Xss-Protection:\n      - '0'\n      Cf-Cache-Status:\n      - DYNAMIC\n      Report-To:\n      - '{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=aDn7ApAO9Ma86gZ%2BJKCUCFjH2Re%2BtXdB5gcqYj2KTGXJKNpgf5TNgzbrp5%2Bw%2FGL5nTvtp%2B7cxT8MMcLWjAV6Ne1r6z5YBFq1K4W7Zw5m1lhMiqYLnTnEs2Oq85TjzOvpsE%2BmC33d\"}],\"group\":\"cf-nel\",\"max_age\":604800}'\n      Nel:\n      - '{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}'\n      Speculation-Rules:\n      - '\"/cdn-cgi/speculation\"'\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 940b10910abdd2ec-ORD\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n      Server-Timing:\n      - cfL4;desc=\"?proto=TCP&rtt=28163&min_rtt=27237&rtt_var=12066&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=905&delivery_rate=83590&cwnd=239&unsent_bytes=0&cid=7ef62bd693b52ccd&ts=240&x=0\"\n    body:\n      encoding: ASCII-8BIT\n      string: '{\"data\":{\"ticker\":\"AAPL\",\"name\":\"Apple Inc.\",\"links\":{\"homepage_url\":\"https://www.apple.com\"},\"logo_url\":\"https://logo.synthfinance.com/ticker/AAPL\",\"description\":\"Apple\n        Inc. designs, manufactures, and markets smartphones, personal computers, tablets,\n        wearables, and accessories worldwide. The company offers iPhone, a line of\n        smartphones; Mac, a line of personal computers; iPad, a line of multi-purpose\n        tablets; and wearables, home, and accessories comprising AirPods, Apple TV,\n        Apple Watch, Beats products, and HomePod. It also provides AppleCare support\n        and cloud services; and operates various platforms, including the App Store\n        that allow customers to discover and download applications and digital content,\n        such as books, music, video, games, and podcasts. In addition, the company\n        offers various services, such as Apple Arcade, a game subscription service;\n        Apple Fitness+, a personalized fitness service; Apple Music, which offers\n        users a curated listening experience with on-demand radio stations; Apple\n        News+, a subscription news and magazine service; Apple TV+, which offers exclusive\n        original content; Apple Card, a co-branded credit card; and Apple Pay, a cashless\n        payment service, as well as licenses its intellectual property. The company\n        serves consumers, and small and mid-sized businesses; and the education, enterprise,\n        and government markets. It distributes third-party applications for its products\n        through the App Store. The company also sells its products through its retail\n        and online stores, and direct sales force; and third-party cellular network\n        carriers, wholesalers, retailers, and resellers. Apple Inc. was founded in\n        1976 and is headquartered in Cupertino, California.\",\"kind\":\"common stock\",\"cik\":\"0000320193\",\"currency\":\"USD\",\"address\":{\"country\":\"USA\",\"address_line1\":\"One\n        Apple Park Way\",\"city\":\"Cupertino\",\"state\":\"CA\",\"postal_code\":\"95014\"},\"exchange\":{\"name\":\"Nasdaq/Ngs\n        (Global Select Market)\",\"mic_code\":\"XNGS\",\"operating_mic_code\":\"XNAS\",\"acronym\":\"NGS\",\"country\":\"United\n        States\",\"country_code\":\"US\",\"timezone\":\"America/New_York\"},\"ceo\":\"Mr. Timothy\n        D. Cook\",\"founding_year\":1976,\"industry\":\"Consumer Electronics\",\"sector\":\"Technology\",\"phone\":\"408-996-1010\",\"total_employees\":161000,\"composite_figi\":\"BBG000B9Y5X2\",\"market_data\":{\"high_today\":212.96,\"low_today\":209.54,\"open_today\":210.95,\"close_today\":211.45,\"volume_today\":44979900.0,\"fifty_two_week_high\":260.1,\"fifty_two_week_low\":169.21,\"average_volume\":61769396.875,\"price_change\":0.0,\"percent_change\":0.0}},\"meta\":{\"credits_used\":1,\"credits_remaining\":249737}}'\n  recorded_at: Fri, 16 May 2025 13:01:37 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/synth/security_price.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: get\n    uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&operating_mic_code=XNAS&page=1&start_date=2024-08-01\n    body:\n      encoding: US-ASCII\n      string: ''\n    headers:\n      Authorization:\n      - Bearer <SYNTH_API_KEY>\n      X-Source:\n      - maybe_app\n      X-Source-Type:\n      - managed\n      User-Agent:\n      - Faraday v2.13.1\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Fri, 16 May 2025 13:01:36 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Cache-Control:\n      - max-age=0, private, must-revalidate\n      Etag:\n      - W/\"72340d82266397447b865407dda15492\"\n      Referrer-Policy:\n      - strict-origin-when-cross-origin\n      Rndr-Id:\n      - 4c3462aa-2471-40b4\n      Strict-Transport-Security:\n      - max-age=63072000; includeSubDomains\n      Vary:\n      - Accept-Encoding\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - SAMEORIGIN\n      X-Permitted-Cross-Domain-Policies:\n      - none\n      X-Render-Origin-Server:\n      - Render\n      X-Request-Id:\n      - bdbc757d-2528-44c3-ae08-9788e8ee15f7\n      X-Runtime:\n      - '0.034898'\n      X-Xss-Protection:\n      - '0'\n      Cf-Cache-Status:\n      - DYNAMIC\n      Report-To:\n      - '{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=2Mu4PK4XTsAq%2Bn1%2F2yxy%2Blj7kz3ZCiQ9t8ikr2m19BrhQhrqfeUQfPwxbLc1WIgGMIxpPInKYtDVIX3En%2FGpTNQLAeu%2FpuLKv%2BRmCx%2B7u28od5L%2F9%2BLmEhFWqJjs8Y6C1O2a3SKv\"}],\"group\":\"cf-nel\",\"max_age\":604800}'\n      Nel:\n      - '{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}'\n      Speculation-Rules:\n      - '\"/cdn-cgi/speculation\"'\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 940b108f29129d03-ORD\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n      Server-Timing:\n      - cfL4;desc=\"?proto=TCP&rtt=27793&min_rtt=26182&rtt_var=13041&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2827&recv_bytes=970&delivery_rate=74111&cwnd=244&unsent_bytes=0&cid=9bcc030369a615fb&ts=210&x=0\"\n    body:\n      encoding: ASCII-8BIT\n      string: '{\"ticker\":\"AAPL\",\"currency\":\"USD\",\"exchange\":{\"name\":\"Nasdaq/Ngs (Global\n        Select Market)\",\"mic_code\":\"XNGS\",\"operating_mic_code\":\"XNAS\",\"acronym\":\"NGS\",\"country\":\"United\n        States\",\"country_code\":\"US\",\"timezone\":\"America/New_York\"},\"prices\":[{\"date\":\"2024-08-01\",\"open\":224.37,\"close\":218.36,\"high\":224.48,\"low\":217.02,\"volume\":62501000}],\"paging\":{\"prev\":\"/tickers/AAPL/open-close?end_date=2024-08-01\\u0026operating_mic_code=XNAS\\u0026page=\\u0026start_date=2024-08-01\",\"next\":\"/tickers/AAPL/open-close?end_date=2024-08-01\\u0026operating_mic_code=XNAS\\u0026page=\\u0026start_date=2024-08-01\",\"total_records\":1,\"current_page\":1,\"per_page\":100,\"total_pages\":1},\"meta\":{\"total_records\":1,\"credits_used\":1,\"credits_remaining\":249738}}'\n  recorded_at: Fri, 16 May 2025 13:01:36 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/synth/security_prices.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: get\n    uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&operating_mic_code=XNAS&page=1&start_date=2024-01-01\n    body:\n      encoding: US-ASCII\n      string: ''\n    headers:\n      Authorization:\n      - Bearer <SYNTH_API_KEY>\n      X-Source:\n      - maybe_app\n      X-Source-Type:\n      - managed\n      User-Agent:\n      - Faraday v2.13.1\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Fri, 16 May 2025 13:01:37 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Cache-Control:\n      - max-age=0, private, must-revalidate\n      Etag:\n      - W/\"909e48e0b9ed1f892c1a1e1b4abd3082\"\n      Referrer-Policy:\n      - strict-origin-when-cross-origin\n      Rndr-Id:\n      - 63af1418-59b9-4111\n      Strict-Transport-Security:\n      - max-age=63072000; includeSubDomains\n      Vary:\n      - Accept-Encoding\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - SAMEORIGIN\n      X-Permitted-Cross-Domain-Policies:\n      - none\n      X-Render-Origin-Server:\n      - Render\n      X-Request-Id:\n      - 74da6a68-0bbd-48fb-b52a-0c5a750bd925\n      X-Runtime:\n      - '0.044404'\n      X-Xss-Protection:\n      - '0'\n      Cf-Cache-Status:\n      - DYNAMIC\n      Report-To:\n      - '{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=nwA0tsfR9it%2B9%2BtfHGjyzyfiSqPdNGxQqOLNF%2BIqlTeT1wJT6gLDCtbd1WFpOc1f8UXm2Zjn%2FJDOf7jOKWmGN6SKUBBjZvFLlBq%2FWyC7DN55NJwwyO77vD%2F5nf%2FaqduWCdPx7n7m\"}],\"group\":\"cf-nel\",\"max_age\":604800}'\n      Nel:\n      - '{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}'\n      Speculation-Rules:\n      - '\"/cdn-cgi/speculation\"'\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 940b10932ec92305-ORD\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n      Server-Timing:\n      - cfL4;desc=\"?proto=TCP&rtt=27451&min_rtt=26715&rtt_var=11491&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=970&delivery_rate=88818&cwnd=249&unsent_bytes=0&cid=63105dfc059c15ef&ts=344&x=0\"\n    body:\n      encoding: ASCII-8BIT\n      string: '{\"ticker\":\"AAPL\",\"currency\":\"USD\",\"exchange\":{\"name\":\"Nasdaq/Ngs (Global\n        Select Market)\",\"mic_code\":\"XNGS\",\"operating_mic_code\":\"XNAS\",\"acronym\":\"NGS\",\"country\":\"United\n        States\",\"country_code\":\"US\",\"timezone\":\"America/New_York\"},\"prices\":[{\"date\":\"2024-01-02\",\"open\":187.15,\"close\":185.64,\"high\":188.44,\"low\":183.89,\"volume\":82488700},{\"date\":\"2024-01-03\",\"open\":184.22,\"close\":184.25,\"high\":185.88,\"low\":183.43,\"volume\":58414500},{\"date\":\"2024-01-04\",\"open\":182.15,\"close\":181.91,\"high\":183.09,\"low\":180.88,\"volume\":71983600},{\"date\":\"2024-01-05\",\"open\":181.99,\"close\":181.18,\"high\":182.76,\"low\":180.17,\"volume\":62303300},{\"date\":\"2024-01-08\",\"open\":182.09,\"close\":185.56,\"high\":185.6,\"low\":181.5,\"volume\":59144500},{\"date\":\"2024-01-09\",\"open\":183.92,\"close\":185.14,\"high\":185.15,\"low\":182.73,\"volume\":42841800},{\"date\":\"2024-01-10\",\"open\":184.35,\"close\":186.19,\"high\":186.4,\"low\":183.92,\"volume\":46792900},{\"date\":\"2024-01-11\",\"open\":186.54,\"close\":185.59,\"high\":187.05,\"low\":183.62,\"volume\":49128400},{\"date\":\"2024-01-12\",\"open\":186.06,\"close\":185.92,\"high\":186.74,\"low\":185.19,\"volume\":40444700},{\"date\":\"2024-01-16\",\"open\":182.16,\"close\":183.63,\"high\":184.26,\"low\":180.93,\"volume\":65603000},{\"date\":\"2024-01-17\",\"open\":181.27,\"close\":182.68,\"high\":182.93,\"low\":180.3,\"volume\":47317400},{\"date\":\"2024-01-18\",\"open\":186.09,\"close\":188.63,\"high\":189.14,\"low\":185.83,\"volume\":78005800},{\"date\":\"2024-01-19\",\"open\":189.33,\"close\":191.56,\"high\":191.95,\"low\":188.82,\"volume\":68741000},{\"date\":\"2024-01-22\",\"open\":192.3,\"close\":193.89,\"high\":195.33,\"low\":192.26,\"volume\":60133900},{\"date\":\"2024-01-23\",\"open\":195.02,\"close\":195.18,\"high\":195.75,\"low\":193.83,\"volume\":42355600},{\"date\":\"2024-01-24\",\"open\":195.42,\"close\":194.5,\"high\":196.38,\"low\":194.34,\"volume\":53631300},{\"date\":\"2024-01-25\",\"open\":195.22,\"close\":194.17,\"high\":196.27,\"low\":193.11,\"volume\":54822100},{\"date\":\"2024-01-26\",\"open\":194.27,\"close\":192.42,\"high\":194.76,\"low\":191.94,\"volume\":44594000},{\"date\":\"2024-01-29\",\"open\":192.01,\"close\":191.73,\"high\":192.2,\"low\":189.58,\"volume\":47145600},{\"date\":\"2024-01-30\",\"open\":190.94,\"close\":188.04,\"high\":191.8,\"low\":187.47,\"volume\":55859400},{\"date\":\"2024-01-31\",\"open\":187.04,\"close\":184.4,\"high\":187.1,\"low\":184.35,\"volume\":55467800},{\"date\":\"2024-02-01\",\"open\":183.99,\"close\":186.86,\"high\":186.95,\"low\":183.82,\"volume\":64885400},{\"date\":\"2024-02-02\",\"open\":179.86,\"close\":185.85,\"high\":187.33,\"low\":179.25,\"volume\":102518000},{\"date\":\"2024-02-05\",\"open\":188.15,\"close\":187.68,\"high\":189.25,\"low\":185.84,\"volume\":69668800},{\"date\":\"2024-02-06\",\"open\":186.86,\"close\":189.3,\"high\":189.31,\"low\":186.77,\"volume\":43490800},{\"date\":\"2024-02-07\",\"open\":190.64,\"close\":189.41,\"high\":191.05,\"low\":188.61,\"volume\":53439000},{\"date\":\"2024-02-08\",\"open\":189.39,\"close\":188.32,\"high\":189.54,\"low\":187.35,\"volume\":40962000},{\"date\":\"2024-02-09\",\"open\":188.65,\"close\":188.85,\"high\":189.99,\"low\":188.0,\"volume\":45155200},{\"date\":\"2024-02-12\",\"open\":188.42,\"close\":187.15,\"high\":188.67,\"low\":186.79,\"volume\":41781900},{\"date\":\"2024-02-13\",\"open\":185.77,\"close\":185.04,\"high\":186.21,\"low\":183.51,\"volume\":56529500},{\"date\":\"2024-02-14\",\"open\":185.32,\"close\":184.15,\"high\":185.53,\"low\":182.44,\"volume\":54630500},{\"date\":\"2024-02-15\",\"open\":183.55,\"close\":183.86,\"high\":184.49,\"low\":181.35,\"volume\":65434500},{\"date\":\"2024-02-16\",\"open\":183.42,\"close\":182.31,\"high\":184.85,\"low\":181.67,\"volume\":49701400},{\"date\":\"2024-02-20\",\"open\":181.79,\"close\":181.56,\"high\":182.43,\"low\":180.0,\"volume\":53665600},{\"date\":\"2024-02-21\",\"open\":181.94,\"close\":182.32,\"high\":182.89,\"low\":180.66,\"volume\":41529700},{\"date\":\"2024-02-22\",\"open\":183.48,\"close\":184.37,\"high\":184.96,\"low\":182.46,\"volume\":52292200},{\"date\":\"2024-02-23\",\"open\":185.01,\"close\":182.52,\"high\":185.04,\"low\":182.23,\"volume\":45119700},{\"date\":\"2024-02-26\",\"open\":182.24,\"close\":181.16,\"high\":182.76,\"low\":180.65,\"volume\":40867400},{\"date\":\"2024-02-27\",\"open\":181.1,\"close\":182.63,\"high\":183.92,\"low\":179.56,\"volume\":54318900},{\"date\":\"2024-02-28\",\"open\":182.51,\"close\":181.42,\"high\":183.12,\"low\":180.13,\"volume\":48953900},{\"date\":\"2024-02-29\",\"open\":181.27,\"close\":180.75,\"high\":182.57,\"low\":179.53,\"volume\":136682600},{\"date\":\"2024-03-01\",\"open\":179.55,\"close\":179.66,\"high\":180.53,\"low\":177.38,\"volume\":73488000},{\"date\":\"2024-03-04\",\"open\":176.15,\"close\":175.1,\"high\":176.9,\"low\":173.79,\"volume\":81510100},{\"date\":\"2024-03-05\",\"open\":170.76,\"close\":170.12,\"high\":172.04,\"low\":169.62,\"volume\":95132400},{\"date\":\"2024-03-06\",\"open\":171.06,\"close\":169.12,\"high\":171.24,\"low\":168.68,\"volume\":68587700},{\"date\":\"2024-03-07\",\"open\":169.15,\"close\":169.0,\"high\":170.73,\"low\":168.49,\"volume\":71765100},{\"date\":\"2024-03-08\",\"open\":169.0,\"close\":170.73,\"high\":173.7,\"low\":168.94,\"volume\":76114600},{\"date\":\"2024-03-11\",\"open\":172.94,\"close\":172.75,\"high\":174.38,\"low\":172.05,\"volume\":60139500},{\"date\":\"2024-03-12\",\"open\":173.15,\"close\":173.23,\"high\":174.03,\"low\":171.01,\"volume\":59825400},{\"date\":\"2024-03-13\",\"open\":172.77,\"close\":171.13,\"high\":173.19,\"low\":170.76,\"volume\":52488700},{\"date\":\"2024-03-14\",\"open\":172.91,\"close\":173.0,\"high\":174.31,\"low\":172.05,\"volume\":72913500},{\"date\":\"2024-03-15\",\"open\":171.17,\"close\":172.62,\"high\":172.62,\"low\":170.29,\"volume\":121664700},{\"date\":\"2024-03-18\",\"open\":175.57,\"close\":173.72,\"high\":177.71,\"low\":173.52,\"volume\":75604200},{\"date\":\"2024-03-19\",\"open\":174.34,\"close\":176.08,\"high\":176.61,\"low\":173.03,\"volume\":55215200},{\"date\":\"2024-03-20\",\"open\":175.72,\"close\":178.67,\"high\":178.67,\"low\":175.09,\"volume\":53423100},{\"date\":\"2024-03-21\",\"open\":177.05,\"close\":171.37,\"high\":177.49,\"low\":170.84,\"volume\":106181300},{\"date\":\"2024-03-22\",\"open\":171.76,\"close\":172.28,\"high\":173.05,\"low\":170.06,\"volume\":71106600},{\"date\":\"2024-03-25\",\"open\":170.57,\"close\":170.85,\"high\":171.94,\"low\":169.45,\"volume\":54288300},{\"date\":\"2024-03-26\",\"open\":170.0,\"close\":169.71,\"high\":171.42,\"low\":169.58,\"volume\":57388400},{\"date\":\"2024-03-27\",\"open\":170.41,\"close\":173.31,\"high\":173.6,\"low\":170.11,\"volume\":60273300},{\"date\":\"2024-03-28\",\"open\":171.75,\"close\":171.48,\"high\":172.23,\"low\":170.51,\"volume\":65672700},{\"date\":\"2024-04-01\",\"open\":171.19,\"close\":170.03,\"high\":171.25,\"low\":169.48,\"volume\":46240500},{\"date\":\"2024-04-02\",\"open\":169.08,\"close\":168.84,\"high\":169.34,\"low\":168.23,\"volume\":49329500},{\"date\":\"2024-04-03\",\"open\":168.79,\"close\":169.65,\"high\":170.68,\"low\":168.58,\"volume\":47691700},{\"date\":\"2024-04-04\",\"open\":170.29,\"close\":168.82,\"high\":171.92,\"low\":168.82,\"volume\":53704400},{\"date\":\"2024-04-05\",\"open\":169.59,\"close\":169.58,\"high\":170.39,\"low\":168.95,\"volume\":42055200},{\"date\":\"2024-04-08\",\"open\":169.03,\"close\":168.45,\"high\":169.2,\"low\":168.24,\"volume\":37425500},{\"date\":\"2024-04-09\",\"open\":168.7,\"close\":169.67,\"high\":170.08,\"low\":168.35,\"volume\":42451200},{\"date\":\"2024-04-10\",\"open\":168.8,\"close\":167.78,\"high\":169.09,\"low\":167.11,\"volume\":49709300},{\"date\":\"2024-04-11\",\"open\":168.34,\"close\":175.04,\"high\":175.46,\"low\":168.16,\"volume\":91070300},{\"date\":\"2024-04-12\",\"open\":174.26,\"close\":176.55,\"high\":178.36,\"low\":174.21,\"volume\":101593300},{\"date\":\"2024-04-15\",\"open\":175.36,\"close\":172.69,\"high\":176.63,\"low\":172.5,\"volume\":73531800},{\"date\":\"2024-04-16\",\"open\":171.75,\"close\":169.38,\"high\":173.76,\"low\":168.27,\"volume\":73711200},{\"date\":\"2024-04-17\",\"open\":169.61,\"close\":168.0,\"high\":170.65,\"low\":168.0,\"volume\":50901200},{\"date\":\"2024-04-18\",\"open\":168.03,\"close\":167.04,\"high\":168.64,\"low\":166.55,\"volume\":43122900},{\"date\":\"2024-04-19\",\"open\":166.21,\"close\":165.0,\"high\":166.4,\"low\":164.08,\"volume\":67772100},{\"date\":\"2024-04-22\",\"open\":165.52,\"close\":165.84,\"high\":167.26,\"low\":164.77,\"volume\":48116400},{\"date\":\"2024-04-23\",\"open\":165.35,\"close\":166.9,\"high\":167.05,\"low\":164.92,\"volume\":49537800},{\"date\":\"2024-04-24\",\"open\":166.54,\"close\":169.02,\"high\":169.3,\"low\":166.21,\"volume\":48251800},{\"date\":\"2024-04-25\",\"open\":169.53,\"close\":169.89,\"high\":170.61,\"low\":168.15,\"volume\":50558300},{\"date\":\"2024-04-26\",\"open\":169.88,\"close\":169.3,\"high\":171.34,\"low\":169.18,\"volume\":44838400},{\"date\":\"2024-04-29\",\"open\":173.37,\"close\":173.5,\"high\":176.03,\"low\":173.1,\"volume\":68169400},{\"date\":\"2024-04-30\",\"open\":173.33,\"close\":170.33,\"high\":174.99,\"low\":170.0,\"volume\":65934800},{\"date\":\"2024-05-01\",\"open\":169.58,\"close\":169.3,\"high\":172.71,\"low\":169.11,\"volume\":50383100},{\"date\":\"2024-05-02\",\"open\":172.51,\"close\":173.03,\"high\":173.42,\"low\":170.89,\"volume\":94214900},{\"date\":\"2024-05-03\",\"open\":186.65,\"close\":183.38,\"high\":187.0,\"low\":182.66,\"volume\":163224100},{\"date\":\"2024-05-06\",\"open\":182.35,\"close\":181.71,\"high\":184.2,\"low\":180.42,\"volume\":78569700},{\"date\":\"2024-05-07\",\"open\":183.45,\"close\":182.4,\"high\":184.9,\"low\":181.32,\"volume\":77305800},{\"date\":\"2024-05-08\",\"open\":182.85,\"close\":182.74,\"high\":183.07,\"low\":181.45,\"volume\":45057100},{\"date\":\"2024-05-09\",\"open\":182.56,\"close\":184.57,\"high\":184.66,\"low\":182.11,\"volume\":48983000},{\"date\":\"2024-05-10\",\"open\":184.9,\"close\":183.05,\"high\":185.09,\"low\":182.13,\"volume\":50759500},{\"date\":\"2024-05-13\",\"open\":185.44,\"close\":186.28,\"high\":187.1,\"low\":184.62,\"volume\":72044800},{\"date\":\"2024-05-14\",\"open\":187.51,\"close\":187.43,\"high\":188.3,\"low\":186.29,\"volume\":52393600},{\"date\":\"2024-05-15\",\"open\":187.91,\"close\":189.72,\"high\":190.65,\"low\":187.37,\"volume\":70400000},{\"date\":\"2024-05-16\",\"open\":190.47,\"close\":189.84,\"high\":191.1,\"low\":189.66,\"volume\":52845200},{\"date\":\"2024-05-17\",\"open\":189.51,\"close\":189.87,\"high\":190.81,\"low\":189.18,\"volume\":41282900},{\"date\":\"2024-05-20\",\"open\":189.33,\"close\":191.04,\"high\":191.92,\"low\":189.01,\"volume\":44361300},{\"date\":\"2024-05-21\",\"open\":191.09,\"close\":192.35,\"high\":192.73,\"low\":190.92,\"volume\":42309400},{\"date\":\"2024-05-22\",\"open\":192.27,\"close\":190.9,\"high\":192.82,\"low\":190.27,\"volume\":34648500},{\"date\":\"2024-05-23\",\"open\":190.98,\"close\":186.88,\"high\":191.0,\"low\":186.63,\"volume\":51005900}],\"paging\":{\"prev\":\"/tickers/AAPL/open-close?end_date=2024-08-01\\u0026operating_mic_code=XNAS\\u0026page=\\u0026start_date=2024-01-01\",\"next\":\"/tickers/AAPL/open-close?end_date=2024-08-01\\u0026operating_mic_code=XNAS\\u0026page=2\\u0026start_date=2024-01-01\",\"total_records\":147,\"current_page\":1,\"per_page\":100,\"total_pages\":2},\"meta\":{\"total_records\":147,\"credits_used\":1,\"credits_remaining\":249736}}'\n  recorded_at: Fri, 16 May 2025 13:01:37 GMT\n- request:\n    method: get\n    uri: https://api.synthfinance.com/tickers/AAPL/open-close?end_date=2024-08-01&operating_mic_code=XNAS&page=2&start_date=2024-01-01\n    body:\n      encoding: US-ASCII\n      string: ''\n    headers:\n      Authorization:\n      - Bearer <SYNTH_API_KEY>\n      X-Source:\n      - maybe_app\n      X-Source-Type:\n      - managed\n      User-Agent:\n      - Faraday v2.13.1\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Fri, 16 May 2025 13:01:37 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Cache-Control:\n      - max-age=0, private, must-revalidate\n      Etag:\n      - W/\"bbc82ef9591694561dd9992a9c06d491\"\n      Referrer-Policy:\n      - strict-origin-when-cross-origin\n      Rndr-Id:\n      - 63ebee52-f1b2-4e81\n      Strict-Transport-Security:\n      - max-age=63072000; includeSubDomains\n      Vary:\n      - Accept-Encoding\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - SAMEORIGIN\n      X-Permitted-Cross-Domain-Policies:\n      - none\n      X-Render-Origin-Server:\n      - Render\n      X-Request-Id:\n      - dd95cb59-aead-4d1e-b1a2-881696e742fb\n      X-Runtime:\n      - '0.031482'\n      X-Xss-Protection:\n      - '0'\n      Cf-Cache-Status:\n      - DYNAMIC\n      Report-To:\n      - '{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=ebrOdAId1yoCYepT0CPKImNIA%2BOe8V3W3BHYheEOkVQFLsffFpfl%2B%2BYXfEHL21wczvW5dkZSd3OrF%2FklB%2FwGGDahXpveXzf497azY1Ho4YJrtDJeghxyZV6J%2BALPYwwpGrfUpv%2F1\"}],\"group\":\"cf-nel\",\"max_age\":604800}'\n      Nel:\n      - '{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}'\n      Speculation-Rules:\n      - '\"/cdn-cgi/speculation\"'\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 940b1095ccd41156-ORD\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n      Server-Timing:\n      - cfL4;desc=\"?proto=TCP&rtt=26344&min_rtt=26162&rtt_var=10175&sent=4&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=970&delivery_rate=104847&cwnd=237&unsent_bytes=0&cid=9603fe0eb1df39aa&ts=212&x=0\"\n    body:\n      encoding: ASCII-8BIT\n      string: '{\"ticker\":\"AAPL\",\"currency\":\"USD\",\"exchange\":{\"name\":\"Nasdaq/Ngs (Global\n        Select Market)\",\"mic_code\":\"XNGS\",\"operating_mic_code\":\"XNAS\",\"acronym\":\"NGS\",\"country\":\"United\n        States\",\"country_code\":\"US\",\"timezone\":\"America/New_York\"},\"prices\":[{\"date\":\"2024-05-24\",\"open\":188.82,\"close\":189.98,\"high\":190.58,\"low\":188.04,\"volume\":36294600},{\"date\":\"2024-05-28\",\"open\":191.51,\"close\":189.99,\"high\":193.0,\"low\":189.1,\"volume\":52280100},{\"date\":\"2024-05-29\",\"open\":189.61,\"close\":190.29,\"high\":192.25,\"low\":189.51,\"volume\":53068000},{\"date\":\"2024-05-30\",\"open\":190.76,\"close\":191.29,\"high\":192.18,\"low\":190.63,\"volume\":49947900},{\"date\":\"2024-05-31\",\"open\":191.44,\"close\":192.25,\"high\":192.57,\"low\":189.91,\"volume\":75158300},{\"date\":\"2024-06-03\",\"open\":192.9,\"close\":194.03,\"high\":194.99,\"low\":192.52,\"volume\":50080500},{\"date\":\"2024-06-04\",\"open\":194.64,\"close\":194.35,\"high\":195.32,\"low\":193.03,\"volume\":47471400},{\"date\":\"2024-06-05\",\"open\":195.4,\"close\":195.87,\"high\":196.9,\"low\":194.87,\"volume\":54156800},{\"date\":\"2024-06-06\",\"open\":195.69,\"close\":194.48,\"high\":196.5,\"low\":194.17,\"volume\":41181800},{\"date\":\"2024-06-07\",\"open\":194.65,\"close\":196.89,\"high\":196.94,\"low\":194.14,\"volume\":53103900},{\"date\":\"2024-06-10\",\"open\":196.9,\"close\":193.12,\"high\":197.3,\"low\":192.15,\"volume\":97262100},{\"date\":\"2024-06-11\",\"open\":193.65,\"close\":207.15,\"high\":207.16,\"low\":193.63,\"volume\":172373300},{\"date\":\"2024-06-12\",\"open\":207.37,\"close\":213.07,\"high\":220.2,\"low\":206.9,\"volume\":198134300},{\"date\":\"2024-06-13\",\"open\":214.74,\"close\":214.24,\"high\":216.75,\"low\":211.6,\"volume\":97862700},{\"date\":\"2024-06-14\",\"open\":213.85,\"close\":212.49,\"high\":215.17,\"low\":211.3,\"volume\":70122700},{\"date\":\"2024-06-17\",\"open\":213.37,\"close\":216.67,\"high\":218.95,\"low\":212.72,\"volume\":93728300},{\"date\":\"2024-06-18\",\"open\":217.59,\"close\":214.29,\"high\":218.63,\"low\":213.0,\"volume\":79943300},{\"date\":\"2024-06-20\",\"open\":213.93,\"close\":209.68,\"high\":214.24,\"low\":208.85,\"volume\":86172500},{\"date\":\"2024-06-21\",\"open\":210.39,\"close\":207.49,\"high\":211.89,\"low\":207.11,\"volume\":246421400},{\"date\":\"2024-06-24\",\"open\":207.72,\"close\":208.14,\"high\":212.7,\"low\":206.59,\"volume\":80727000},{\"date\":\"2024-06-25\",\"open\":209.15,\"close\":209.07,\"high\":211.38,\"low\":208.61,\"volume\":56713900},{\"date\":\"2024-06-26\",\"open\":211.5,\"close\":213.25,\"high\":214.86,\"low\":210.64,\"volume\":66213200},{\"date\":\"2024-06-27\",\"open\":214.69,\"close\":214.1,\"high\":215.74,\"low\":212.35,\"volume\":49772700},{\"date\":\"2024-06-28\",\"open\":215.77,\"close\":210.62,\"high\":216.07,\"low\":210.3,\"volume\":82542700},{\"date\":\"2024-07-01\",\"open\":212.09,\"close\":216.75,\"high\":217.51,\"low\":211.92,\"volume\":60402900},{\"date\":\"2024-07-02\",\"open\":216.15,\"close\":220.27,\"high\":220.38,\"low\":215.1,\"volume\":58046200},{\"date\":\"2024-07-03\",\"open\":220.0,\"close\":221.55,\"high\":221.55,\"low\":219.03,\"volume\":37369800},{\"date\":\"2024-07-05\",\"open\":221.65,\"close\":226.34,\"high\":226.45,\"low\":221.65,\"volume\":60412400},{\"date\":\"2024-07-08\",\"open\":227.09,\"close\":227.82,\"high\":227.85,\"low\":223.25,\"volume\":59085900},{\"date\":\"2024-07-09\",\"open\":227.93,\"close\":228.68,\"high\":229.4,\"low\":226.37,\"volume\":48076100},{\"date\":\"2024-07-10\",\"open\":229.3,\"close\":232.98,\"high\":233.08,\"low\":229.25,\"volume\":62627700},{\"date\":\"2024-07-11\",\"open\":231.39,\"close\":227.57,\"high\":232.39,\"low\":225.77,\"volume\":64710600},{\"date\":\"2024-07-12\",\"open\":228.92,\"close\":230.54,\"high\":232.64,\"low\":228.68,\"volume\":53046500},{\"date\":\"2024-07-15\",\"open\":236.48,\"close\":234.4,\"high\":237.23,\"low\":233.09,\"volume\":62631300},{\"date\":\"2024-07-16\",\"open\":235.0,\"close\":234.82,\"high\":236.27,\"low\":232.33,\"volume\":43234300},{\"date\":\"2024-07-17\",\"open\":229.45,\"close\":228.88,\"high\":231.46,\"low\":226.64,\"volume\":57345900},{\"date\":\"2024-07-18\",\"open\":230.28,\"close\":224.18,\"high\":230.44,\"low\":222.27,\"volume\":66034600},{\"date\":\"2024-07-19\",\"open\":224.82,\"close\":224.31,\"high\":226.8,\"low\":223.28,\"volume\":49151500},{\"date\":\"2024-07-22\",\"open\":227.01,\"close\":223.96,\"high\":227.78,\"low\":223.09,\"volume\":48201800},{\"date\":\"2024-07-23\",\"open\":224.37,\"close\":225.01,\"high\":226.94,\"low\":222.68,\"volume\":39960300},{\"date\":\"2024-07-24\",\"open\":224.0,\"close\":218.54,\"high\":224.8,\"low\":217.13,\"volume\":61777600},{\"date\":\"2024-07-25\",\"open\":218.93,\"close\":217.49,\"high\":220.85,\"low\":214.62,\"volume\":51391200},{\"date\":\"2024-07-26\",\"open\":218.7,\"close\":217.96,\"high\":219.49,\"low\":216.01,\"volume\":41601300},{\"date\":\"2024-07-29\",\"open\":216.96,\"close\":218.24,\"high\":219.3,\"low\":215.75,\"volume\":36311800},{\"date\":\"2024-07-30\",\"open\":219.19,\"close\":218.8,\"high\":220.33,\"low\":216.12,\"volume\":41643800},{\"date\":\"2024-07-31\",\"open\":221.44,\"close\":222.08,\"high\":223.82,\"low\":220.63,\"volume\":50036300},{\"date\":\"2024-08-01\",\"open\":224.37,\"close\":218.36,\"high\":224.48,\"low\":217.02,\"volume\":62501000}],\"paging\":{\"prev\":\"/tickers/AAPL/open-close?end_date=2024-08-01\\u0026operating_mic_code=XNAS\\u0026page=1\\u0026start_date=2024-01-01\",\"next\":\"/tickers/AAPL/open-close?end_date=2024-08-01\\u0026operating_mic_code=XNAS\\u0026page=\\u0026start_date=2024-01-01\",\"total_records\":147,\"current_page\":2,\"per_page\":100,\"total_pages\":2},\"meta\":{\"total_records\":147,\"credits_used\":1,\"credits_remaining\":249735}}'\n  recorded_at: Fri, 16 May 2025 13:01:37 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/synth/security_search.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: get\n    uri: https://api.synthfinance.com/tickers/search?country_code=US&dataset=limited&limit=25&name=AAPL\n    body:\n      encoding: US-ASCII\n      string: ''\n    headers:\n      Authorization:\n      - Bearer <SYNTH_API_KEY>\n      X-Source:\n      - maybe_app\n      X-Source-Type:\n      - managed\n      User-Agent:\n      - Faraday v2.13.1\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Fri, 16 May 2025 13:01:38 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Cache-Control:\n      - max-age=0, private, must-revalidate\n      Etag:\n      - W/\"3e444869eacbaf17006766a691cc8fdc\"\n      Referrer-Policy:\n      - strict-origin-when-cross-origin\n      Rndr-Id:\n      - 701ae22a-18c8-4e62\n      Strict-Transport-Security:\n      - max-age=63072000; includeSubDomains\n      Vary:\n      - Accept-Encoding\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - SAMEORIGIN\n      X-Permitted-Cross-Domain-Policies:\n      - none\n      X-Render-Origin-Server:\n      - Render\n      X-Request-Id:\n      - edb55bc6-e3ea-470b-b7af-9b4d9883420b\n      X-Runtime:\n      - '0.355152'\n      X-Xss-Protection:\n      - '0'\n      Cf-Cache-Status:\n      - DYNAMIC\n      Report-To:\n      - '{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=QGeBWdYED%2F%2FgT9BzborFAnM%2FG6UiNmI0ej212XGHWdFwYXUvTJ2GyqA9hMJrpYIvgbHdQ9Ed0MsQUv3KFb57VXQq0T6UXTNPa%2BFRPepK0hsXeGDLxch04v6KnkTATqcw2M8HuYHS\"}],\"group\":\"cf-nel\",\"max_age\":604800}'\n      Nel:\n      - '{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}'\n      Speculation-Rules:\n      - '\"/cdn-cgi/speculation\"'\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 940b1097a830f856-ORD\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n      Server-Timing:\n      - cfL4;desc=\"?proto=TCP&rtt=26401&min_rtt=25556&rtt_var=11273&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2825&recv_bytes=939&delivery_rate=89615&cwnd=244&unsent_bytes=0&cid=cf6d0758d165295d&ts=500&x=0\"\n    body:\n      encoding: ASCII-8BIT\n      string: '{\"data\":[{\"symbol\":\"AAPL\",\"name\":\"Apple Inc.\",\"logo_url\":\"https://logo.synthfinance.com/ticker/AAPL\",\"currency\":\"USD\",\"exchange\":{\"name\":\"Nasdaq/Ngs\n        (Global Select Market)\",\"mic_code\":\"XNGS\",\"operating_mic_code\":\"XNAS\",\"acronym\":\"NGS\",\"country\":\"United\n        States\",\"country_code\":\"US\",\"timezone\":\"America/New_York\"}},{\"symbol\":\"APLY\",\"isin\":\"US88634T8577\",\"name\":\"YieldMax\n        AAPL Option Income ETF\",\"logo_url\":\"https://logo.synthfinance.com/ticker/APLY\",\"currency\":\"USD\",\"exchange\":{\"name\":\"Nyse\n        Arca\",\"mic_code\":\"ARCX\",\"operating_mic_code\":\"XNYS\",\"acronym\":\"NYSE\",\"country\":\"United\n        States\",\"country_code\":\"US\",\"timezone\":\"America/New_York\"}},{\"symbol\":\"AAPD\",\"name\":\"Direxion\n        Daily AAPL Bear 1X ETF\",\"logo_url\":\"https://logo.synthfinance.com/ticker/AAPD\",\"currency\":\"USD\",\"exchange\":{\"name\":\"Nasdaq/Nms\n        (Global Market)\",\"mic_code\":\"XNMS\",\"operating_mic_code\":\"XNAS\",\"acronym\":\"\",\"country\":\"United\n        States\",\"country_code\":\"US\",\"timezone\":\"America/New_York\"}},{\"symbol\":\"AAPU\",\"isin\":\"US25461A8743\",\"name\":\"Direxion\n        Daily AAPL Bull 2X Shares\",\"logo_url\":\"https://logo.synthfinance.com/ticker/AAPU\",\"currency\":\"USD\",\"exchange\":{\"name\":\"Nasdaq/Nms\n        (Global Market)\",\"mic_code\":\"XNMS\",\"operating_mic_code\":\"XNAS\",\"acronym\":\"\",\"country\":\"United\n        States\",\"country_code\":\"US\",\"timezone\":\"America/New_York\"}},{\"symbol\":\"AAPB\",\"isin\":\"XXXXXXXR8842\",\"name\":\"GraniteShares\n        2x Long AAPL Daily ETF\",\"logo_url\":\"https://logo.synthfinance.com/ticker/AAPB\",\"currency\":\"USD\",\"exchange\":{\"name\":\"Nasdaq/Ngs\n        (Global Select Market)\",\"mic_code\":\"XNGS\",\"operating_mic_code\":\"XNAS\",\"acronym\":\"NGS\",\"country\":\"United\n        States\",\"country_code\":\"US\",\"timezone\":\"America/New_York\"}},{\"symbol\":\"AAPD\",\"isin\":\"US25461A3041\",\"name\":\"Direxion\n        Daily AAPL Bear 1X Shares\",\"logo_url\":\"https://logo.synthfinance.com/ticker/AAPD\",\"currency\":\"USD\",\"exchange\":{\"name\":\"Nasdaq/Ngs\n        (Global Select Market)\",\"mic_code\":\"XNGS\",\"operating_mic_code\":\"XNAS\",\"acronym\":\"NGS\",\"country\":\"United\n        States\",\"country_code\":\"US\",\"timezone\":\"America/New_York\"}},{\"symbol\":\"AAPU\",\"isin\":\"US25461A8743\",\"name\":\"Direxion\n        Daily AAPL Bull 1.5X Shares\",\"logo_url\":\"https://logo.synthfinance.com/ticker/AAPU\",\"currency\":\"USD\",\"exchange\":{\"name\":\"Nasdaq/Ngs\n        (Global Select Market)\",\"mic_code\":\"XNGS\",\"operating_mic_code\":\"XNAS\",\"acronym\":\"NGS\",\"country\":\"United\n        States\",\"country_code\":\"US\",\"timezone\":\"America/New_York\"}},{\"symbol\":\"AAPJ\",\"isin\":\"US00037T1034\",\"name\":\"AAP,\n        Inc.\",\"logo_url\":\"https://logo.synthfinance.com/ticker/AAPJ\",\"currency\":\"USD\",\"exchange\":{\"name\":\"Otc\n        Pink Marketplace\",\"mic_code\":\"PINX\",\"operating_mic_code\":\"OTCM\",\"acronym\":\"\",\"country\":\"United\n        States\",\"country_code\":\"US\",\"timezone\":\"America/New_York\"}}]}'\n  recorded_at: Fri, 16 May 2025 13:01:38 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "test/vcr_cassettes/synth/usage.yml",
    "content": "---\nhttp_interactions:\n- request:\n    method: get\n    uri: https://api.synthfinance.com/user\n    body:\n      encoding: US-ASCII\n      string: ''\n    headers:\n      Authorization:\n      - Bearer <SYNTH_API_KEY>\n      X-Source:\n      - maybe_app\n      X-Source-Type:\n      - managed\n      User-Agent:\n      - Faraday v2.13.1\n      Accept-Encoding:\n      - gzip;q=1.0,deflate;q=0.6,identity;q=0.3\n      Accept:\n      - \"*/*\"\n  response:\n    status:\n      code: 200\n      message: OK\n    headers:\n      Date:\n      - Fri, 16 May 2025 13:01:36 GMT\n      Content-Type:\n      - application/json; charset=utf-8\n      Transfer-Encoding:\n      - chunked\n      Connection:\n      - keep-alive\n      Cache-Control:\n      - max-age=0, private, must-revalidate\n      Etag:\n      - W/\"7b8c2bf0cba54bc26b78bdc6e611dcbd\"\n      Referrer-Policy:\n      - strict-origin-when-cross-origin\n      Rndr-Id:\n      - 1b53adf6-b391-45b2\n      Strict-Transport-Security:\n      - max-age=63072000; includeSubDomains\n      Vary:\n      - Accept-Encoding\n      X-Content-Type-Options:\n      - nosniff\n      X-Frame-Options:\n      - SAMEORIGIN\n      X-Permitted-Cross-Domain-Policies:\n      - none\n      X-Render-Origin-Server:\n      - Render\n      X-Request-Id:\n      - f88670a2-81d2-48b6-8d73-a911c846e330\n      X-Runtime:\n      - '0.018749'\n      X-Xss-Protection:\n      - '0'\n      Cf-Cache-Status:\n      - DYNAMIC\n      Report-To:\n      - '{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=oH4OsWB6itK0jpi%2FPs%2BswVyCZIbkJGPfyJaoR4TKFtTAfmnqa8Lp6aZhv22WKzotXJuAKbh99VdYdZIOkeIPWbYTc6j4rGw%2BkQB3Hw%2Fc44QxDBJFdIo6wJNe8TGiPAZ%2BvgoBVHWn\"}],\"group\":\"cf-nel\",\"max_age\":604800}'\n      Nel:\n      - '{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}'\n      Speculation-Rules:\n      - '\"/cdn-cgi/speculation\"'\n      Server:\n      - cloudflare\n      Cf-Ray:\n      - 940b108c38f66392-ORD\n      Alt-Svc:\n      - h3=\":443\"; ma=86400\n      Server-Timing:\n      - cfL4;desc=\"?proto=TCP&rtt=33369&min_rtt=25798&rtt_var=15082&sent=5&recv=6&lost=0&retrans=0&sent_bytes=2826&recv_bytes=878&delivery_rate=112256&cwnd=205&unsent_bytes=0&cid=1b13324eb0768fd3&ts=285&x=0\"\n    body:\n      encoding: ASCII-8BIT\n      string: '{\"id\":\"user_3208c49393f54b3e974795e4bea5b864\",\"email\":\"zach@maybe.co\",\"name\":\"Zach\n        Gollwitzer\",\"plan\":\"Business\",\"api_calls_remaining\":249738,\"api_limit\":250000,\"credits_reset_at\":\"2025-06-01T00:00:00.000-04:00\",\"current_period_start\":\"2025-05-01T00:00:00.000-04:00\"}'\n  recorded_at: Fri, 16 May 2025 13:01:36 GMT\nrecorded_with: VCR 6.3.1\n"
  },
  {
    "path": "tmp/.keep",
    "content": ""
  },
  {
    "path": "vendor/.keep",
    "content": ""
  },
  {
    "path": "vendor/javascript/.keep",
    "content": ""
  },
  {
    "path": "vendor/javascript/@floating-ui--core.js",
    "content": "import{getSideAxis as t,getAlignmentAxis as e,getAxisLength as n,getSide as o,getAlignment as s,evaluate as c,getPaddingObject as i,rectToClientRect as r,min as l,clamp as a,getOppositeAlignmentPlacement as f,placements as m,getAlignmentSides as u,getOppositePlacement as d,getExpandedPlacements as g,getOppositeAxisPlacements as p,sides as y,max as h,getOppositeAxis as w}from\"@floating-ui/utils\";export{rectToClientRect}from\"@floating-ui/utils\";function computeCoordsFromPlacement(c,i,r){let{reference:l,floating:a}=c;const f=t(i);const m=e(i);const u=n(m);const d=o(i);const g=f===\"y\";const p=l.x+l.width/2-a.width/2;const y=l.y+l.height/2-a.height/2;const h=l[u]/2-a[u]/2;let w;switch(d){case\"top\":w={x:p,y:l.y-a.height};break;case\"bottom\":w={x:p,y:l.y+l.height};break;case\"right\":w={x:l.x+l.width,y:y};break;case\"left\":w={x:l.x-a.width,y:y};break;default:w={x:l.x,y:l.y}}switch(s(i)){case\"start\":w[m]-=h*(r&&g?-1:1);break;case\"end\":w[m]+=h*(r&&g?-1:1);break}return w}const computePosition=async(t,e,n)=>{const{placement:o=\"bottom\",strategy:s=\"absolute\",middleware:c=[],platform:i}=n;const r=c.filter(Boolean);const l=await(i.isRTL==null?void 0:i.isRTL(e));let a=await i.getElementRects({reference:t,floating:e,strategy:s});let{x:f,y:m}=computeCoordsFromPlacement(a,o,l);let u=o;let d={};let g=0;for(let n=0;n<r.length;n++){const{name:c,fn:p}=r[n];const{x:y,y:h,data:w,reset:v}=await p({x:f,y:m,initialPlacement:o,placement:u,strategy:s,middlewareData:d,rects:a,platform:i,elements:{reference:t,floating:e}});f=y!=null?y:f;m=h!=null?h:m;d={...d,[c]:{...d[c],...w}};if(v&&g<=50){g++;if(typeof v===\"object\"){v.placement&&(u=v.placement);v.rects&&(a=v.rects===true?await i.getElementRects({reference:t,floating:e,strategy:s}):v.rects);({x:f,y:m}=computeCoordsFromPlacement(a,u,l))}n=-1}}return{x:f,y:m,placement:u,strategy:s,middlewareData:d}};async function detectOverflow(t,e){var n;e===void 0&&(e={});const{x:o,y:s,platform:l,rects:a,elements:f,strategy:m}=t;const{boundary:u=\"clippingAncestors\",rootBoundary:d=\"viewport\",elementContext:g=\"floating\",altBoundary:p=false,padding:y=0}=c(e,t);const h=i(y);const w=g===\"floating\"?\"reference\":\"floating\";const v=f[p?w:g];const x=r(await l.getClippingRect({element:(n=await(l.isElement==null?void 0:l.isElement(v)))==null||n?v:v.contextElement||await(l.getDocumentElement==null?void 0:l.getDocumentElement(f.floating)),boundary:u,rootBoundary:d,strategy:m}));const b=g===\"floating\"?{x:o,y:s,width:a.floating.width,height:a.floating.height}:a.reference;const R=await(l.getOffsetParent==null?void 0:l.getOffsetParent(f.floating));const A=await(l.isElement==null?void 0:l.isElement(R))&&await(l.getScale==null?void 0:l.getScale(R))||{x:1,y:1};const O=r(l.convertOffsetParentRelativeRectToViewportRelativeRect?await l.convertOffsetParentRelativeRectToViewportRelativeRect({elements:f,rect:b,offsetParent:R,strategy:m}):b);return{top:(x.top-O.top+h.top)/A.y,bottom:(O.bottom-x.bottom+h.bottom)/A.y,left:(x.left-O.left+h.left)/A.x,right:(O.right-x.right+h.right)/A.x}}const arrow=t=>({name:\"arrow\",options:t,async fn(o){const{x:r,y:f,placement:m,rects:u,platform:d,elements:g,middlewareData:p}=o;const{element:y,padding:h=0}=c(t,o)||{};if(y==null)return{};const w=i(h);const v={x:r,y:f};const x=e(m);const b=n(x);const R=await d.getDimensions(y);const A=x===\"y\";const O=A?\"top\":\"left\";const P=A?\"bottom\":\"right\";const C=A?\"clientHeight\":\"clientWidth\";const T=u.reference[b]+u.reference[x]-v[x]-u.floating[b];const L=v[x]-u.reference[x];const B=await(d.getOffsetParent==null?void 0:d.getOffsetParent(y));let D=B?B[C]:0;D&&await(d.isElement==null?void 0:d.isElement(B))||(D=g.floating[C]||u.floating[b]);const E=T/2-L/2;const k=D/2-R[b]/2-1;const S=l(w[O],k);const F=l(w[P],k);const H=S;const V=D-R[b]-F;const W=D/2-R[b]/2+E;const j=a(H,W,V);const z=!p.arrow&&s(m)!=null&&W!==j&&u.reference[b]/2-(W<H?S:F)-R[b]/2<0;const q=z?W<H?W-H:W-V:0;return{[x]:v[x]+q,data:{[x]:j,centerOffset:W-j-q,...z&&{alignmentOffset:q}},reset:z}}});function getPlacementList(t,e,n){const c=t?[...n.filter((e=>s(e)===t)),...n.filter((e=>s(e)!==t))]:n.filter((t=>o(t)===t));return c.filter((n=>!t||(s(n)===t||!!e&&f(n)!==n)))}const autoPlacement=function(t){t===void 0&&(t={});return{name:\"autoPlacement\",options:t,async fn(e){var n,i,r;const{rects:l,middlewareData:a,placement:f,platform:d,elements:g}=e;const{crossAxis:p=false,alignment:y,allowedPlacements:h=m,autoAlignment:w=true,...v}=c(t,e);const x=y!==void 0||h===m?getPlacementList(y||null,w,h):h;const b=await detectOverflow(e,v);const R=((n=a.autoPlacement)==null?void 0:n.index)||0;const A=x[R];if(A==null)return{};const O=u(A,l,await(d.isRTL==null?void 0:d.isRTL(g.floating)));if(f!==A)return{reset:{placement:x[0]}};const P=[b[o(A)],b[O[0]],b[O[1]]];const C=[...((i=a.autoPlacement)==null?void 0:i.overflows)||[],{placement:A,overflows:P}];const T=x[R+1];if(T)return{data:{index:R+1,overflows:C},reset:{placement:T}};const L=C.map((t=>{const e=s(t.placement);return[t.placement,e&&p?t.overflows.slice(0,2).reduce(((t,e)=>t+e),0):t.overflows[0],t.overflows]})).sort(((t,e)=>t[1]-e[1]));const B=L.filter((t=>t[2].slice(0,s(t[0])?2:3).every((t=>t<=0))));const D=((r=B[0])==null?void 0:r[0])||L[0][0];return D!==f?{data:{index:R+1,overflows:C},reset:{placement:D}}:{}}}};const flip=function(e){e===void 0&&(e={});return{name:\"flip\",options:e,async fn(n){var s,i;const{placement:r,middlewareData:l,rects:a,initialPlacement:f,platform:m,elements:y}=n;const{mainAxis:h=true,crossAxis:w=true,fallbackPlacements:v,fallbackStrategy:x=\"bestFit\",fallbackAxisSideDirection:b=\"none\",flipAlignment:R=true,...A}=c(e,n);if((s=l.arrow)!=null&&s.alignmentOffset)return{};const O=o(r);const P=t(f);const C=o(f)===f;const T=await(m.isRTL==null?void 0:m.isRTL(y.floating));const L=v||(C||!R?[d(f)]:g(f));const B=b!==\"none\";!v&&B&&L.push(...p(f,R,b,T));const D=[f,...L];const E=await detectOverflow(n,A);const k=[];let S=((i=l.flip)==null?void 0:i.overflows)||[];h&&k.push(E[O]);if(w){const t=u(r,a,T);k.push(E[t[0]],E[t[1]])}S=[...S,{placement:r,overflows:k}];if(!k.every((t=>t<=0))){var F,H;const e=(((F=l.flip)==null?void 0:F.index)||0)+1;const n=D[e];if(n)return{data:{index:e,overflows:S},reset:{placement:n}};let o=(H=S.filter((t=>t.overflows[0]<=0)).sort(((t,e)=>t.overflows[1]-e.overflows[1]))[0])==null?void 0:H.placement;if(!o)switch(x){case\"bestFit\":{var V;const e=(V=S.filter((e=>{if(B){const n=t(e.placement);return n===P||n===\"y\"}return true})).map((t=>[t.placement,t.overflows.filter((t=>t>0)).reduce(((t,e)=>t+e),0)])).sort(((t,e)=>t[1]-e[1]))[0])==null?void 0:V[0];e&&(o=e);break}case\"initialPlacement\":o=f;break}if(r!==o)return{reset:{placement:o}}}return{}}}};function getSideOffsets(t,e){return{top:t.top-e.height,right:t.right-e.width,bottom:t.bottom-e.height,left:t.left-e.width}}function isAnySideFullyClipped(t){return y.some((e=>t[e]>=0))}const hide=function(t){t===void 0&&(t={});return{name:\"hide\",options:t,async fn(e){const{rects:n}=e;const{strategy:o=\"referenceHidden\",...s}=c(t,e);switch(o){case\"referenceHidden\":{const t=await detectOverflow(e,{...s,elementContext:\"reference\"});const o=getSideOffsets(t,n.reference);return{data:{referenceHiddenOffsets:o,referenceHidden:isAnySideFullyClipped(o)}}}case\"escaped\":{const t=await detectOverflow(e,{...s,altBoundary:true});const o=getSideOffsets(t,n.floating);return{data:{escapedOffsets:o,escaped:isAnySideFullyClipped(o)}}}default:return{}}}}};function getBoundingRect(t){const e=l(...t.map((t=>t.left)));const n=l(...t.map((t=>t.top)));const o=h(...t.map((t=>t.right)));const s=h(...t.map((t=>t.bottom)));return{x:e,y:n,width:o-e,height:s-n}}function getRectsByLine(t){const e=t.slice().sort(((t,e)=>t.y-e.y));const n=[];let o=null;for(let t=0;t<e.length;t++){const s=e[t];!o||s.y-o.y>o.height/2?n.push([s]):n[n.length-1].push(s);o=s}return n.map((t=>r(getBoundingRect(t))))}const inline=function(e){e===void 0&&(e={});return{name:\"inline\",options:e,async fn(n){const{placement:s,elements:a,rects:f,platform:m,strategy:u}=n;const{padding:d=2,x:g,y:p}=c(e,n);const y=Array.from(await(m.getClientRects==null?void 0:m.getClientRects(a.reference))||[]);const w=getRectsByLine(y);const v=r(getBoundingRect(y));const x=i(d);function getBoundingClientRect(){if(w.length===2&&w[0].left>w[1].right&&g!=null&&p!=null)return w.find((t=>g>t.left-x.left&&g<t.right+x.right&&p>t.top-x.top&&p<t.bottom+x.bottom))||v;if(w.length>=2){if(t(s)===\"y\"){const t=w[0];const e=w[w.length-1];const n=o(s)===\"top\";const c=t.top;const i=e.bottom;const r=n?t.left:e.left;const l=n?t.right:e.right;const a=l-r;const f=i-c;return{top:c,bottom:i,left:r,right:l,width:a,height:f,x:r,y:c}}const e=o(s)===\"left\";const n=h(...w.map((t=>t.right)));const c=l(...w.map((t=>t.left)));const i=w.filter((t=>e?t.left===c:t.right===n));const r=i[0].top;const a=i[i.length-1].bottom;const f=c;const m=n;const u=m-f;const d=a-r;return{top:r,bottom:a,left:f,right:m,width:u,height:d,x:f,y:r}}return v}const b=await m.getElementRects({reference:{getBoundingClientRect:getBoundingClientRect},floating:a.floating,strategy:u});return f.reference.x!==b.reference.x||f.reference.y!==b.reference.y||f.reference.width!==b.reference.width||f.reference.height!==b.reference.height?{reset:{rects:b}}:{}}}};async function convertValueToCoords(e,n){const{placement:i,platform:r,elements:l}=e;const a=await(r.isRTL==null?void 0:r.isRTL(l.floating));const f=o(i);const m=s(i);const u=t(i)===\"y\";const d=[\"left\",\"top\"].includes(f)?-1:1;const g=a&&u?-1:1;const p=c(n,e);let{mainAxis:y,crossAxis:h,alignmentAxis:w}=typeof p===\"number\"?{mainAxis:p,crossAxis:0,alignmentAxis:null}:{mainAxis:0,crossAxis:0,alignmentAxis:null,...p};m&&typeof w===\"number\"&&(h=m===\"end\"?w*-1:w);return u?{x:h*g,y:y*d}:{x:y*d,y:h*g}}const offset=function(t){t===void 0&&(t=0);return{name:\"offset\",options:t,async fn(e){var n,o;const{x:s,y:c,placement:i,middlewareData:r}=e;const l=await convertValueToCoords(e,t);return i===((n=r.offset)==null?void 0:n.placement)&&(o=r.arrow)!=null&&o.alignmentOffset?{}:{x:s+l.x,y:c+l.y,data:{...l,placement:i}}}}};const shift=function(e){e===void 0&&(e={});return{name:\"shift\",options:e,async fn(n){const{x:s,y:i,placement:r}=n;const{mainAxis:l=true,crossAxis:f=false,limiter:m={fn:t=>{let{x:e,y:n}=t;return{x:e,y:n}}},...u}=c(e,n);const d={x:s,y:i};const g=await detectOverflow(n,u);const p=t(o(r));const y=w(p);let h=d[y];let v=d[p];if(l){const t=y===\"y\"?\"top\":\"left\";const e=y===\"y\"?\"bottom\":\"right\";const n=h+g[t];const o=h-g[e];h=a(n,h,o)}if(f){const t=p===\"y\"?\"top\":\"left\";const e=p===\"y\"?\"bottom\":\"right\";const n=v+g[t];const o=v-g[e];v=a(n,v,o)}const x=m.fn({...n,[y]:h,[p]:v});return{...x,data:{x:x.x-s,y:x.y-i}}}}};const limitShift=function(e){e===void 0&&(e={});return{options:e,fn(n){const{x:s,y:i,placement:r,rects:l,middlewareData:a}=n;const{offset:f=0,mainAxis:m=true,crossAxis:u=true}=c(e,n);const d={x:s,y:i};const g=t(r);const p=w(g);let y=d[p];let h=d[g];const v=c(f,n);const x=typeof v===\"number\"?{mainAxis:v,crossAxis:0}:{mainAxis:0,crossAxis:0,...v};if(m){const t=p===\"y\"?\"height\":\"width\";const e=l.reference[p]-l.floating[t]+x.mainAxis;const n=l.reference[p]+l.reference[t]-x.mainAxis;y<e?y=e:y>n&&(y=n)}if(u){var b,R;const t=p===\"y\"?\"width\":\"height\";const e=[\"top\",\"left\"].includes(o(r));const n=l.reference[g]-l.floating[t]+(e&&((b=a.offset)==null?void 0:b[g])||0)+(e?0:x.crossAxis);const s=l.reference[g]+l.reference[t]+(e?0:((R=a.offset)==null?void 0:R[g])||0)-(e?x.crossAxis:0);h<n?h=n:h>s&&(h=s)}return{[p]:y,[g]:h}}}};const size=function(e){e===void 0&&(e={});return{name:\"size\",options:e,async fn(n){const{placement:i,rects:r,platform:a,elements:f}=n;const{apply:m=(()=>{}),...u}=c(e,n);const d=await detectOverflow(n,u);const g=o(i);const p=s(i);const y=t(i)===\"y\";const{width:w,height:v}=r.floating;let x;let b;if(g===\"top\"||g===\"bottom\"){x=g;b=p===(await(a.isRTL==null?void 0:a.isRTL(f.floating))?\"start\":\"end\")?\"left\":\"right\"}else{b=g;x=p===\"end\"?\"top\":\"bottom\"}const R=v-d.top-d.bottom;const A=w-d.left-d.right;const O=l(v-d[x],R);const P=l(w-d[b],A);const C=!n.middlewareData.shift;let T=O;let L=P;y?L=p||C?l(P,A):A:T=p||C?l(O,R):R;if(C&&!p){const t=h(d.left,0);const e=h(d.right,0);const n=h(d.top,0);const o=h(d.bottom,0);y?L=w-2*(t!==0||e!==0?t+e:h(d.left,d.right)):T=v-2*(n!==0||o!==0?n+o:h(d.top,d.bottom))}await m({...n,availableWidth:L,availableHeight:T});const B=await a.getDimensions(f.floating);return w!==B.width||v!==B.height?{reset:{rects:true}}:{}}}};export{arrow,autoPlacement,computePosition,detectOverflow,flip,hide,inline,limitShift,offset,shift,size};\n\n"
  },
  {
    "path": "vendor/javascript/@floating-ui--dom.js",
    "content": "import{rectToClientRect as t,detectOverflow as e,offset as n,autoPlacement as i,shift as o,flip as s,size as c,hide as r,arrow as l,inline as f,limitShift as a,computePosition as u}from\"@floating-ui/core\";import{round as g,createCoords as h,max as d,min as p,floor as m}from\"@floating-ui/utils\";import{getComputedStyle as w,isHTMLElement as x,isElement as R,getWindow as v,isWebKit as y,getFrameElement as C,getDocumentElement as O,isTopLayer as b,getNodeName as T,isOverflowElement as P,getNodeScroll as L,getParentNode as B,isLastTraversableNode as S,getOverflowAncestors as A,isContainingBlock as F,isTableElement as E,getContainingBlock as V}from\"@floating-ui/utils/dom\";export{getOverflowAncestors}from\"@floating-ui/utils/dom\";function getCssDimensions(t){const e=w(t);let n=parseFloat(e.width)||0;let i=parseFloat(e.height)||0;const o=x(t);const s=o?t.offsetWidth:n;const c=o?t.offsetHeight:i;const r=g(n)!==s||g(i)!==c;if(r){n=s;i=c}return{width:n,height:i,$:r}}function unwrapElement(t){return R(t)?t:t.contextElement}function getScale(t){const e=unwrapElement(t);if(!x(e))return h(1);const n=e.getBoundingClientRect();const{width:i,height:o,$:s}=getCssDimensions(e);let c=(s?g(n.width):n.width)/i;let r=(s?g(n.height):n.height)/o;c&&Number.isFinite(c)||(c=1);r&&Number.isFinite(r)||(r=1);return{x:c,y:r}}const W=h(0);function getVisualOffsets(t){const e=v(t);return y()&&e.visualViewport?{x:e.visualViewport.offsetLeft,y:e.visualViewport.offsetTop}:W}function shouldAddVisualOffsets(t,e,n){e===void 0&&(e=false);return!(!n||e&&n!==v(t))&&e}function getBoundingClientRect(e,n,i,o){n===void 0&&(n=false);i===void 0&&(i=false);const s=e.getBoundingClientRect();const c=unwrapElement(e);let r=h(1);n&&(o?R(o)&&(r=getScale(o)):r=getScale(e));const l=shouldAddVisualOffsets(c,i,o)?getVisualOffsets(c):h(0);let f=(s.left+l.x)/r.x;let a=(s.top+l.y)/r.y;let u=s.width/r.x;let g=s.height/r.y;if(c){const t=v(c);const e=o&&R(o)?v(o):o;let n=t;let i=C(n);while(i&&o&&e!==n){const t=getScale(i);const e=i.getBoundingClientRect();const o=w(i);const s=e.left+(i.clientLeft+parseFloat(o.paddingLeft))*t.x;const c=e.top+(i.clientTop+parseFloat(o.paddingTop))*t.y;f*=t.x;a*=t.y;u*=t.x;g*=t.y;f+=s;a+=c;n=v(i);i=C(n)}}return t({width:u,height:g,x:f,y:a})}function convertOffsetParentRelativeRectToViewportRelativeRect(t){let{elements:e,rect:n,offsetParent:i,strategy:o}=t;const s=o===\"fixed\";const c=O(i);const r=!!e&&b(e.floating);if(i===c||r&&s)return n;let l={scrollLeft:0,scrollTop:0};let f=h(1);const a=h(0);const u=x(i);if(u||!u&&!s){(T(i)!==\"body\"||P(c))&&(l=L(i));if(x(i)){const t=getBoundingClientRect(i);f=getScale(i);a.x=t.x+i.clientLeft;a.y=t.y+i.clientTop}}return{width:n.width*f.x,height:n.height*f.y,x:n.x*f.x-l.scrollLeft*f.x+a.x,y:n.y*f.y-l.scrollTop*f.y+a.y}}function getClientRects(t){return Array.from(t.getClientRects())}function getWindowScrollBarX(t){return getBoundingClientRect(O(t)).left+L(t).scrollLeft}function getDocumentRect(t){const e=O(t);const n=L(t);const i=t.ownerDocument.body;const o=d(e.scrollWidth,e.clientWidth,i.scrollWidth,i.clientWidth);const s=d(e.scrollHeight,e.clientHeight,i.scrollHeight,i.clientHeight);let c=-n.scrollLeft+getWindowScrollBarX(t);const r=-n.scrollTop;w(i).direction===\"rtl\"&&(c+=d(e.clientWidth,i.clientWidth)-o);return{width:o,height:s,x:c,y:r}}function getViewportRect(t,e){const n=v(t);const i=O(t);const o=n.visualViewport;let s=i.clientWidth;let c=i.clientHeight;let r=0;let l=0;if(o){s=o.width;c=o.height;const t=y();if(!t||t&&e===\"fixed\"){r=o.offsetLeft;l=o.offsetTop}}return{width:s,height:c,x:r,y:l}}function getInnerBoundingClientRect(t,e){const n=getBoundingClientRect(t,true,e===\"fixed\");const i=n.top+t.clientTop;const o=n.left+t.clientLeft;const s=x(t)?getScale(t):h(1);const c=t.clientWidth*s.x;const r=t.clientHeight*s.y;const l=o*s.x;const f=i*s.y;return{width:c,height:r,x:l,y:f}}function getClientRectFromClippingAncestor(e,n,i){let o;if(n===\"viewport\")o=getViewportRect(e,i);else if(n===\"document\")o=getDocumentRect(O(e));else if(R(n))o=getInnerBoundingClientRect(n,i);else{const t=getVisualOffsets(e);o={...n,x:n.x-t.x,y:n.y-t.y}}return t(o)}function hasFixedPositionAncestor(t,e){const n=B(t);return!(n===e||!R(n)||S(n))&&(w(n).position===\"fixed\"||hasFixedPositionAncestor(n,e))}function getClippingElementAncestors(t,e){const n=e.get(t);if(n)return n;let i=A(t,[],false).filter((t=>R(t)&&T(t)!==\"body\"));let o=null;const s=w(t).position===\"fixed\";let c=s?B(t):t;while(R(c)&&!S(c)){const e=w(c);const n=F(c);n||e.position!==\"fixed\"||(o=null);const r=s?!n&&!o:!n&&e.position===\"static\"&&!!o&&[\"absolute\",\"fixed\"].includes(o.position)||P(c)&&!n&&hasFixedPositionAncestor(t,c);r?i=i.filter((t=>t!==c)):o=e;c=B(c)}e.set(t,i);return i}function getClippingRect(t){let{element:e,boundary:n,rootBoundary:i,strategy:o}=t;const s=n===\"clippingAncestors\"?b(e)?[]:getClippingElementAncestors(e,this._c):[].concat(n);const c=[...s,i];const r=c[0];const l=c.reduce(((t,n)=>{const i=getClientRectFromClippingAncestor(e,n,o);t.top=d(i.top,t.top);t.right=p(i.right,t.right);t.bottom=p(i.bottom,t.bottom);t.left=d(i.left,t.left);return t}),getClientRectFromClippingAncestor(e,r,o));return{width:l.right-l.left,height:l.bottom-l.top,x:l.left,y:l.top}}function getDimensions(t){const{width:e,height:n}=getCssDimensions(t);return{width:e,height:n}}function getRectRelativeToOffsetParent(t,e,n){const i=x(e);const o=O(e);const s=n===\"fixed\";const c=getBoundingClientRect(t,true,s,e);let r={scrollLeft:0,scrollTop:0};const l=h(0);if(i||!i&&!s){(T(e)!==\"body\"||P(o))&&(r=L(e));if(i){const t=getBoundingClientRect(e,true,s,e);l.x=t.x+e.clientLeft;l.y=t.y+e.clientTop}else o&&(l.x=getWindowScrollBarX(o))}const f=c.left+r.scrollLeft-l.x;const a=c.top+r.scrollTop-l.y;return{x:f,y:a,width:c.width,height:c.height}}function isStaticPositioned(t){return w(t).position===\"static\"}function getTrueOffsetParent(t,e){return x(t)&&w(t).position!==\"fixed\"?e?e(t):t.offsetParent:null}function getOffsetParent(t,e){const n=v(t);if(b(t))return n;if(!x(t)){let e=B(t);while(e&&!S(e)){if(R(e)&&!isStaticPositioned(e))return e;e=B(e)}return n}let i=getTrueOffsetParent(t,e);while(i&&E(i)&&isStaticPositioned(i))i=getTrueOffsetParent(i,e);return i&&S(i)&&isStaticPositioned(i)&&!F(i)?n:i||V(t)||n}const getElementRects=async function(t){const e=this.getOffsetParent||getOffsetParent;const n=this.getDimensions;const i=await n(t.floating);return{reference:getRectRelativeToOffsetParent(t.reference,await e(t.floating),t.strategy),floating:{x:0,y:0,width:i.width,height:i.height}}};function isRTL(t){return w(t).direction===\"rtl\"}const D={convertOffsetParentRelativeRectToViewportRelativeRect:convertOffsetParentRelativeRectToViewportRelativeRect,getDocumentElement:O,getClippingRect:getClippingRect,getOffsetParent:getOffsetParent,getElementRects:getElementRects,getClientRects:getClientRects,getDimensions:getDimensions,getScale:getScale,isElement:R,isRTL:isRTL};function observeMove(t,e){let n=null;let i;const o=O(t);function cleanup(){var t;clearTimeout(i);(t=n)==null||t.disconnect();n=null}function refresh(s,c){s===void 0&&(s=false);c===void 0&&(c=1);cleanup();const{left:r,top:l,width:f,height:a}=t.getBoundingClientRect();s||e();if(!f||!a)return;const u=m(l);const g=m(o.clientWidth-(r+f));const h=m(o.clientHeight-(l+a));const w=m(r);const x=-u+\"px \"+-g+\"px \"+-h+\"px \"+-w+\"px\";const R={rootMargin:x,threshold:d(0,p(1,c))||1};let v=true;function handleObserve(t){const e=t[0].intersectionRatio;if(e!==c){if(!v)return refresh();e?refresh(false,e):i=setTimeout((()=>{refresh(false,1e-7)}),1e3)}v=false}try{n=new IntersectionObserver(handleObserve,{...R,root:o.ownerDocument})}catch(t){n=new IntersectionObserver(handleObserve,R)}n.observe(t)}refresh(true);return cleanup}\n/**\n * Automatically updates the position of the floating element when necessary.\n * Should only be called when the floating element is mounted on the DOM or\n * visible on the screen.\n * @returns cleanup function that should be invoked when the floating element is\n * removed from the DOM or hidden from the screen.\n * @see https://floating-ui.com/docs/autoUpdate\n */function autoUpdate(t,e,n,i){i===void 0&&(i={});const{ancestorScroll:o=true,ancestorResize:s=true,elementResize:c=typeof ResizeObserver===\"function\",layoutShift:r=typeof IntersectionObserver===\"function\",animationFrame:l=false}=i;const f=unwrapElement(t);const a=o||s?[...f?A(f):[],...A(e)]:[];a.forEach((t=>{o&&t.addEventListener(\"scroll\",n,{passive:true});s&&t.addEventListener(\"resize\",n)}));const u=f&&r?observeMove(f,n):null;let g=-1;let h=null;if(c){h=new ResizeObserver((t=>{let[i]=t;if(i&&i.target===f&&h){h.unobserve(e);cancelAnimationFrame(g);g=requestAnimationFrame((()=>{var t;(t=h)==null||t.observe(e)}))}n()}));f&&!l&&h.observe(f);h.observe(e)}let d;let p=l?getBoundingClientRect(t):null;l&&frameLoop();function frameLoop(){const e=getBoundingClientRect(t);!p||e.x===p.x&&e.y===p.y&&e.width===p.width&&e.height===p.height||n();p=e;d=requestAnimationFrame(frameLoop)}n();return()=>{var t;a.forEach((t=>{o&&t.removeEventListener(\"scroll\",n);s&&t.removeEventListener(\"resize\",n)}));u==null||u();(t=h)==null||t.disconnect();h=null;l&&cancelAnimationFrame(d)}}const H=e;const z=n;const I=i;const M=o;const X=s;const q=c;const N=r;const U=l;const $=f;const _=a;const computePosition=(t,e,n)=>{const i=new Map;const o={platform:D,...n};const s={...o.platform,_c:i};return u(t,e,{...o,platform:s})};export{U as arrow,I as autoPlacement,autoUpdate,computePosition,H as detectOverflow,X as flip,N as hide,$ as inline,_ as limitShift,z as offset,D as platform,M as shift,q as size};\n\n"
  },
  {
    "path": "vendor/javascript/@floating-ui--utils--dom.js",
    "content": "// @floating-ui/utils/dom@0.2.9 downloaded from https://ga.jspm.io/npm:@floating-ui/utils@0.2.9/dist/floating-ui.utils.dom.mjs\n\nfunction hasWindow(){return typeof window!==\"undefined\"}function getNodeName(e){return isNode(e)?(e.nodeName||\"\").toLowerCase():\"#document\"}function getWindow(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function getDocumentElement(e){var t;return(t=(isNode(e)?e.ownerDocument:e.document)||window.document)==null?void 0:t.documentElement}function isNode(e){return!!hasWindow()&&(e instanceof Node||e instanceof getWindow(e).Node)}function isElement(e){return!!hasWindow()&&(e instanceof Element||e instanceof getWindow(e).Element)}function isHTMLElement(e){return!!hasWindow()&&(e instanceof HTMLElement||e instanceof getWindow(e).HTMLElement)}function isShadowRoot(e){return!(!hasWindow()||typeof ShadowRoot===\"undefined\")&&(e instanceof ShadowRoot||e instanceof getWindow(e).ShadowRoot)}function isOverflowElement(e){const{overflow:t,overflowX:n,overflowY:o,display:r}=getComputedStyle(e);return/auto|scroll|overlay|hidden|clip/.test(t+o+n)&&![\"inline\",\"contents\"].includes(r)}function isTableElement(e){return[\"table\",\"td\",\"th\"].includes(getNodeName(e))}function isTopLayer(e){return[\":popover-open\",\":modal\"].some((t=>{try{return e.matches(t)}catch(e){return false}}))}function isContainingBlock(e){const t=isWebKit();const n=isElement(e)?getComputedStyle(e):e;return[\"transform\",\"translate\",\"scale\",\"rotate\",\"perspective\"].some((e=>!!n[e]&&n[e]!==\"none\"))||!!n.containerType&&n.containerType!==\"normal\"||!t&&!!n.backdropFilter&&n.backdropFilter!==\"none\"||!t&&!!n.filter&&n.filter!==\"none\"||[\"transform\",\"translate\",\"scale\",\"rotate\",\"perspective\",\"filter\"].some((e=>(n.willChange||\"\").includes(e)))||[\"paint\",\"layout\",\"strict\",\"content\"].some((e=>(n.contain||\"\").includes(e)))}function getContainingBlock(e){let t=getParentNode(e);while(isHTMLElement(t)&&!isLastTraversableNode(t)){if(isContainingBlock(t))return t;if(isTopLayer(t))return null;t=getParentNode(t)}return null}function isWebKit(){return!(typeof CSS===\"undefined\"||!CSS.supports)&&CSS.supports(\"-webkit-backdrop-filter\",\"none\")}function isLastTraversableNode(e){return[\"html\",\"body\",\"#document\"].includes(getNodeName(e))}function getComputedStyle(e){return getWindow(e).getComputedStyle(e)}function getNodeScroll(e){return isElement(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function getParentNode(e){if(getNodeName(e)===\"html\")return e;const t=e.assignedSlot||e.parentNode||isShadowRoot(e)&&e.host||getDocumentElement(e);return isShadowRoot(t)?t.host:t}function getNearestOverflowAncestor(e){const t=getParentNode(e);return isLastTraversableNode(t)?e.ownerDocument?e.ownerDocument.body:e.body:isHTMLElement(t)&&isOverflowElement(t)?t:getNearestOverflowAncestor(t)}function getOverflowAncestors(e,t,n){var o;t===void 0&&(t=[]);n===void 0&&(n=true);const r=getNearestOverflowAncestor(e);const i=r===((o=e.ownerDocument)==null?void 0:o.body);const l=getWindow(r);if(i){const e=getFrameElement(l);return t.concat(l,l.visualViewport||[],isOverflowElement(r)?r:[],e&&n?getOverflowAncestors(e):[])}return t.concat(r,getOverflowAncestors(r,[],n))}function getFrameElement(e){return e.parent&&Object.getPrototypeOf(e.parent)?e.frameElement:null}export{getComputedStyle,getContainingBlock,getDocumentElement,getFrameElement,getNearestOverflowAncestor,getNodeName,getNodeScroll,getOverflowAncestors,getParentNode,getWindow,isContainingBlock,isElement,isHTMLElement,isLastTraversableNode,isNode,isOverflowElement,isShadowRoot,isTableElement,isTopLayer,isWebKit};\n\n"
  },
  {
    "path": "vendor/javascript/@floating-ui--utils.js",
    "content": "const t=[\"top\",\"right\",\"bottom\",\"left\"];const e=[\"start\",\"end\"];const n=t.reduce(((t,n)=>t.concat(n,n+\"-\"+e[0],n+\"-\"+e[1])),[]);const i=Math.min;const o=Math.max;const g=Math.round;const c=Math.floor;const createCoords=t=>({x:t,y:t});const s={left:\"right\",right:\"left\",bottom:\"top\",top:\"bottom\"};const r={start:\"end\",end:\"start\"};function clamp(t,e,n){return o(t,i(e,n))}function evaluate(t,e){return typeof t===\"function\"?t(e):t}function getSide(t){return t.split(\"-\")[0]}function getAlignment(t){return t.split(\"-\")[1]}function getOppositeAxis(t){return t===\"x\"?\"y\":\"x\"}function getAxisLength(t){return t===\"y\"?\"height\":\"width\"}function getSideAxis(t){return[\"top\",\"bottom\"].includes(getSide(t))?\"y\":\"x\"}function getAlignmentAxis(t){return getOppositeAxis(getSideAxis(t))}function getAlignmentSides(t,e,n){n===void 0&&(n=false);const i=getAlignment(t);const o=getAlignmentAxis(t);const g=getAxisLength(o);let c=o===\"x\"?i===(n?\"end\":\"start\")?\"right\":\"left\":i===\"start\"?\"bottom\":\"top\";e.reference[g]>e.floating[g]&&(c=getOppositePlacement(c));return[c,getOppositePlacement(c)]}function getExpandedPlacements(t){const e=getOppositePlacement(t);return[getOppositeAlignmentPlacement(t),e,getOppositeAlignmentPlacement(e)]}function getOppositeAlignmentPlacement(t){return t.replace(/start|end/g,(t=>r[t]))}function getSideList(t,e,n){const i=[\"left\",\"right\"];const o=[\"right\",\"left\"];const g=[\"top\",\"bottom\"];const c=[\"bottom\",\"top\"];switch(t){case\"top\":case\"bottom\":return n?e?o:i:e?i:o;case\"left\":case\"right\":return e?g:c;default:return[]}}function getOppositeAxisPlacements(t,e,n,i){const o=getAlignment(t);let g=getSideList(getSide(t),n===\"start\",i);if(o){g=g.map((t=>t+\"-\"+o));e&&(g=g.concat(g.map(getOppositeAlignmentPlacement)))}return g}function getOppositePlacement(t){return t.replace(/left|right|bottom|top/g,(t=>s[t]))}function expandPaddingObject(t){return{top:0,right:0,bottom:0,left:0,...t}}function getPaddingObject(t){return typeof t!==\"number\"?expandPaddingObject(t):{top:t,right:t,bottom:t,left:t}}function rectToClientRect(t){const{x:e,y:n,width:i,height:o}=t;return{width:i,height:o,top:n,left:e,right:e+i,bottom:n+o,x:e,y:n}}export{e as alignments,clamp,createCoords,evaluate,expandPaddingObject,c as floor,getAlignment,getAlignmentAxis,getAlignmentSides,getAxisLength,getExpandedPlacements,getOppositeAlignmentPlacement,getOppositeAxis,getOppositeAxisPlacements,getOppositePlacement,getPaddingObject,getSide,getSideAxis,o as max,i as min,n as placements,rectToClientRect,g as round,t as sides};\n\n"
  },
  {
    "path": "vendor/javascript/@github--hotkey.js",
    "content": "class Leaf{constructor(e){this.children=[];this.parent=e}delete(e){const t=this.children.indexOf(e);if(t===-1)return false;this.children=this.children.slice(0,t).concat(this.children.slice(t+1));this.children.length===0&&this.parent.delete(this);return true}add(e){this.children.push(e);return this}}class RadixTrie{constructor(e){this.parent=null;this.children={};this.parent=e||null}get(e){return this.children[e]}insert(e){let t=this;for(let n=0;n<e.length;n+=1){const i=e[n];let r=t.get(i);if(n===e.length-1){if(r instanceof RadixTrie){t.delete(r);r=null}if(!r){r=new Leaf(t);t.children[i]=r}return r}r instanceof Leaf&&(r=null);if(!r){r=new RadixTrie(t);t.children[i]=r}t=r}return t}delete(e){for(const t in this.children){const n=this.children[t];if(n===e){const e=delete this.children[t];Object.keys(this.children).length===0&&this.parent&&this.parent.delete(this);return e}}return false}}const e={\"¡\":\"1\",\"™\":\"2\",\"£\":\"3\",\"¢\":\"4\",\"∞\":\"5\",\"§\":\"6\",\"¶\":\"7\",\"•\":\"8\",\"ª\":\"9\",\"º\":\"0\",\"–\":\"-\",\"≠\":\"=\",\"⁄\":\"!\",\"€\":\"@\",\"‹\":\"#\",\"›\":\"$\",\"ﬁ\":\"%\",\"ﬂ\":\"^\",\"‡\":\"&\",\"°\":\"*\",\"·\":\"(\",\"‚\":\")\",\"—\":\"_\",\"±\":\"+\",\"œ\":\"q\",\"∑\":\"w\",\"®\":\"r\",\"†\":\"t\",\"¥\":\"y\",\"ø\":\"o\",\"π\":\"p\",\"“\":\"[\",\"‘\":\"]\",\"«\":\"\\\\\",\"Œ\":\"Q\",\"„\":\"W\",\"´\":\"E\",\"‰\":\"R\",\"ˇ\":\"T\",\"Á\":\"Y\",\"¨\":\"U\",\"ˆ\":\"I\",\"Ø\":\"O\",\"∏\":\"P\",\"”\":\"{\",\"’\":\"}\",\"»\":\"|\",\"å\":\"a\",\"ß\":\"s\",\"∂\":\"d\",\"ƒ\":\"f\",\"©\":\"g\",\"˙\":\"h\",\"∆\":\"j\",\"˚\":\"k\",\"¬\":\"l\",\"…\":\";\",\"æ\":\"'\",\"Å\":\"A\",\"Í\":\"S\",\"Î\":\"D\",\"Ï\":\"F\",\"˝\":\"G\",\"Ó\":\"H\",\"Ô\":\"J\",\"\":\"K\",\"Ò\":\"L\",\"Ú\":\":\",\"Æ\":'\"',\"Ω\":\"z\",\"≈\":\"x\",\"ç\":\"c\",\"√\":\"v\",\"∫\":\"b\",\"µ\":\"m\",\"≤\":\",\",\"≥\":\".\",\"÷\":\"/\",\"¸\":\"Z\",\"˛\":\"X\",\"Ç\":\"C\",\"◊\":\"V\",\"ı\":\"B\",\"˜\":\"N\",\"Â\":\"M\",\"¯\":\"<\",\"˘\":\">\",\"¿\":\"?\"};const t={\"`\":\"~\",1:\"!\",2:\"@\",3:\"#\",4:\"$\",5:\"%\",6:\"^\",7:\"&\",8:\"*\",9:\"(\",0:\")\",\"-\":\"_\",\"=\":\"+\",\"[\":\"{\",\"]\":\"}\",\"\\\\\":\"|\",\";\":\":\",\"'\":'\"',\",\":\"<\",\".\":\">\",\"/\":\"?\",q:\"Q\",w:\"W\",e:\"E\",r:\"R\",t:\"T\",y:\"Y\",u:\"U\",i:\"I\",o:\"O\",p:\"P\",a:\"A\",s:\"S\",d:\"D\",f:\"F\",g:\"G\",h:\"H\",j:\"J\",k:\"K\",l:\"L\",z:\"Z\",x:\"X\",c:\"C\",v:\"V\",b:\"B\",n:\"N\",m:\"M\"};const n={\" \":\"Space\",\"+\":\"Plus\"};function eventToHotkeyString(s,o=navigator.platform){var l,c,a;const{ctrlKey:h,altKey:d,metaKey:u,shiftKey:f,key:p}=s;const m=[];const k=[h,d,u,f];for(const[e,t]of k.entries())t&&m.push(i[e]);if(!i.includes(p)){const i=m.includes(\"Alt\")&&r.test(o)&&(l=e[p])!==null&&l!==void 0?l:p;const s=m.includes(\"Shift\")&&r.test(o)&&(c=t[i])!==null&&c!==void 0?c:i;const h=(a=n[s])!==null&&a!==void 0?a:s;m.push(h)}return m.join(\"+\")}const i=[\"Control\",\"Alt\",\"Meta\",\"Shift\"];function normalizeHotkey(e,t){let n;n=localizeMod(e,t);n=sortModifiers(n);return n}const r=/Mac|iPod|iPhone|iPad/i;function localizeMod(e,t=navigator.platform){const n=r.test(t)?\"Meta\":\"Control\";return e.replace(\"Mod\",n)}function sortModifiers(e){const t=e.split(\"+\").pop();const n=[];for(const t of[\"Control\",\"Alt\",\"Meta\",\"Shift\"])e.includes(t)&&n.push(t);t&&n.push(t);return n.join(\"+\")}const s=\" \";class SequenceTracker{constructor({onReset:e}={}){this._path=[];this.timer=null;this.onReset=e}get path(){return this._path}get sequence(){return this._path.join(s)}registerKeypress(e){this._path=[...this._path,eventToHotkeyString(e)];this.startTimer()}reset(){var e;this.killTimer();this._path=[];(e=this.onReset)===null||e===void 0?void 0:e.call(this)}killTimer(){this.timer!=null&&window.clearTimeout(this.timer);this.timer=null}startTimer(){this.killTimer();this.timer=window.setTimeout((()=>this.reset()),SequenceTracker.CHORD_TIMEOUT)}}SequenceTracker.CHORD_TIMEOUT=1500;function normalizeSequence(e){return e.split(s).map((e=>normalizeHotkey(e))).join(s)}function isFormField(e){if(!(e instanceof HTMLElement))return false;const t=e.nodeName.toLowerCase();const n=(e.getAttribute(\"type\")||\"\").toLowerCase();return t===\"select\"||t===\"textarea\"||t===\"input\"&&n!==\"submit\"&&n!==\"reset\"&&n!==\"checkbox\"&&n!==\"radio\"&&n!==\"file\"||e.isContentEditable}function fireDeterminedAction(e,t){const n=new CustomEvent(\"hotkey-fire\",{cancelable:true,detail:{path:t}});const i=!e.dispatchEvent(n);i||(isFormField(e)?e.focus():e.click())}function expandHotkeyToEdges(e){const t=[];let n=[\"\"];let i=false;for(let r=0;r<e.length;r++)if(i&&e[r]===\",\"){t.push(n);n=[\"\"];i=false}else if(e[r]!==s){i=e[r]!==\"+\";n[n.length-1]+=e[r]}else{n.push(\"\");i=false}t.push(n);return t.map((e=>e.map((e=>normalizeHotkey(e))).filter((e=>e!==\"\")))).filter((e=>e.length>0))}const o=new RadixTrie;const l=new WeakMap;let c=o;const a=new SequenceTracker({onReset(){c=o}});function keyDownHandler(e){if(e.defaultPrevented)return;if(!(e.target instanceof Node))return;if(isFormField(e.target)){const t=e.target;if(!t.id)return;if(!t.ownerDocument.querySelector(`[data-hotkey-scope=\"${t.id}\"]`))return}const t=c.get(eventToHotkeyString(e));if(t){a.registerKeypress(e);c=t;if(t instanceof Leaf){const n=e.target;let i=false;let r;const s=isFormField(n);for(let e=t.children.length-1;e>=0;e-=1){r=t.children[e];const o=r.getAttribute(\"data-hotkey-scope\");if(!s&&!o||s&&n.id===o){i=true;break}}if(r&&i){fireDeterminedAction(r,a.path);e.preventDefault()}a.reset()}}else a.reset()}function install(e,t){Object.keys(o.children).length===0&&document.addEventListener(\"keydown\",keyDownHandler);const n=expandHotkeyToEdges(t||e.getAttribute(\"data-hotkey\")||\"\");const i=n.map((t=>o.insert(t).add(e)));l.set(e,i)}function uninstall(e){const t=l.get(e);if(t&&t.length)for(const n of t)n&&n.delete(e);Object.keys(o.children).length===0&&document.removeEventListener(\"keydown\",keyDownHandler)}export{Leaf,RadixTrie,SequenceTracker,eventToHotkeyString,install,normalizeHotkey,normalizeSequence,uninstall};\n\n"
  },
  {
    "path": "vendor/javascript/@simonwep--pickr.js",
    "content": "// @simonwep/pickr@1.9.1 downloaded from https://ga.jspm.io/npm:@simonwep/pickr@1.9.1/dist/pickr.min.js\n\nvar t={};!function(u,h){t=h()}(self,(()=>(()=>{var t={d:(u,h)=>{for(var d in h)t.o(h,d)&&!t.o(u,d)&&Object.defineProperty(u,d,{enumerable:!0,get:h[d]})},o:(t,u)=>Object.prototype.hasOwnProperty.call(t,u),r:t=>{\"undefined\"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"}),Object.defineProperty(t,\"__esModule\",{value:!0})}},u={};t.d(u,{default:()=>E});var h={};function n(t,u,h,d,m={}){u instanceof HTMLCollection||u instanceof NodeList?u=Array.from(u):Array.isArray(u)||(u=[u]),Array.isArray(h)||(h=[h]);for(const S of u)for(const u of h)S[t](u,d,{capture:!1,...m});return Array.prototype.slice.call(arguments,1)}t.r(h),t.d(h,{adjustableInputNumbers:()=>p,createElementFromString:()=>r,createFromTemplate:()=>a,eventPath:()=>l,off:()=>m,on:()=>d,resolveElement:()=>c});const d=n.bind(null,\"addEventListener\"),m=n.bind(null,\"removeEventListener\");function r(t){const u=document.createElement(\"div\");return u.innerHTML=t.trim(),u.firstElementChild}function a(t){const e=(t,u)=>{const h=t.getAttribute(u);return t.removeAttribute(u),h},o=(t,u={})=>{const h=e(t,\":obj\"),d=e(t,\":ref\"),m=h?u[h]={}:u;d&&(u[d]=t);for(const u of Array.from(t.children)){const t=e(u,\":arr\"),h=o(u,t?{}:m);t&&(m[t]||(m[t]=[])).push(Object.keys(h).length?h:u)}return u};return o(r(t))}function l(t){let u=t.path||t.composedPath&&t.composedPath();if(u)return u;let h=t.target.parentElement;for(u=[t.target,h];h=h.parentElement;)u.push(h);return u.push(document,window),u}function c(t){return t instanceof Element?t:\"string\"==typeof t?t.split(/>>/g).reduce(((t,u,h,d)=>(t=t.querySelector(u),h<d.length-1?t.shadowRoot:t)),document):null}function p(t,u=(t=>t)){function o(h){const d=[.001,.01,.1][Number(h.shiftKey||2*h.ctrlKey)]*(h.deltaY<0?1:-1);let m=0,S=t.selectionStart;t.value=t.value.replace(/[\\d.]+/g,((t,h)=>h<=S&&h+t.length>=S?(S=h,u(Number(t),d,m)):(m++,t))),t.focus(),t.setSelectionRange(S,S),h.preventDefault(),t.dispatchEvent(new Event(\"input\"))}d(t,\"focus\",(()=>d(window,\"wheel\",o,{passive:!1}))),d(t,\"blur\",(()=>m(window,\"wheel\",o)))}const{min:S,max:L,floor:B,round:P}=Math;function f(t,u,h){u/=100,h/=100;const d=B(t=t/360*6),m=t-d,S=h*(1-u),L=h*(1-m*u),P=h*(1-(1-m)*u),x=d%6;return[255*[h,L,S,S,P,h][x],255*[P,h,h,L,S,S][x],255*[S,S,P,h,h,L][x]]}function v(t,u,h){const d=(2-(u/=100))*(h/=100)/2;return 0!==d&&(u=1===d?0:d<.5?u*h/(2*d):u*h/(2-2*d)),[t,100*u,100*d]}function b(t,u,h){const d=S(t/=255,u/=255,h/=255),m=L(t,u,h),B=m-d;let P,x;if(0===B)P=x=0;else{x=B/m;const d=((m-t)/6+B/2)/B,S=((m-u)/6+B/2)/B,L=((m-h)/6+B/2)/B;t===m?P=L-S:u===m?P=1/3+d-L:h===m&&(P=2/3+S-d),P<0?P+=1:P>1&&(P-=1)}return[360*P,100*x,100*m]}function y(t,u,h,d){u/=100,h/=100;return[...b(255*(1-S(1,(t/=100)*(1-(d/=100))+d)),255*(1-S(1,u*(1-d)+d)),255*(1-S(1,h*(1-d)+d)))]}function g(t,u,h){u/=100;const d=2*(u*=(h/=100)<.5?h:1-h)/(h+u)*100,m=100*(h+u);return[t,isNaN(d)?0:d,m]}function _(t){return b(...t.match(/.{2}/g).map((t=>parseInt(t,16))))}function w(t){t=t.match(/^[a-zA-Z]+$/)?function(t){if(\"black\"===t.toLowerCase())return\"#000\";const u=document.createElement(\"canvas\").getContext(\"2d\");return u.fillStyle=t,\"#000\"===u.fillStyle?null:u.fillStyle}(t):t;const u={cmyk:/^cmyk\\D+([\\d.]+)\\D+([\\d.]+)\\D+([\\d.]+)\\D+([\\d.]+)/i,rgba:/^rgba?\\D+([\\d.]+)(%?)\\D+([\\d.]+)(%?)\\D+([\\d.]+)(%?)\\D*?(([\\d.]+)(%?)|$)/i,hsla:/^hsla?\\D+([\\d.]+)\\D+([\\d.]+)\\D+([\\d.]+)\\D*?(([\\d.]+)(%?)|$)/i,hsva:/^hsva?\\D+([\\d.]+)\\D+([\\d.]+)\\D+([\\d.]+)\\D*?(([\\d.]+)(%?)|$)/i,hexa:/^#?(([\\dA-Fa-f]{3,4})|([\\dA-Fa-f]{6})|([\\dA-Fa-f]{8}))$/i},o=t=>t.map((t=>/^(|\\d+)\\.\\d+|\\d+$/.test(t)?Number(t):void 0));let h;t:for(const d in u)if(h=u[d].exec(t))switch(d){case\"cmyk\":{const[,t,u,m,S]=o(h);if(t>100||u>100||m>100||S>100)break t;return{values:y(t,u,m,S),type:d}}case\"rgba\":{let[,t,,u,,m,,,S]=o(h);if(t=\"%\"===h[2]?t/100*255:t,u=\"%\"===h[4]?u/100*255:u,m=\"%\"===h[6]?m/100*255:m,S=\"%\"===h[9]?S/100:S,t>255||u>255||m>255||S<0||S>1)break t;return{values:[...b(t,u,m),S],a:S,type:d}}case\"hexa\":{let[,t]=h;4!==t.length&&3!==t.length||(t=t.split(\"\").map((t=>t+t)).join(\"\"));const u=t.substring(0,6);let m=t.substring(6);return m=m?parseInt(m,16)/255:void 0,{values:[..._(u),m],a:m,type:d}}case\"hsla\":{let[,t,u,m,,S]=o(h);if(S=\"%\"===h[6]?S/100:S,t>360||u>100||m>100||S<0||S>1)break t;return{values:[...g(t,u,m),S],a:S,type:d}}case\"hsva\":{let[,t,u,m,,S]=o(h);if(S=\"%\"===h[6]?S/100:S,t>360||u>100||m>100||S<0||S>1)break t;return{values:[t,u,m,S],a:S,type:d}}}return{values:null,type:null}}function A(t=0,u=0,h=0,d=1){const i=(t,u)=>(h=-1)=>u(~h?t.map((t=>Number(t.toFixed(h)))):t),m={h:t,s:u,v:h,a:d,toHSVA(){const t=[m.h,m.s,m.v,m.a];return t.toString=i(t,(t=>`hsva(${t[0]}, ${t[1]}%, ${t[2]}%, ${m.a})`)),t},toHSLA(){const t=[...v(m.h,m.s,m.v),m.a];return t.toString=i(t,(t=>`hsla(${t[0]}, ${t[1]}%, ${t[2]}%, ${m.a})`)),t},toRGBA(){const t=[...f(m.h,m.s,m.v),m.a];return t.toString=i(t,(t=>`rgba(${t[0]}, ${t[1]}, ${t[2]}, ${m.a})`)),t},toCMYK(){const t=function(t,u,h){const d=f(t,u,h),m=d[0]/255,L=d[1]/255,B=d[2]/255,P=S(1-m,1-L,1-B);return[100*(1===P?0:(1-m-P)/(1-P)),100*(1===P?0:(1-L-P)/(1-P)),100*(1===P?0:(1-B-P)/(1-P)),100*P]}(m.h,m.s,m.v);return t.toString=i(t,(t=>`cmyk(${t[0]}%, ${t[1]}%, ${t[2]}%, ${t[3]}%)`)),t},toHEXA(){const t=function(t,u,h){return f(t,u,h).map((t=>P(t).toString(16).padStart(2,\"0\")))}(m.h,m.s,m.v),u=m.a>=1?\"\":Number((255*m.a).toFixed(0)).toString(16).toUpperCase().padStart(2,\"0\");return u&&t.push(u),t.toString=()=>`#${t.join(\"\").toUpperCase()}`,t},clone:()=>A(m.h,m.s,m.v,m.a)};return m}const $=t=>Math.max(Math.min(t,1),0);function C(t){const u={options:Object.assign({lock:null,onchange:()=>0,onstop:()=>0},t),_keyboard(t){const{options:h}=u,{type:d,key:m}=t;if(document.activeElement===h.wrapper){const{lock:h}=u.options,S=\"ArrowUp\"===m,L=\"ArrowRight\"===m,B=\"ArrowDown\"===m,P=\"ArrowLeft\"===m;if(\"keydown\"===d&&(S||L||B||P)){let d=0,m=0;\"v\"===h?d=S||L?1:-1:\"h\"===h?d=S||L?-1:1:(m=S?-1:B?1:0,d=P?-1:L?1:0),u.update($(u.cache.x+.01*d),$(u.cache.y+.01*m)),t.preventDefault()}else m.startsWith(\"Arrow\")&&(u.options.onstop(),t.preventDefault())}},_tapstart(t){d(document,[\"mouseup\",\"touchend\",\"touchcancel\"],u._tapstop),d(document,[\"mousemove\",\"touchmove\"],u._tapmove),t.cancelable&&t.preventDefault(),u._tapmove(t)},_tapmove(t){const{options:h,cache:d}=u,{lock:m,element:S,wrapper:L}=h,B=L.getBoundingClientRect();let P=0,x=0;if(t){const u=t&&t.touches&&t.touches[0];P=t?(u||t).clientX:0,x=t?(u||t).clientY:0,P<B.left?P=B.left:P>B.left+B.width&&(P=B.left+B.width),x<B.top?x=B.top:x>B.top+B.height&&(x=B.top+B.height),P-=B.left,x-=B.top}else d&&(P=d.x*B.width,x=d.y*B.height);\"h\"!==m&&(S.style.left=`calc(${P/B.width*100}% - ${S.offsetWidth/2}px)`),\"v\"!==m&&(S.style.top=`calc(${x/B.height*100}% - ${S.offsetHeight/2}px)`),u.cache={x:P/B.width,y:x/B.height};const R=$(P/B.width),D=$(x/B.height);switch(m){case\"v\":return h.onchange(R);case\"h\":return h.onchange(D);default:return h.onchange(R,D)}},_tapstop(){u.options.onstop(),m(document,[\"mouseup\",\"touchend\",\"touchcancel\"],u._tapstop),m(document,[\"mousemove\",\"touchmove\"],u._tapmove)},trigger(){u._tapmove()},update(t=0,h=0){const{left:d,top:m,width:S,height:L}=u.options.wrapper.getBoundingClientRect();\"h\"===u.options.lock&&(h=t),u._tapmove({clientX:d+S*t,clientY:m+L*h})},destroy(){const{options:t,_tapstart:h,_keyboard:d}=u;m(document,[\"keydown\",\"keyup\"],d),m([t.wrapper,t.element],\"mousedown\",h),m([t.wrapper,t.element],\"touchstart\",h,{passive:!1})}},{options:h,_tapstart:S,_keyboard:L}=u;return d([h.wrapper,h.element],\"mousedown\",S),d([h.wrapper,h.element],\"touchstart\",S,{passive:!1}),d(document,[\"keydown\",\"keyup\"],L),u}function k(t={}){t=Object.assign({onchange:()=>0,className:\"\",elements:[]},t);const u=d(t.elements,\"click\",(u=>{t.elements.forEach((h=>h.classList[u.target===h?\"add\":\"remove\"](t.className))),t.onchange(u),u.stopPropagation()}));return{destroy:()=>m(...u)}}const x={variantFlipOrder:{start:\"sme\",middle:\"mse\",end:\"ems\"},positionFlipOrder:{top:\"tbrl\",right:\"rltb\",bottom:\"btrl\",left:\"lrbt\"},position:\"bottom\",margin:8,padding:0},O=(t,u,h)=>{const d=\"object\"!=typeof t||t instanceof HTMLElement?{reference:t,popper:u,...h}:t;return{update(t=d){const{reference:u,popper:h}=Object.assign(d,t);if(!h||!u)throw new Error(\"Popper- or reference-element missing.\");return((t,u,h)=>{const{container:d,arrow:m,margin:S,padding:L,position:B,variantFlipOrder:P,positionFlipOrder:R}={container:document.documentElement.getBoundingClientRect(),...x,...h},{left:D,top:H}=u.style;u.style.left=\"0\",u.style.top=\"0\";const j=t.getBoundingClientRect(),F=u.getBoundingClientRect(),N={t:j.top-F.height-S,b:j.bottom+S,r:j.right+S,l:j.left-F.width-S},T={vs:j.left,vm:j.left+j.width/2-F.width/2,ve:j.left+j.width-F.width,hs:j.top,hm:j.bottom-j.height/2-F.height/2,he:j.bottom-F.height},[M,U=\"middle\"]=B.split(\"-\"),V=R[M],z=P[U],{top:I,left:X,bottom:G,right:K}=d;for(const t of V){const h=\"t\"===t||\"b\"===t;let d=N[t];const[S,B]=h?[\"top\",\"left\"]:[\"left\",\"top\"],[P,x]=h?[F.height,F.width]:[F.width,F.height],[R,D]=h?[G,K]:[K,G],[H,M]=h?[I,X]:[X,I];if(!(d<H||d+P+L>R))for(const R of z){let H=T[(h?\"v\":\"h\")+R];if(!(H<M||H+x+L>D)){if(H-=F[B],d-=F[S],u.style[B]=`${H}px`,u.style[S]=`${d}px`,m){const u=h?j.width/2:j.height/2,L=x/2,D=u>L,F=H+{s:D?L:u,m:L,e:D?L:x-u}[R],N=d+{t:P,b:0,r:0,l:P}[t];m.style[B]=`${F}px`,m.style[S]=`${N}px`}return t+R}}}return u.style.left=D,u.style.top=H,null})(u,h,d)}}};class E{static utils=h;static version=\"1.9.1\";static I18N_DEFAULTS={\"ui:dialog\":\"color picker dialog\",\"btn:toggle\":\"toggle color picker dialog\",\"btn:swatch\":\"color swatch\",\"btn:last-color\":\"use previous color\",\"btn:save\":\"Save\",\"btn:cancel\":\"Cancel\",\"btn:clear\":\"Clear\",\"aria:btn:save\":\"save and close\",\"aria:btn:cancel\":\"cancel and close\",\"aria:btn:clear\":\"clear and close\",\"aria:input\":\"color input field\",\"aria:palette\":\"color selection area\",\"aria:hue\":\"hue selection slider\",\"aria:opacity\":\"selection slider\"};static DEFAULT_OPTIONS={appClass:null,theme:\"classic\",useAsButton:!1,padding:8,disabled:!1,comparison:!0,closeOnScroll:!1,outputPrecision:0,lockOpacity:!1,autoReposition:!0,container:\"body\",components:{interaction:{}},i18n:{},swatches:null,inline:!1,sliders:null,default:\"#42445a\",defaultRepresentation:null,position:\"bottom-middle\",adjustableNumbers:!0,showAlways:!1,closeWithKey:\"Escape\"};_initializingActive=!0;_recalc=!0;_nanopop=null;_root=null;_color=A();_lastColor=A();_swatchColors=[];_setupAnimationFrame=null;_eventListener={init:[],save:[],hide:[],show:[],clear:[],change:[],changestop:[],cancel:[],swatchselect:[]};constructor(t){this.options=t=Object.assign({...E.DEFAULT_OPTIONS},t);const{swatches:u,components:h,theme:d,sliders:m,lockOpacity:S,padding:L}=t;[\"nano\",\"monolith\"].includes(d)&&!m&&(t.sliders=\"h\"),h.interaction||(h.interaction={});const{preview:B,opacity:P,hue:x,palette:R}=h;h.opacity=!S&&P,h.palette=R||B||P||x,this._preBuild(),this._buildComponents(),this._bindEvents(),this._finalBuild(),u&&u.length&&u.forEach((t=>this.addSwatch(t)));const{button:D,app:H}=this._root;this._nanopop=O(D,H,{margin:L}),D.setAttribute(\"role\",\"button\"),D.setAttribute(\"aria-label\",this._t(\"btn:toggle\"));const j=this;this._setupAnimationFrame=requestAnimationFrame((function e(){if(!H.offsetWidth)return requestAnimationFrame(e);j.setColor(t.default),j._rePositioningPicker(),t.defaultRepresentation&&(j._representation=t.defaultRepresentation,j.setColorRepresentation(j._representation)),t.showAlways&&j.show(),j._initializingActive=!1,j._emit(\"init\")}))}static create=t=>new E(t);_preBuild(){const{options:t}=this;for(const u of[\"el\",\"container\"])t[u]=c(t[u]);this._root=(t=>{const{components:u,useAsButton:h,inline:d,appClass:m,theme:S,lockOpacity:L}=t.options,l=t=>t?\"\":'style=\"display:none\" hidden',c=u=>t._t(u),B=a(`\\n      <div :ref=\"root\" class=\"pickr\">\\n\\n        ${h?\"\":'<button type=\"button\" :ref=\"button\" class=\"pcr-button\"></button>'}\\n\\n        <div :ref=\"app\" class=\"pcr-app ${m||\"\"}\" data-theme=\"${S}\" ${d?'style=\"position: unset\"':\"\"} aria-label=\"${c(\"ui:dialog\")}\" role=\"window\">\\n          <div class=\"pcr-selection\" ${l(u.palette)}>\\n            <div :obj=\"preview\" class=\"pcr-color-preview\" ${l(u.preview)}>\\n              <button type=\"button\" :ref=\"lastColor\" class=\"pcr-last-color\" aria-label=\"${c(\"btn:last-color\")}\"></button>\\n              <div :ref=\"currentColor\" class=\"pcr-current-color\"></div>\\n            </div>\\n\\n            <div :obj=\"palette\" class=\"pcr-color-palette\">\\n              <div :ref=\"picker\" class=\"pcr-picker\"></div>\\n              <div :ref=\"palette\" class=\"pcr-palette\" tabindex=\"0\" aria-label=\"${c(\"aria:palette\")}\" role=\"listbox\"></div>\\n            </div>\\n\\n            <div :obj=\"hue\" class=\"pcr-color-chooser\" ${l(u.hue)}>\\n              <div :ref=\"picker\" class=\"pcr-picker\"></div>\\n              <div :ref=\"slider\" class=\"pcr-hue pcr-slider\" tabindex=\"0\" aria-label=\"${c(\"aria:hue\")}\" role=\"slider\"></div>\\n            </div>\\n\\n            <div :obj=\"opacity\" class=\"pcr-color-opacity\" ${l(u.opacity)}>\\n              <div :ref=\"picker\" class=\"pcr-picker\"></div>\\n              <div :ref=\"slider\" class=\"pcr-opacity pcr-slider\" tabindex=\"0\" aria-label=\"${c(\"aria:opacity\")}\" role=\"slider\"></div>\\n            </div>\\n          </div>\\n\\n          <div class=\"pcr-swatches ${u.palette?\"\":\"pcr-last\"}\" :ref=\"swatches\"></div>\\n\\n          <div :obj=\"interaction\" class=\"pcr-interaction\" ${l(Object.keys(u.interaction).length)}>\\n            <input :ref=\"result\" class=\"pcr-result\" type=\"text\" spellcheck=\"false\" ${l(u.interaction.input)} aria-label=\"${c(\"aria:input\")}\">\\n\\n            <input :arr=\"options\" class=\"pcr-type\" data-type=\"HEXA\" value=\"${L?\"HEX\":\"HEXA\"}\" type=\"button\" ${l(u.interaction.hex)}>\\n            <input :arr=\"options\" class=\"pcr-type\" data-type=\"RGBA\" value=\"${L?\"RGB\":\"RGBA\"}\" type=\"button\" ${l(u.interaction.rgba)}>\\n            <input :arr=\"options\" class=\"pcr-type\" data-type=\"HSLA\" value=\"${L?\"HSL\":\"HSLA\"}\" type=\"button\" ${l(u.interaction.hsla)}>\\n            <input :arr=\"options\" class=\"pcr-type\" data-type=\"HSVA\" value=\"${L?\"HSV\":\"HSVA\"}\" type=\"button\" ${l(u.interaction.hsva)}>\\n            <input :arr=\"options\" class=\"pcr-type\" data-type=\"CMYK\" value=\"CMYK\" type=\"button\" ${l(u.interaction.cmyk)}>\\n\\n            <input :ref=\"save\" class=\"pcr-save\" value=\"${c(\"btn:save\")}\" type=\"button\" ${l(u.interaction.save)} aria-label=\"${c(\"aria:btn:save\")}\">\\n            <input :ref=\"cancel\" class=\"pcr-cancel\" value=\"${c(\"btn:cancel\")}\" type=\"button\" ${l(u.interaction.cancel)} aria-label=\"${c(\"aria:btn:cancel\")}\">\\n            <input :ref=\"clear\" class=\"pcr-clear\" value=\"${c(\"btn:clear\")}\" type=\"button\" ${l(u.interaction.clear)} aria-label=\"${c(\"aria:btn:clear\")}\">\\n          </div>\\n        </div>\\n      </div>\\n    `),P=B.interaction;return P.options.find((t=>!t.hidden&&!t.classList.add(\"active\"))),P.type=()=>P.options.find((t=>t.classList.contains(\"active\"))),B})(this),t.useAsButton&&(this._root.button=t.el),t.container.appendChild(this._root.root)}_finalBuild(){const t=this.options,u=this._root;if(t.container.removeChild(u.root),t.inline){const h=t.el.parentElement;t.el.nextSibling?h.insertBefore(u.app,t.el.nextSibling):h.appendChild(u.app)}else t.container.appendChild(u.app);t.useAsButton?t.inline&&t.el.remove():t.el.parentNode.replaceChild(u.root,t.el),t.disabled&&this.disable(),t.comparison||(u.button.style.transition=\"none\",t.useAsButton||(u.preview.lastColor.style.transition=\"none\")),this.hide()}_buildComponents(){const t=this,u=this.options.components,h=(t.options.sliders||\"v\").repeat(2),[d,m]=h.match(/^[vh]+$/g)?h:[],s=()=>this._color||(this._color=this._lastColor.clone()),S={palette:C({element:t._root.palette.picker,wrapper:t._root.palette.palette,onstop:()=>t._emit(\"changestop\",\"slider\",t),onchange(h,d){if(!u.palette)return;const m=s(),{_root:S,options:L}=t,{lastColor:B,currentColor:P}=S.preview;t._recalc&&(m.s=100*h,m.v=100-100*d,m.v<0&&(m.v=0),t._updateOutput(\"slider\"));const x=m.toRGBA().toString(0);this.element.style.background=x,this.wrapper.style.background=`\\n                        linear-gradient(to top, rgba(0, 0, 0, ${m.a}), transparent),\\n                        linear-gradient(to left, hsla(${m.h}, 100%, 50%, ${m.a}), rgba(255, 255, 255, ${m.a}))\\n                    `,L.comparison?L.useAsButton||t._lastColor||B.style.setProperty(\"--pcr-color\",x):(S.button.style.setProperty(\"--pcr-color\",x),S.button.classList.remove(\"clear\"));const R=m.toHEXA().toString();for(const{el:u,color:h}of t._swatchColors)u.classList[R===h.toHEXA().toString()?\"add\":\"remove\"](\"pcr-active\");P.style.setProperty(\"--pcr-color\",x)}}),hue:C({lock:\"v\"===m?\"h\":\"v\",element:t._root.hue.picker,wrapper:t._root.hue.slider,onstop:()=>t._emit(\"changestop\",\"slider\",t),onchange(h){if(!u.hue||!u.palette)return;const d=s();t._recalc&&(d.h=360*h),this.element.style.backgroundColor=`hsl(${d.h}, 100%, 50%)`,S.palette.trigger()}}),opacity:C({lock:\"v\"===d?\"h\":\"v\",element:t._root.opacity.picker,wrapper:t._root.opacity.slider,onstop:()=>t._emit(\"changestop\",\"slider\",t),onchange(h){if(!u.opacity||!u.palette)return;const d=s();t._recalc&&(d.a=Math.round(100*h)/100),this.element.style.background=`rgba(0, 0, 0, ${d.a})`,S.palette.trigger()}}),selectable:k({elements:t._root.interaction.options,className:\"active\",onchange(u){t._representation=u.target.getAttribute(\"data-type\").toUpperCase(),t._recalc&&t._updateOutput(\"swatch\")}})};this._components=S}_bindEvents(){const{_root:t,options:u}=this,h=[d(t.interaction.clear,\"click\",(()=>this._clearColor())),d([t.interaction.cancel,t.preview.lastColor],\"click\",(()=>{this.setHSVA(...(this._lastColor||this._color).toHSVA(),!0),this._emit(\"cancel\")})),d(t.interaction.save,\"click\",(()=>{!this.applyColor()&&!u.showAlways&&this.hide()})),d(t.interaction.result,[\"keyup\",\"input\"],(t=>{this.setColor(t.target.value,!0)&&!this._initializingActive&&(this._emit(\"change\",this._color,\"input\",this),this._emit(\"changestop\",\"input\",this)),t.stopImmediatePropagation()})),d(t.interaction.result,[\"focus\",\"blur\"],(t=>{this._recalc=\"blur\"===t.type,this._recalc&&this._updateOutput(null)})),d([t.palette.palette,t.palette.picker,t.hue.slider,t.hue.picker,t.opacity.slider,t.opacity.picker],[\"mousedown\",\"touchstart\"],(()=>this._recalc=!0),{passive:!0})];if(!u.showAlways){const m=u.closeWithKey;h.push(d(t.button,\"click\",(()=>this.isOpen()?this.hide():this.show())),d(document,\"keyup\",(t=>this.isOpen()&&(t.key===m||t.code===m)&&this.hide())),d(document,[\"touchstart\",\"mousedown\"],(u=>{this.isOpen()&&!l(u).some((u=>u===t.app||u===t.button))&&this.hide()}),{capture:!0}))}if(u.adjustableNumbers){const u={rgba:[255,255,255,1],hsva:[360,100,100,1],hsla:[360,100,100,1],cmyk:[100,100,100,100]};p(t.interaction.result,((t,h,d)=>{const m=u[this.getColorRepresentation().toLowerCase()];if(m){const u=m[d],S=t+(u>=100?1e3*h:h);return S<=0?0:Number((S<u?S:u).toPrecision(3))}return t}))}if(u.autoReposition&&!u.inline){let t=null;const m=this;h.push(d(window,[\"scroll\",\"resize\"],(()=>{m.isOpen()&&(u.closeOnScroll&&m.hide(),null===t?(t=setTimeout((()=>t=null),100),requestAnimationFrame((function e(){m._rePositioningPicker(),null!==t&&requestAnimationFrame(e)}))):(clearTimeout(t),t=setTimeout((()=>t=null),100)))}),{capture:!0}))}this._eventBindings=h}_rePositioningPicker(){const{options:t}=this;if(!t.inline&&!this._nanopop.update({container:document.body.getBoundingClientRect(),position:t.position})){const t=this._root.app,u=t.getBoundingClientRect();t.style.top=(window.innerHeight-u.height)/2+\"px\",t.style.left=(window.innerWidth-u.width)/2+\"px\"}}_updateOutput(t){const{_root:u,_color:h,options:d}=this;if(u.interaction.type()){const t=`to${u.interaction.type().getAttribute(\"data-type\")}`;u.interaction.result.value=\"function\"==typeof h[t]?h[t]().toString(d.outputPrecision):\"\"}!this._initializingActive&&this._recalc&&this._emit(\"change\",h,t,this)}_clearColor(t=!1){const{_root:u,options:h}=this;h.useAsButton||u.button.style.setProperty(\"--pcr-color\",\"rgba(0, 0, 0, 0.15)\"),u.button.classList.add(\"clear\"),h.showAlways||this.hide(),this._lastColor=null,this._initializingActive||t||(this._emit(\"save\",null),this._emit(\"clear\"))}_parseLocalColor(t){const{values:u,type:h,a:d}=w(t),{lockOpacity:m}=this.options,S=void 0!==d&&1!==d;return u&&3===u.length&&(u[3]=void 0),{values:!u||m&&S?null:u,type:h}}_t(t){return this.options.i18n[t]||E.I18N_DEFAULTS[t]}_emit(t,...u){this._eventListener[t].forEach((t=>t(...u,this)))}on(t,u){return this._eventListener[t].push(u),this}off(t,u){const h=this._eventListener[t]||[],d=h.indexOf(u);return~d&&h.splice(d,1),this}addSwatch(t){const{values:u}=this._parseLocalColor(t);if(u){const{_swatchColors:t,_root:h}=this,m=A(...u),S=r(`<button type=\"button\" style=\"--pcr-color: ${m.toRGBA().toString(0)}\" aria-label=\"${this._t(\"btn:swatch\")}\"/>`);return h.swatches.appendChild(S),t.push({el:S,color:m}),this._eventBindings.push(d(S,\"click\",(()=>{this.setHSVA(...m.toHSVA(),!0),this._emit(\"swatchselect\",m),this._emit(\"change\",m,\"swatch\",this)}))),!0}return!1}removeSwatch(t){const u=this._swatchColors[t];if(u){const{el:h}=u;return this._root.swatches.removeChild(h),this._swatchColors.splice(t,1),!0}return!1}applyColor(t=!1){const{preview:u,button:h}=this._root,d=this._color.toRGBA().toString(0);return u.lastColor.style.setProperty(\"--pcr-color\",d),this.options.useAsButton||h.style.setProperty(\"--pcr-color\",d),h.classList.remove(\"clear\"),this._lastColor=this._color.clone(),this._initializingActive||t||this._emit(\"save\",this._color),this}destroy(){cancelAnimationFrame(this._setupAnimationFrame),this._eventBindings.forEach((t=>m(...t))),Object.keys(this._components).forEach((t=>this._components[t].destroy()))}destroyAndRemove(){this.destroy();const{root:t,app:u}=this._root;t.parentElement&&t.parentElement.removeChild(t),u.parentElement.removeChild(u),Object.keys(this).forEach((t=>this[t]=null))}hide(){return!!this.isOpen()&&(this._root.app.classList.remove(\"visible\"),this._emit(\"hide\"),!0)}show(){return!this.options.disabled&&!this.isOpen()&&(this._root.app.classList.add(\"visible\"),this._rePositioningPicker(),this._emit(\"show\",this._color),this)}isOpen(){return this._root.app.classList.contains(\"visible\")}setHSVA(t=360,u=0,h=0,d=1,m=!1){const S=this._recalc;if(this._recalc=!1,t<0||t>360||u<0||u>100||h<0||h>100||d<0||d>1)return!1;this._color=A(t,u,h,d);const{hue:L,opacity:B,palette:P}=this._components;return L.update(t/360),B.update(d),P.update(u/100,1-h/100),m||this.applyColor(),S&&this._updateOutput(),this._recalc=S,!0}setColor(t,u=!1){if(null===t)return this._clearColor(u),!0;const{values:h,type:d}=this._parseLocalColor(t);if(h){const t=d.toUpperCase(),{options:m}=this._root.interaction,S=m.find((u=>u.getAttribute(\"data-type\")===t));if(S&&!S.hidden)for(const t of m)t.classList[t===S?\"add\":\"remove\"](\"active\");return!!this.setHSVA(...h,u)&&this.setColorRepresentation(t)}return!1}setColorRepresentation(t){return t=t.toUpperCase(),!!this._root.interaction.options.find((u=>u.getAttribute(\"data-type\").startsWith(t)&&!u.click()))}getColorRepresentation(){return this._representation}getColor(){return this._color}getSelectedColor(){return this._lastColor}getRoot(){return this._root}disable(){return this.hide(),this.options.disabled=!0,this._root.button.classList.add(\"disabled\"),this}enable(){return this.options.disabled=!1,this._root.button.classList.remove(\"disabled\"),this}}return u.default})()));var u=t;const h=t.Pickr;export{h as Pickr,u as default};\n\n"
  },
  {
    "path": "vendor/javascript/d3-array.js",
    "content": "// d3-array@3.2.4 downloaded from https://ga.jspm.io/npm:d3-array@3.2.4/src/index.js\n\nimport{InternMap as t,InternSet as n}from\"internmap\";export{InternMap,InternSet}from\"internmap\";function ascending(t,n){return null==t||null==n?NaN:t<n?-1:t>n?1:t>=n?0:NaN}function descending(t,n){return null==t||null==n?NaN:n<t?-1:n>t?1:n>=t?0:NaN}function bisector(t){let n,e,r;if(2!==t.length){n=ascending;e=(n,e)=>ascending(t(n),e);r=(n,e)=>t(n)-e}else{n=t===ascending||t===descending?t:zero;e=t;r=t}function left(t,r,o=0,i=t.length){if(o<i){if(0!==n(r,r))return i;do{const n=o+i>>>1;e(t[n],r)<0?o=n+1:i=n}while(o<i)}return o}function right(t,r,o=0,i=t.length){if(o<i){if(0!==n(r,r))return i;do{const n=o+i>>>1;e(t[n],r)<=0?o=n+1:i=n}while(o<i)}return o}function center(t,n,e=0,o=t.length){const i=left(t,n,e,o-1);return i>e&&r(t[i-1],n)>-r(t[i],n)?i-1:i}return{left:left,center:center,right:right}}function zero(){return 0}function number(t){return null===t?NaN:+t}function*numbers(t,n){if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(yield n);else{let e=-1;for(let r of t)null!=(r=n(r,++e,t))&&(r=+r)>=r&&(yield r)}}const e=bisector(ascending);const r=e.right;const o=e.left;const i=bisector(number).center;function blur(t,n){if(!((n=+n)>=0))throw new RangeError(\"invalid r\");let e=t.length;if(!((e=Math.floor(e))>=0))throw new RangeError(\"invalid length\");if(!e||!n)return t;const r=blurf(n);const o=t.slice();r(t,o,0,e,1);r(o,t,0,e,1);r(t,o,0,e,1);return t}const f=Blur2(blurf);const u=Blur2(blurfImage);function Blur2(t){return function(n,e,r=e){if(!((e=+e)>=0))throw new RangeError(\"invalid rx\");if(!((r=+r)>=0))throw new RangeError(\"invalid ry\");let{data:o,width:i,height:f}=n;if(!((i=Math.floor(i))>=0))throw new RangeError(\"invalid width\");if(!((f=Math.floor(void 0!==f?f:o.length/i))>=0))throw new RangeError(\"invalid height\");if(!i||!f||!e&&!r)return n;const u=e&&t(e);const l=r&&t(r);const c=o.slice();if(u&&l){blurh(u,c,o,i,f);blurh(u,o,c,i,f);blurh(u,c,o,i,f);blurv(l,o,c,i,f);blurv(l,c,o,i,f);blurv(l,o,c,i,f)}else if(u){blurh(u,o,c,i,f);blurh(u,c,o,i,f);blurh(u,o,c,i,f)}else if(l){blurv(l,o,c,i,f);blurv(l,c,o,i,f);blurv(l,o,c,i,f)}return n}}function blurh(t,n,e,r,o){for(let i=0,f=r*o;i<f;)t(n,e,i,i+=r,1)}function blurv(t,n,e,r,o){for(let i=0,f=r*o;i<r;++i)t(n,e,i,i+f,r)}function blurfImage(t){const n=blurf(t);return(t,e,r,o,i)=>{r<<=2,o<<=2,i<<=2;n(t,e,r+0,o+0,i);n(t,e,r+1,o+1,i);n(t,e,r+2,o+2,i);n(t,e,r+3,o+3,i)}}function blurf(t){const n=Math.floor(t);if(n===t)return bluri(t);const e=t-n;const r=2*t+1;return(t,o,i,f,u)=>{if(!((f-=u)>=i))return;let l=n*o[i];const c=u*n;const s=c+u;for(let t=i,n=i+c;t<n;t+=u)l+=o[Math.min(f,t)];for(let n=i,a=f;n<=a;n+=u){l+=o[Math.min(f,n+c)];t[n]=(l+e*(o[Math.max(i,n-s)]+o[Math.min(f,n+s)]))/r;l-=o[Math.max(i,n-c)]}}}function bluri(t){const n=2*t+1;return(e,r,o,i,f)=>{if(!((i-=f)>=o))return;let u=t*r[o];const l=f*t;for(let t=o,n=o+l;t<n;t+=f)u+=r[Math.min(i,t)];for(let t=o,c=i;t<=c;t+=f){u+=r[Math.min(i,t+l)];e[t]=u/n;u-=r[Math.max(o,t-l)]}}}function count(t,n){let e=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&++e;else{let r=-1;for(let o of t)null!=(o=n(o,++r,t))&&(o=+o)>=o&&++e}return e}function length$1(t){return 0|t.length}function empty(t){return!(t>0)}function arrayify(t){return\"object\"!==typeof t||\"length\"in t?t:Array.from(t)}function reducer(t){return n=>t(...n)}function cross(...t){const n=\"function\"===typeof t[t.length-1]&&reducer(t.pop());t=t.map(arrayify);const e=t.map(length$1);const r=t.length-1;const o=new Array(r+1).fill(0);const i=[];if(r<0||e.some(empty))return i;while(true){i.push(o.map(((n,e)=>t[e][n])));let f=r;while(++o[f]===e[f]){if(0===f)return n?i.map(n):i;o[f--]=0}}}function cumsum(t,n){var e=0,r=0;return Float64Array.from(t,void 0===n?t=>e+=+t||0:o=>e+=+n(o,r++,t)||0)}function variance(t,n){let e=0;let r;let o=0;let i=0;if(void 0===n){for(let n of t)if(null!=n&&(n=+n)>=n){r=n-o;o+=r/++e;i+=r*(n-o)}}else{let f=-1;for(let u of t)if(null!=(u=n(u,++f,t))&&(u=+u)>=u){r=u-o;o+=r/++e;i+=r*(u-o)}}if(e>1)return i/(e-1)}function deviation(t,n){const e=variance(t,n);return e?Math.sqrt(e):e}function extent(t,n){let e;let r;if(void 0===n){for(const n of t)if(null!=n)if(void 0===e)n>=n&&(e=r=n);else{e>n&&(e=n);r<n&&(r=n)}}else{let o=-1;for(let i of t)if(null!=(i=n(i,++o,t)))if(void 0===e)i>=i&&(e=r=i);else{e>i&&(e=i);r<i&&(r=i)}}return[e,r]}class Adder{constructor(){this._partials=new Float64Array(32);this._n=0}add(t){const n=this._partials;let e=0;for(let r=0;r<this._n&&r<32;r++){const o=n[r],i=t+o,f=Math.abs(t)<Math.abs(o)?t-(i-o):o-(i-t);f&&(n[e++]=f);t=i}n[e]=t;this._n=e+1;return this}valueOf(){const t=this._partials;let n,e,r,o=this._n,i=0;if(o>0){i=t[--o];while(o>0){n=i;e=t[--o];i=n+e;r=e-(i-n);if(r)break}if(o>0&&(r<0&&t[o-1]<0||r>0&&t[o-1]>0)){e=2*r;n=i+e;e==n-i&&(i=n)}}return i}}function fsum(t,n){const e=new Adder;if(void 0===n)for(let n of t)(n=+n)&&e.add(n);else{let r=-1;for(let o of t)(o=+n(o,++r,t))&&e.add(o)}return+e}function fcumsum(t,n){const e=new Adder;let r=-1;return Float64Array.from(t,void 0===n?t=>e.add(+t||0):o=>e.add(+n(o,++r,t)||0))}function identity(t){return t}function group(t,...n){return nest(t,identity,identity,n)}function groups(t,...n){return nest(t,Array.from,identity,n)}function flatten$1(t,n){for(let e=1,r=n.length;e<r;++e)t=t.flatMap((t=>t.pop().map((([n,e])=>[...t,n,e]))));return t}function flatGroup(t,...n){return flatten$1(groups(t,...n),n)}function flatRollup(t,n,...e){return flatten$1(rollups(t,n,...e),e)}function rollup(t,n,...e){return nest(t,identity,n,e)}function rollups(t,n,...e){return nest(t,Array.from,n,e)}function index(t,...n){return nest(t,identity,unique,n)}function indexes(t,...n){return nest(t,Array.from,unique,n)}function unique(t){if(1!==t.length)throw new Error(\"duplicate key\");return t[0]}function nest(n,e,r,o){return function regroup(n,i){if(i>=o.length)return r(n);const f=new t;const u=o[i++];let l=-1;for(const t of n){const e=u(t,++l,n);const r=f.get(e);r?r.push(t):f.set(e,[t])}for(const[t,n]of f)f.set(t,regroup(n,i));return e(f)}(n,0)}function permute(t,n){return Array.from(n,(n=>t[n]))}function sort(t,...n){if(\"function\"!==typeof t[Symbol.iterator])throw new TypeError(\"values is not iterable\");t=Array.from(t);let[e]=n;if(e&&2!==e.length||n.length>1){const r=Uint32Array.from(t,((t,n)=>n));if(n.length>1){n=n.map((n=>t.map(n)));r.sort(((t,e)=>{for(const r of n){const n=ascendingDefined(r[t],r[e]);if(n)return n}}))}else{e=t.map(e);r.sort(((t,n)=>ascendingDefined(e[t],e[n])))}return permute(t,r)}return t.sort(compareDefined(e))}function compareDefined(t=ascending){if(t===ascending)return ascendingDefined;if(\"function\"!==typeof t)throw new TypeError(\"compare is not a function\");return(n,e)=>{const r=t(n,e);return r||0===r?r:(0===t(e,e))-(0===t(n,n))}}function ascendingDefined(t,n){return(null==t||!(t>=t))-(null==n||!(n>=n))||(t<n?-1:t>n?1:0)}function groupSort(t,n,e){return(2!==n.length?sort(rollup(t,n,e),(([t,n],[e,r])=>ascending(n,r)||ascending(t,e))):sort(group(t,e),(([t,e],[r,o])=>n(e,o)||ascending(t,r)))).map((([t])=>t))}var l=Array.prototype;var c=l.slice;l.map;function constant(t){return()=>t}const s=Math.sqrt(50),a=Math.sqrt(10),h=Math.sqrt(2);function tickSpec(t,n,e){const r=(n-t)/Math.max(0,e),o=Math.floor(Math.log10(r)),i=r/Math.pow(10,o),f=i>=s?10:i>=a?5:i>=h?2:1;let u,l,c;if(o<0){c=Math.pow(10,-o)/f;u=Math.round(t*c);l=Math.round(n*c);u/c<t&&++u;l/c>n&&--l;c=-c}else{c=Math.pow(10,o)*f;u=Math.round(t/c);l=Math.round(n/c);u*c<t&&++u;l*c>n&&--l}return l<u&&.5<=e&&e<2?tickSpec(t,n,2*e):[u,l,c]}function ticks(t,n,e){n=+n,t=+t,e=+e;if(!(e>0))return[];if(t===n)return[t];const r=n<t,[o,i,f]=r?tickSpec(n,t,e):tickSpec(t,n,e);if(!(i>=o))return[];const u=i-o+1,l=new Array(u);if(r)if(f<0)for(let t=0;t<u;++t)l[t]=(i-t)/-f;else for(let t=0;t<u;++t)l[t]=(i-t)*f;else if(f<0)for(let t=0;t<u;++t)l[t]=(o+t)/-f;else for(let t=0;t<u;++t)l[t]=(o+t)*f;return l}function tickIncrement(t,n,e){n=+n,t=+t,e=+e;return tickSpec(t,n,e)[2]}function tickStep(t,n,e){n=+n,t=+t,e=+e;const r=n<t,o=r?tickIncrement(n,t,e):tickIncrement(t,n,e);return(r?-1:1)*(o<0?1/-o:o)}function nice(t,n,e){let r;while(true){const o=tickIncrement(t,n,e);if(o===r||0===o||!isFinite(o))return[t,n];if(o>0){t=Math.floor(t/o)*o;n=Math.ceil(n/o)*o}else if(o<0){t=Math.ceil(t*o)/o;n=Math.floor(n*o)/o}r=o}}function thresholdSturges(t){return Math.max(1,Math.ceil(Math.log(count(t))/Math.LN2)+1)}function bin(){var t=identity,n=extent,e=thresholdSturges;function histogram(o){Array.isArray(o)||(o=Array.from(o));var i,f,u,l=o.length,c=new Array(l);for(i=0;i<l;++i)c[i]=t(o[i],i,o);var s=n(c),a=s[0],h=s[1],d=e(c,a,h);if(!Array.isArray(d)){const t=h,e=+d;n===extent&&([a,h]=nice(a,h,e));d=ticks(a,h,e);d[0]<=a&&(u=tickIncrement(a,h,e));if(d[d.length-1]>=h)if(t>=h&&n===extent){const t=tickIncrement(a,h,e);isFinite(t)&&(t>0?h=(Math.floor(h/t)+1)*t:t<0&&(h=(Math.ceil(h*-t)+1)/-t))}else d.pop()}var m=d.length,p=0,g=m;while(d[p]<=a)++p;while(d[g-1]>h)--g;(p||g<m)&&(d=d.slice(p,g),m=g-p);var y,w=new Array(m+1);for(i=0;i<=m;++i){y=w[i]=[];y.x0=i>0?d[i-1]:a;y.x1=i<m?d[i]:h}if(isFinite(u)){if(u>0)for(i=0;i<l;++i)null!=(f=c[i])&&a<=f&&f<=h&&w[Math.min(m,Math.floor((f-a)/u))].push(o[i]);else if(u<0)for(i=0;i<l;++i)if(null!=(f=c[i])&&a<=f&&f<=h){const t=Math.floor((a-f)*u);w[Math.min(m,t+(d[t]<=f))].push(o[i])}}else for(i=0;i<l;++i)null!=(f=c[i])&&a<=f&&f<=h&&w[r(d,f,0,m)].push(o[i]);return w}histogram.value=function(n){return arguments.length?(t=\"function\"===typeof n?n:constant(n),histogram):t};histogram.domain=function(t){return arguments.length?(n=\"function\"===typeof t?t:constant([t[0],t[1]]),histogram):n};histogram.thresholds=function(t){return arguments.length?(e=\"function\"===typeof t?t:constant(Array.isArray(t)?c.call(t):t),histogram):e};return histogram}function max(t,n){let e;if(void 0===n)for(const n of t)null!=n&&(e<n||void 0===e&&n>=n)&&(e=n);else{let r=-1;for(let o of t)null!=(o=n(o,++r,t))&&(e<o||void 0===e&&o>=o)&&(e=o)}return e}function maxIndex(t,n){let e;let r=-1;let o=-1;if(void 0===n)for(const n of t){++o;null!=n&&(e<n||void 0===e&&n>=n)&&(e=n,r=o)}else for(let i of t)null!=(i=n(i,++o,t))&&(e<i||void 0===e&&i>=i)&&(e=i,r=o);return r}function min(t,n){let e;if(void 0===n)for(const n of t)null!=n&&(e>n||void 0===e&&n>=n)&&(e=n);else{let r=-1;for(let o of t)null!=(o=n(o,++r,t))&&(e>o||void 0===e&&o>=o)&&(e=o)}return e}function minIndex(t,n){let e;let r=-1;let o=-1;if(void 0===n)for(const n of t){++o;null!=n&&(e>n||void 0===e&&n>=n)&&(e=n,r=o)}else for(let i of t)null!=(i=n(i,++o,t))&&(e>i||void 0===e&&i>=i)&&(e=i,r=o);return r}function quickselect(t,n,e=0,r=Infinity,o){n=Math.floor(n);e=Math.floor(Math.max(0,e));r=Math.floor(Math.min(t.length-1,r));if(!(e<=n&&n<=r))return t;o=void 0===o?ascendingDefined:compareDefined(o);while(r>e){if(r-e>600){const i=r-e+1;const f=n-e+1;const u=Math.log(i);const l=.5*Math.exp(2*u/3);const c=.5*Math.sqrt(u*l*(i-l)/i)*(f-i/2<0?-1:1);const s=Math.max(e,Math.floor(n-f*l/i+c));const a=Math.min(r,Math.floor(n+(i-f)*l/i+c));quickselect(t,n,s,a,o)}const i=t[n];let f=e;let u=r;swap(t,e,n);o(t[r],i)>0&&swap(t,e,r);while(f<u){swap(t,f,u),++f,--u;while(o(t[f],i)<0)++f;while(o(t[u],i)>0)--u}0===o(t[e],i)?swap(t,e,u):(++u,swap(t,u,r));u<=n&&(e=u+1);n<=u&&(r=u-1)}return t}function swap(t,n,e){const r=t[n];t[n]=t[e];t[e]=r}function greatest(t,n=ascending){let e;let r=false;if(1===n.length){let o;for(const i of t){const t=n(i);if(r?ascending(t,o)>0:0===ascending(t,t)){e=i;o=t;r=true}}}else for(const o of t)if(r?n(o,e)>0:0===n(o,o)){e=o;r=true}return e}function quantile(t,n,e){t=Float64Array.from(numbers(t,e));if((r=t.length)&&!isNaN(n=+n)){if(n<=0||r<2)return min(t);if(n>=1)return max(t);var r,o=(r-1)*n,i=Math.floor(o),f=max(quickselect(t,i).subarray(0,i+1)),u=min(t.subarray(i+1));return f+(u-f)*(o-i)}}function quantileSorted(t,n,e=number){if((r=t.length)&&!isNaN(n=+n)){if(n<=0||r<2)return+e(t[0],0,t);if(n>=1)return+e(t[r-1],r-1,t);var r,o=(r-1)*n,i=Math.floor(o),f=+e(t[i],i,t),u=+e(t[i+1],i+1,t);return f+(u-f)*(o-i)}}function quantileIndex(t,n,e=number){if(!isNaN(n=+n)){r=Float64Array.from(t,((n,r)=>number(e(t[r],r,t))));if(n<=0)return minIndex(r);if(n>=1)return maxIndex(r);var r,o=Uint32Array.from(t,((t,n)=>n)),i=r.length-1,f=Math.floor(i*n);quickselect(o,f,0,i,((t,n)=>ascendingDefined(r[t],r[n])));f=greatest(o.subarray(0,f+1),(t=>r[t]));return f>=0?f:-1}}function thresholdFreedmanDiaconis(t,n,e){const r=count(t),o=quantile(t,.75)-quantile(t,.25);return r&&o?Math.ceil((e-n)/(2*o*Math.pow(r,-1/3))):1}function thresholdScott(t,n,e){const r=count(t),o=deviation(t);return r&&o?Math.ceil((e-n)*Math.cbrt(r)/(3.49*o)):1}function mean(t,n){let e=0;let r=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(++e,r+=n);else{let o=-1;for(let i of t)null!=(i=n(i,++o,t))&&(i=+i)>=i&&(++e,r+=i)}if(e)return r/e}function median(t,n){return quantile(t,.5,n)}function medianIndex(t,n){return quantileIndex(t,.5,n)}function*flatten(t){for(const n of t)yield*n}function merge(t){return Array.from(flatten(t))}function mode(n,e){const r=new t;if(void 0===e)for(let t of n)null!=t&&t>=t&&r.set(t,(r.get(t)||0)+1);else{let t=-1;for(let o of n)null!=(o=e(o,++t,n))&&o>=o&&r.set(o,(r.get(o)||0)+1)}let o;let i=0;for(const[t,n]of r)if(n>i){i=n;o=t}return o}function pairs(t,n=pair){const e=[];let r;let o=false;for(const i of t){o&&e.push(n(r,i));r=i;o=true}return e}function pair(t,n){return[t,n]}function range(t,n,e){t=+t,n=+n,e=(o=arguments.length)<2?(n=t,t=0,1):o<3?1:+e;var r=-1,o=0|Math.max(0,Math.ceil((n-t)/e)),i=new Array(o);while(++r<o)i[r]=t+r*e;return i}function rank(t,n=ascending){if(\"function\"!==typeof t[Symbol.iterator])throw new TypeError(\"values is not iterable\");let e=Array.from(t);const r=new Float64Array(e.length);2!==n.length&&(e=e.map(n),n=ascending);const compareIndex=(t,r)=>n(e[t],e[r]);let o,i;t=Uint32Array.from(e,((t,n)=>n));t.sort(n===ascending?(t,n)=>ascendingDefined(e[t],e[n]):compareDefined(compareIndex));t.forEach(((t,n)=>{const e=compareIndex(t,void 0===o?t:o);if(e>=0){(void 0===o||e>0)&&(o=t,i=n);r[t]=i}else r[t]=NaN}));return r}function least(t,n=ascending){let e;let r=false;if(1===n.length){let o;for(const i of t){const t=n(i);if(r?ascending(t,o)<0:0===ascending(t,t)){e=i;o=t;r=true}}}else for(const o of t)if(r?n(o,e)<0:0===n(o,o)){e=o;r=true}return e}function leastIndex(t,n=ascending){if(1===n.length)return minIndex(t,n);let e;let r=-1;let o=-1;for(const i of t){++o;if(r<0?0===n(i,i):n(i,e)<0){e=i;r=o}}return r}function greatestIndex(t,n=ascending){if(1===n.length)return maxIndex(t,n);let e;let r=-1;let o=-1;for(const i of t){++o;if(r<0?0===n(i,i):n(i,e)>0){e=i;r=o}}return r}function scan(t,n){const e=leastIndex(t,n);return e<0?void 0:e}var d=shuffler(Math.random);function shuffler(t){return function shuffle(n,e=0,r=n.length){let o=r-(e=+e);while(o){const r=t()*o--|0,i=n[o+e];n[o+e]=n[r+e];n[r+e]=i}return n}}function sum(t,n){let e=0;if(void 0===n)for(let n of t)(n=+n)&&(e+=n);else{let r=-1;for(let o of t)(o=+n(o,++r,t))&&(e+=o)}return e}function transpose(t){if(!(o=t.length))return[];for(var n=-1,e=min(t,length),r=new Array(e);++n<e;)for(var o,i=-1,f=r[n]=new Array(o);++i<o;)f[i]=t[i][n];return r}function length(t){return t.length}function zip(){return transpose(arguments)}function every(t,n){if(\"function\"!==typeof n)throw new TypeError(\"test is not a function\");let e=-1;for(const r of t)if(!n(r,++e,t))return false;return true}function some(t,n){if(\"function\"!==typeof n)throw new TypeError(\"test is not a function\");let e=-1;for(const r of t)if(n(r,++e,t))return true;return false}function filter(t,n){if(\"function\"!==typeof n)throw new TypeError(\"test is not a function\");const e=[];let r=-1;for(const o of t)n(o,++r,t)&&e.push(o);return e}function map(t,n){if(\"function\"!==typeof t[Symbol.iterator])throw new TypeError(\"values is not iterable\");if(\"function\"!==typeof n)throw new TypeError(\"mapper is not a function\");return Array.from(t,((e,r)=>n(e,r,t)))}function reduce(t,n,e){if(\"function\"!==typeof n)throw new TypeError(\"reducer is not a function\");const r=t[Symbol.iterator]();let o,i,f=-1;if(arguments.length<3){({done:o,value:e}=r.next());if(o)return;++f}while(({done:o,value:i}=r.next()),!o)e=n(e,i,++f,t);return e}function reverse(t){if(\"function\"!==typeof t[Symbol.iterator])throw new TypeError(\"values is not iterable\");return Array.from(t).reverse()}function difference(t,...e){t=new n(t);for(const n of e)for(const e of n)t.delete(e);return t}function disjoint(t,e){const r=e[Symbol.iterator](),o=new n;for(const n of t){if(o.has(n))return false;let t,e;while(({value:t,done:e}=r.next())){if(e)break;if(Object.is(n,t))return false;o.add(t)}}return true}function intersection(t,...e){t=new n(t);e=e.map(set);t:for(const n of t)for(const r of e)if(!r.has(n)){t.delete(n);continue t}return t}function set(t){return t instanceof n?t:new n(t)}function superset(t,n){const e=t[Symbol.iterator](),r=new Set;for(const t of n){const n=intern(t);if(r.has(n))continue;let o,i;while(({value:o,done:i}=e.next())){if(i)return false;const t=intern(o);r.add(t);if(Object.is(n,t))break}}return true}function intern(t){return null!==t&&\"object\"===typeof t?t.valueOf():t}function subset(t,n){return superset(n,t)}function union(...t){const e=new n;for(const n of t)for(const t of n)e.add(t);return e}export{Adder,ascending,bin,r as bisect,i as bisectCenter,o as bisectLeft,r as bisectRight,bisector,blur,f as blur2,u as blurImage,count,cross,cumsum,descending,deviation,difference,disjoint,every,extent,fcumsum,filter,flatGroup,flatRollup,fsum,greatest,greatestIndex,group,groupSort,groups,bin as histogram,index,indexes,intersection,least,leastIndex,map,max,maxIndex,mean,median,medianIndex,merge,min,minIndex,mode,nice,pairs,permute,quantile,quantileIndex,quantileSorted,quickselect,range,rank,reduce,reverse,rollup,rollups,scan,d as shuffle,shuffler,some,sort,subset,sum,superset,thresholdFreedmanDiaconis,thresholdScott,thresholdSturges,tickIncrement,tickStep,ticks,transpose,union,variance,zip};\n\n"
  },
  {
    "path": "vendor/javascript/d3-axis.js",
    "content": "// d3-axis@3.0.0 downloaded from https://ga.jspm.io/npm:d3-axis@3.0.0/src/index.js\n\nfunction identity(t){return t}var t=1,n=2,r=3,i=4,e=1e-6;function translateX(t){return\"translate(\"+t+\",0)\"}function translateY(t){return\"translate(0,\"+t+\")\"}function number(t){return n=>+t(n)}function center(t,n){n=Math.max(0,t.bandwidth()-2*n)/2;t.round()&&(n=Math.round(n));return r=>+t(r)+n}function entering(){return!this.__axis}function axis(a,s){var o=[],u=null,c=null,l=6,x=6,f=3,d=\"undefined\"!==typeof window&&window.devicePixelRatio>1?0:.5,m=a===t||a===i?-1:1,h=a===i||a===n?\"x\":\"y\",g=a===t||a===r?translateX:translateY;function axis(p){var k=null==u?s.ticks?s.ticks.apply(s,o):s.domain():u,y=null==c?s.tickFormat?s.tickFormat.apply(s,o):identity:c,A=Math.max(l,0)+f,M=s.range(),v=+M[0]+d,w=+M[M.length-1]+d,_=(s.bandwidth?center:number)(s.copy(),d),b=p.selection?p.selection():p,F=b.selectAll(\".domain\").data([null]),V=b.selectAll(\".tick\").data(k,s).order(),z=V.exit(),H=V.enter().append(\"g\").attr(\"class\",\"tick\"),C=V.select(\"line\"),R=V.select(\"text\");F=F.merge(F.enter().insert(\"path\",\".tick\").attr(\"class\",\"domain\").attr(\"stroke\",\"currentColor\"));V=V.merge(H);C=C.merge(H.append(\"line\").attr(\"stroke\",\"currentColor\").attr(h+\"2\",m*l));R=R.merge(H.append(\"text\").attr(\"fill\",\"currentColor\").attr(h,m*A).attr(\"dy\",a===t?\"0em\":a===r?\"0.71em\":\"0.32em\"));if(p!==b){F=F.transition(p);V=V.transition(p);C=C.transition(p);R=R.transition(p);z=z.transition(p).attr(\"opacity\",e).attr(\"transform\",(function(t){return isFinite(t=_(t))?g(t+d):this.getAttribute(\"transform\")}));H.attr(\"opacity\",e).attr(\"transform\",(function(t){var n=this.parentNode.__axis;return g((n&&isFinite(n=n(t))?n:_(t))+d)}))}z.remove();F.attr(\"d\",a===i||a===n?x?\"M\"+m*x+\",\"+v+\"H\"+d+\"V\"+w+\"H\"+m*x:\"M\"+d+\",\"+v+\"V\"+w:x?\"M\"+v+\",\"+m*x+\"V\"+d+\"H\"+w+\"V\"+m*x:\"M\"+v+\",\"+d+\"H\"+w);V.attr(\"opacity\",1).attr(\"transform\",(function(t){return g(_(t)+d)}));C.attr(h+\"2\",m*l);R.attr(h,m*A).text(y);b.filter(entering).attr(\"fill\",\"none\").attr(\"font-size\",10).attr(\"font-family\",\"sans-serif\").attr(\"text-anchor\",a===n?\"start\":a===i?\"end\":\"middle\");b.each((function(){this.__axis=_}))}axis.scale=function(t){return arguments.length?(s=t,axis):s};axis.ticks=function(){return o=Array.from(arguments),axis};axis.tickArguments=function(t){return arguments.length?(o=null==t?[]:Array.from(t),axis):o.slice()};axis.tickValues=function(t){return arguments.length?(u=null==t?null:Array.from(t),axis):u&&u.slice()};axis.tickFormat=function(t){return arguments.length?(c=t,axis):c};axis.tickSize=function(t){return arguments.length?(l=x=+t,axis):l};axis.tickSizeInner=function(t){return arguments.length?(l=+t,axis):l};axis.tickSizeOuter=function(t){return arguments.length?(x=+t,axis):x};axis.tickPadding=function(t){return arguments.length?(f=+t,axis):f};axis.offset=function(t){return arguments.length?(d=+t,axis):d};return axis}function axisTop(n){return axis(t,n)}function axisRight(t){return axis(n,t)}function axisBottom(t){return axis(r,t)}function axisLeft(t){return axis(i,t)}export{axisBottom,axisLeft,axisRight,axisTop};\n\n"
  },
  {
    "path": "vendor/javascript/d3-brush.js",
    "content": "// d3-brush@3.0.0 downloaded from https://ga.jspm.io/npm:d3-brush@3.0.0/src/index.js\n\nimport{dispatch as e}from\"d3-dispatch\";import{dragDisable as t,dragEnable as n}from\"d3-drag\";import{interpolate as r}from\"d3-interpolate\";import{select as u,pointer as i}from\"d3-selection\";import{interrupt as s}from\"d3-transition\";var constant=e=>()=>e;function BrushEvent(e,{sourceEvent:t,target:n,selection:r,mode:u,dispatch:i}){Object.defineProperties(this,{type:{value:e,enumerable:true,configurable:true},sourceEvent:{value:t,enumerable:true,configurable:true},target:{value:n,enumerable:true,configurable:true},selection:{value:r,enumerable:true,configurable:true},mode:{value:u,enumerable:true,configurable:true},_:{value:i}})}function nopropagation(e){e.stopImmediatePropagation()}function noevent(e){e.preventDefault();e.stopImmediatePropagation()}var o={name:\"drag\"},a={name:\"space\"},l={name:\"handle\"},c={name:\"center\"};const{abs:h,max:f,min:d}=Math;function number1(e){return[+e[0],+e[1]]}function number2(e){return[number1(e[0]),number1(e[1])]}var b={name:\"x\",handles:[\"w\",\"e\"].map(type),input:function(e,t){return null==e?null:[[+e[0],t[0][1]],[+e[1],t[1][1]]]},output:function(e){return e&&[e[0][0],e[1][0]]}};var p={name:\"y\",handles:[\"n\",\"s\"].map(type),input:function(e,t){return null==e?null:[[t[0][0],+e[0]],[t[1][0],+e[1]]]},output:function(e){return e&&[e[0][1],e[1][1]]}};var m={name:\"xy\",handles:[\"n\",\"w\",\"e\",\"s\",\"nw\",\"ne\",\"sw\",\"se\"].map(type),input:function(e){return null==e?null:number2(e)},output:function(e){return e}};var v={overlay:\"crosshair\",selection:\"move\",n:\"ns-resize\",e:\"ew-resize\",s:\"ns-resize\",w:\"ew-resize\",nw:\"nwse-resize\",ne:\"nesw-resize\",se:\"nwse-resize\",sw:\"nesw-resize\"};var y={e:\"w\",w:\"e\",nw:\"ne\",ne:\"nw\",se:\"sw\",sw:\"se\"};var w={n:\"s\",s:\"n\",nw:\"sw\",ne:\"se\",se:\"ne\",sw:\"nw\"};var g={overlay:1,selection:1,n:null,e:1,s:null,w:-1,nw:-1,ne:1,se:1,sw:-1};var _={overlay:1,selection:1,n:-1,e:null,s:1,w:null,nw:-1,ne:-1,se:1,sw:1};function type(e){return{type:e}}function defaultFilter(e){return!e.ctrlKey&&!e.button}function defaultExtent(){var e=this.ownerSVGElement||this;if(e.hasAttribute(\"viewBox\")){e=e.viewBox.baseVal;return[[e.x,e.y],[e.x+e.width,e.y+e.height]]}return[[0,0],[e.width.baseVal.value,e.height.baseVal.value]]}function defaultTouchable(){return navigator.maxTouchPoints||\"ontouchstart\"in this}function local(e){while(!e.__brush)if(!(e=e.parentNode))return;return e.__brush}function empty(e){return e[0][0]===e[1][0]||e[0][1]===e[1][1]}function brushSelection(e){var t=e.__brush;return t?t.dim.output(t.selection):null}function brushX(){return brush$1(b)}function brushY(){return brush$1(p)}function brush(){return brush$1(m)}function brush$1(m){var k,x=defaultExtent,E=defaultFilter,z=defaultTouchable,A=true,T=e(\"start\",\"brush\",\"end\"),K=6;function brush(e){var t=e.property(\"__brush\",initialize).selectAll(\".overlay\").data([type(\"overlay\")]);t.enter().append(\"rect\").attr(\"class\",\"overlay\").attr(\"pointer-events\",\"all\").attr(\"cursor\",v.overlay).merge(t).each((function(){var e=local(this).extent;u(this).attr(\"x\",e[0][0]).attr(\"y\",e[0][1]).attr(\"width\",e[1][0]-e[0][0]).attr(\"height\",e[1][1]-e[0][1])}));e.selectAll(\".selection\").data([type(\"selection\")]).enter().append(\"rect\").attr(\"class\",\"selection\").attr(\"cursor\",v.selection).attr(\"fill\",\"#777\").attr(\"fill-opacity\",.3).attr(\"stroke\",\"#fff\").attr(\"shape-rendering\",\"crispEdges\");var n=e.selectAll(\".handle\").data(m.handles,(function(e){return e.type}));n.exit().remove();n.enter().append(\"rect\").attr(\"class\",(function(e){return\"handle handle--\"+e.type})).attr(\"cursor\",(function(e){return v[e.type]}));e.each(redraw).attr(\"fill\",\"none\").attr(\"pointer-events\",\"all\").on(\"mousedown.brush\",started).filter(z).on(\"touchstart.brush\",started).on(\"touchmove.brush\",touchmoved).on(\"touchend.brush touchcancel.brush\",touchended).style(\"touch-action\",\"none\").style(\"-webkit-tap-highlight-color\",\"rgba(0,0,0,0)\")}brush.move=function(e,t,n){e.tween?e.on(\"start.brush\",(function(e){emitter(this,arguments).beforestart().start(e)})).on(\"interrupt.brush end.brush\",(function(e){emitter(this,arguments).end(e)})).tween(\"brush\",(function(){var e=this,n=e.__brush,u=emitter(e,arguments),i=n.selection,s=m.input(\"function\"===typeof t?t.apply(this,arguments):t,n.extent),o=r(i,s);function tween(t){n.selection=1===t&&null===s?null:o(t);redraw.call(e);u.brush()}return null!==i&&null!==s?tween:tween(1)})):e.each((function(){var e=this,r=arguments,u=e.__brush,i=m.input(\"function\"===typeof t?t.apply(e,r):t,u.extent),o=emitter(e,r).beforestart();s(e);u.selection=null===i?null:i;redraw.call(e);o.start(n).brush(n).end(n)}))};brush.clear=function(e,t){brush.move(e,null,t)};function redraw(){var e=u(this),t=local(this).selection;if(t){e.selectAll(\".selection\").style(\"display\",null).attr(\"x\",t[0][0]).attr(\"y\",t[0][1]).attr(\"width\",t[1][0]-t[0][0]).attr(\"height\",t[1][1]-t[0][1]);e.selectAll(\".handle\").style(\"display\",null).attr(\"x\",(function(e){return\"e\"===e.type[e.type.length-1]?t[1][0]-K/2:t[0][0]-K/2})).attr(\"y\",(function(e){return\"s\"===e.type[0]?t[1][1]-K/2:t[0][1]-K/2})).attr(\"width\",(function(e){return\"n\"===e.type||\"s\"===e.type?t[1][0]-t[0][0]+K:K})).attr(\"height\",(function(e){return\"e\"===e.type||\"w\"===e.type?t[1][1]-t[0][1]+K:K}))}else e.selectAll(\".selection,.handle\").style(\"display\",\"none\").attr(\"x\",null).attr(\"y\",null).attr(\"width\",null).attr(\"height\",null)}function emitter(e,t,n){var r=e.__brush.emitter;return!r||n&&r.clean?new Emitter(e,t,n):r}function Emitter(e,t,n){this.that=e;this.args=t;this.state=e.__brush;this.active=0;this.clean=n}Emitter.prototype={beforestart:function(){1===++this.active&&(this.state.emitter=this,this.starting=true);return this},start:function(e,t){this.starting?(this.starting=false,this.emit(\"start\",e,t)):this.emit(\"brush\",e);return this},brush:function(e,t){this.emit(\"brush\",e,t);return this},end:function(e,t){0===--this.active&&(delete this.state.emitter,this.emit(\"end\",e,t));return this},emit:function(e,t,n){var r=u(this.that).datum();T.call(e,this.that,new BrushEvent(e,{sourceEvent:t,target:brush,selection:m.output(this.state.selection),mode:n,dispatch:T}),r)}};function started(e){if((!k||e.touches)&&E.apply(this,arguments)){var r,x,z,T,K,B,P,S,V,$,C,F=this,I=e.target.__data__.type,M=\"selection\"===(A&&e.metaKey?I=\"overlay\":I)?o:A&&e.altKey?c:l,X=m===p?null:g[I],Y=m===b?null:_[I],j=local(F),D=j.extent,G=j.selection,N=D[0][0],O=D[0][1],q=D[1][0],H=D[1][1],J=0,L=0,Q=X&&Y&&A&&e.shiftKey,R=Array.from(e.touches||[e],(e=>{const t=e.identifier;e=i(e,F);e.point0=e.slice();e.identifier=t;return e}));s(F);var U=emitter(F,arguments,true).beforestart();if(\"overlay\"===I){G&&(V=true);const t=[R[0],R[1]||R[0]];j.selection=G=[[r=m===p?N:d(t[0][0],t[1][0]),z=m===b?O:d(t[0][1],t[1][1])],[K=m===p?q:f(t[0][0],t[1][0]),P=m===b?H:f(t[0][1],t[1][1])]];R.length>1&&move(e)}else{r=G[0][0];z=G[0][1];K=G[1][0];P=G[1][1]}x=r;T=z;B=K;S=P;var W=u(F).attr(\"pointer-events\",\"none\");var Z=W.selectAll(\".overlay\").attr(\"cursor\",v[I]);if(e.touches){U.moved=moved;U.ended=ended}else{var ee=u(e.view).on(\"mousemove.brush\",moved,true).on(\"mouseup.brush\",ended,true);A&&ee.on(\"keydown.brush\",keydowned,true).on(\"keyup.brush\",keyupped,true);t(e.view)}redraw.call(F);U.start(e,M.name)}function moved(e){for(const t of e.changedTouches||[e])for(const e of R)e.identifier===t.identifier&&(e.cur=i(t,F));if(Q&&!$&&!C&&1===R.length){const e=R[0];h(e.cur[0]-e[0])>h(e.cur[1]-e[1])?C=true:$=true}for(const e of R)e.cur&&(e[0]=e.cur[0],e[1]=e.cur[1]);V=true;noevent(e);move(e)}function move(e){const t=R[0],n=t.point0;var u;J=t[0]-n[0];L=t[1]-n[1];switch(M){case a:case o:X&&(J=f(N-r,d(q-K,J)),x=r+J,B=K+J);Y&&(L=f(O-z,d(H-P,L)),T=z+L,S=P+L);break;case l:if(R[1]){X&&(x=f(N,d(q,R[0][0])),B=f(N,d(q,R[1][0])),X=1);Y&&(T=f(O,d(H,R[0][1])),S=f(O,d(H,R[1][1])),Y=1)}else{X<0?(J=f(N-r,d(q-r,J)),x=r+J,B=K):X>0&&(J=f(N-K,d(q-K,J)),x=r,B=K+J);Y<0?(L=f(O-z,d(H-z,L)),T=z+L,S=P):Y>0&&(L=f(O-P,d(H-P,L)),T=z,S=P+L)}break;case c:X&&(x=f(N,d(q,r-J*X)),B=f(N,d(q,K+J*X)));Y&&(T=f(O,d(H,z-L*Y)),S=f(O,d(H,P+L*Y)));break}if(B<x){X*=-1;u=r,r=K,K=u;u=x,x=B,B=u;I in y&&Z.attr(\"cursor\",v[I=y[I]])}if(S<T){Y*=-1;u=z,z=P,P=u;u=T,T=S,S=u;I in w&&Z.attr(\"cursor\",v[I=w[I]])}j.selection&&(G=j.selection);$&&(x=G[0][0],B=G[1][0]);C&&(T=G[0][1],S=G[1][1]);if(G[0][0]!==x||G[0][1]!==T||G[1][0]!==B||G[1][1]!==S){j.selection=[[x,T],[B,S]];redraw.call(F);U.brush(e,M.name)}}function ended(e){nopropagation(e);if(e.touches){if(e.touches.length)return;k&&clearTimeout(k);k=setTimeout((function(){k=null}),500)}else{n(e.view,V);ee.on(\"keydown.brush keyup.brush mousemove.brush mouseup.brush\",null)}W.attr(\"pointer-events\",\"all\");Z.attr(\"cursor\",v.overlay);j.selection&&(G=j.selection);empty(G)&&(j.selection=null,redraw.call(F));U.end(e,M.name)}function keydowned(e){switch(e.keyCode){case 16:Q=X&&Y;break;case 18:if(M===l){X&&(K=B-J*X,r=x+J*X);Y&&(P=S-L*Y,z=T+L*Y);M=c;move(e)}break;case 32:if(M===l||M===c){X<0?K=B-J:X>0&&(r=x-J);Y<0?P=S-L:Y>0&&(z=T-L);M=a;Z.attr(\"cursor\",v.selection);move(e)}break;default:return}noevent(e)}function keyupped(e){switch(e.keyCode){case 16:if(Q){$=C=Q=false;move(e)}break;case 18:if(M===c){X<0?K=B:X>0&&(r=x);Y<0?P=S:Y>0&&(z=T);M=l;move(e)}break;case 32:if(M===a){if(e.altKey){X&&(K=B-J*X,r=x+J*X);Y&&(P=S-L*Y,z=T+L*Y);M=c}else{X<0?K=B:X>0&&(r=x);Y<0?P=S:Y>0&&(z=T);M=l}Z.attr(\"cursor\",v[I]);move(e)}break;default:return}noevent(e)}}function touchmoved(e){emitter(this,arguments).moved(e)}function touchended(e){emitter(this,arguments).ended(e)}function initialize(){var e=this.__brush||{selection:null};e.extent=number2(x.apply(this,arguments));e.dim=m;return e}brush.extent=function(e){return arguments.length?(x=\"function\"===typeof e?e:constant(number2(e)),brush):x};brush.filter=function(e){return arguments.length?(E=\"function\"===typeof e?e:constant(!!e),brush):E};brush.touchable=function(e){return arguments.length?(z=\"function\"===typeof e?e:constant(!!e),brush):z};brush.handleSize=function(e){return arguments.length?(K=+e,brush):K};brush.keyModifiers=function(e){return arguments.length?(A=!!e,brush):A};brush.on=function(){var e=T.on.apply(T,arguments);return e===T?brush:e};return brush}export{brush,brushSelection,brushX,brushY};\n\n"
  },
  {
    "path": "vendor/javascript/d3-chord.js",
    "content": "// d3-chord@3.0.1 downloaded from https://ga.jspm.io/npm:d3-chord@3.0.1/src/index.js\n\nimport{path as n}from\"d3-path\";var r=Math.abs;var t=Math.cos;var e=Math.sin;var o=Math.PI;var u=o/2;var a=2*o;var l=Math.max;var i=1e-12;function range(n,r){return Array.from({length:r-n},((r,t)=>n+t))}function compareValue(n){return function(r,t){return n(r.source.value+r.target.value,t.source.value+t.target.value)}}function chord(){return chord$1(false,false)}function chordTranspose(){return chord$1(false,true)}function chordDirected(){return chord$1(true,false)}function chord$1(n,r){var t=0,e=null,o=null,u=null;function chord(i){var c,s=i.length,f=new Array(s),d=range(0,s),g=new Array(s*s),b=new Array(s),h=0;i=Float64Array.from({length:s*s},r?(n,r)=>i[r%s][r/s|0]:(n,r)=>i[r/s|0][r%s]);for(let r=0;r<s;++r){let t=0;for(let e=0;e<s;++e)t+=i[r*s+e]+n*i[e*s+r];h+=f[r]=t}h=l(0,a-t*s)/h;c=h?t:a/s;{let r=0;e&&d.sort(((n,r)=>e(f[n],f[r])));for(const t of d){const e=r;if(n){const n=range(1+~s,s).filter((n=>n<0?i[~n*s+t]:i[t*s+n]));o&&n.sort(((n,r)=>o(n<0?-i[~n*s+t]:i[t*s+n],r<0?-i[~r*s+t]:i[t*s+r])));for(const e of n)if(e<0){const n=g[~e*s+t]||(g[~e*s+t]={source:null,target:null});n.target={index:t,startAngle:r,endAngle:r+=i[~e*s+t]*h,value:i[~e*s+t]}}else{const n=g[t*s+e]||(g[t*s+e]={source:null,target:null});n.source={index:t,startAngle:r,endAngle:r+=i[t*s+e]*h,value:i[t*s+e]}}b[t]={index:t,startAngle:e,endAngle:r,value:f[t]}}else{const n=range(0,s).filter((n=>i[t*s+n]||i[n*s+t]));o&&n.sort(((n,r)=>o(i[t*s+n],i[t*s+r])));for(const e of n){let n;if(t<e){n=g[t*s+e]||(g[t*s+e]={source:null,target:null});n.source={index:t,startAngle:r,endAngle:r+=i[t*s+e]*h,value:i[t*s+e]}}else{n=g[e*s+t]||(g[e*s+t]={source:null,target:null});n.target={index:t,startAngle:r,endAngle:r+=i[t*s+e]*h,value:i[t*s+e]};t===e&&(n.source=n.target)}if(n.source&&n.target&&n.source.value<n.target.value){const r=n.source;n.source=n.target;n.target=r}}b[t]={index:t,startAngle:e,endAngle:r,value:f[t]}}r+=c}}g=Object.values(g);g.groups=b;return u?g.sort(u):g}chord.padAngle=function(n){return arguments.length?(t=l(0,n),chord):t};chord.sortGroups=function(n){return arguments.length?(e=n,chord):e};chord.sortSubgroups=function(n){return arguments.length?(o=n,chord):o};chord.sortChords=function(n){return arguments.length?(null==n?u=null:(u=compareValue(n))._=n,chord):u&&u._};return chord}var c=Array.prototype.slice;function constant(n){return function(){return n}}function defaultSource(n){return n.source}function defaultTarget(n){return n.target}function defaultRadius(n){return n.radius}function defaultStartAngle(n){return n.startAngle}function defaultEndAngle(n){return n.endAngle}function defaultPadAngle(){return 0}function defaultArrowheadRadius(){return 10}function ribbon(o){var a=defaultSource,l=defaultTarget,s=defaultRadius,f=defaultRadius,d=defaultStartAngle,g=defaultEndAngle,b=defaultPadAngle,h=null;function ribbon(){var p,A=a.apply(this,arguments),v=l.apply(this,arguments),y=b.apply(this,arguments)/2,T=c.call(arguments),x=+s.apply(this,(T[0]=A,T)),m=d.apply(this,T)-u,R=g.apply(this,T)-u,w=+f.apply(this,(T[0]=v,T)),$=d.apply(this,T)-u,M=g.apply(this,T)-u;h||(h=p=n());if(y>i){r(R-m)>2*y+i?R>m?(m+=y,R-=y):(m-=y,R+=y):m=R=(m+R)/2;r(M-$)>2*y+i?M>$?($+=y,M-=y):($-=y,M+=y):$=M=($+M)/2}h.moveTo(x*t(m),x*e(m));h.arc(0,0,x,m,R);if(m!==$||R!==M)if(o){var S=+o.apply(this,arguments),C=w-S,P=($+M)/2;h.quadraticCurveTo(0,0,C*t($),C*e($));h.lineTo(w*t(P),w*e(P));h.lineTo(C*t(M),C*e(M))}else{h.quadraticCurveTo(0,0,w*t($),w*e($));h.arc(0,0,w,$,M)}h.quadraticCurveTo(0,0,x*t(m),x*e(m));h.closePath();if(p)return h=null,p+\"\"||null}o&&(ribbon.headRadius=function(n){return arguments.length?(o=\"function\"===typeof n?n:constant(+n),ribbon):o});ribbon.radius=function(n){return arguments.length?(s=f=\"function\"===typeof n?n:constant(+n),ribbon):s};ribbon.sourceRadius=function(n){return arguments.length?(s=\"function\"===typeof n?n:constant(+n),ribbon):s};ribbon.targetRadius=function(n){return arguments.length?(f=\"function\"===typeof n?n:constant(+n),ribbon):f};ribbon.startAngle=function(n){return arguments.length?(d=\"function\"===typeof n?n:constant(+n),ribbon):d};ribbon.endAngle=function(n){return arguments.length?(g=\"function\"===typeof n?n:constant(+n),ribbon):g};ribbon.padAngle=function(n){return arguments.length?(b=\"function\"===typeof n?n:constant(+n),ribbon):b};ribbon.source=function(n){return arguments.length?(a=n,ribbon):a};ribbon.target=function(n){return arguments.length?(l=n,ribbon):l};ribbon.context=function(n){return arguments.length?(h=null==n?null:n,ribbon):h};return ribbon}function ribbon$1(){return ribbon()}function ribbonArrow(){return ribbon(defaultArrowheadRadius)}export{chord,chordDirected,chordTranspose,ribbon$1 as ribbon,ribbonArrow};\n\n"
  },
  {
    "path": "vendor/javascript/d3-color.js",
    "content": "// d3-color@3.1.0 downloaded from https://ga.jspm.io/npm:d3-color@3.1.0/src/index.js\n\nfunction define(t,e,r){t.prototype=e.prototype=r;r.constructor=t}function extend(t,e){var r=Object.create(t.prototype);for(var n in e)r[n]=e[n];return r}function Color(){}var t=.7;var e=1/t;var r=\"\\\\s*([+-]?\\\\d+)\\\\s*\",n=\"\\\\s*([+-]?(?:\\\\d*\\\\.)?\\\\d+(?:[eE][+-]?\\\\d+)?)\\\\s*\",i=\"\\\\s*([+-]?(?:\\\\d*\\\\.)?\\\\d+(?:[eE][+-]?\\\\d+)?)%\\\\s*\",a=/^#([0-9a-f]{3,8})$/,l=new RegExp(`^rgb\\\\(${r},${r},${r}\\\\)$`),o=new RegExp(`^rgb\\\\(${i},${i},${i}\\\\)$`),h=new RegExp(`^rgba\\\\(${r},${r},${r},${n}\\\\)$`),s=new RegExp(`^rgba\\\\(${i},${i},${i},${n}\\\\)$`),c=new RegExp(`^hsl\\\\(${n},${i},${i}\\\\)$`),b=new RegExp(`^hsla\\\\(${n},${i},${i},${n}\\\\)$`);var u={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074};define(Color,color,{copy(t){return Object.assign(new this.constructor,this,t)},displayable(){return this.rgb().displayable()},hex:color_formatHex,formatHex:color_formatHex,formatHex8:color_formatHex8,formatHsl:color_formatHsl,formatRgb:color_formatRgb,toString:color_formatRgb});function color_formatHex(){return this.rgb().formatHex()}function color_formatHex8(){return this.rgb().formatHex8()}function color_formatHsl(){return hslConvert(this).formatHsl()}function color_formatRgb(){return this.rgb().formatRgb()}function color(t){var e,r;t=(t+\"\").trim().toLowerCase();return(e=a.exec(t))?(r=e[1].length,e=parseInt(e[1],16),6===r?rgbn(e):3===r?new Rgb(e>>8&15|e>>4&240,e>>4&15|240&e,(15&e)<<4|15&e,1):8===r?rgba(e>>24&255,e>>16&255,e>>8&255,(255&e)/255):4===r?rgba(e>>12&15|e>>8&240,e>>8&15|e>>4&240,e>>4&15|240&e,((15&e)<<4|15&e)/255):null):(e=l.exec(t))?new Rgb(e[1],e[2],e[3],1):(e=o.exec(t))?new Rgb(255*e[1]/100,255*e[2]/100,255*e[3]/100,1):(e=h.exec(t))?rgba(e[1],e[2],e[3],e[4]):(e=s.exec(t))?rgba(255*e[1]/100,255*e[2]/100,255*e[3]/100,e[4]):(e=c.exec(t))?hsla(e[1],e[2]/100,e[3]/100,1):(e=b.exec(t))?hsla(e[1],e[2]/100,e[3]/100,e[4]):u.hasOwnProperty(t)?rgbn(u[t]):\"transparent\"===t?new Rgb(NaN,NaN,NaN,0):null}function rgbn(t){return new Rgb(t>>16&255,t>>8&255,255&t,1)}function rgba(t,e,r,n){n<=0&&(t=e=r=NaN);return new Rgb(t,e,r,n)}function rgbConvert(t){t instanceof Color||(t=color(t));if(!t)return new Rgb;t=t.rgb();return new Rgb(t.r,t.g,t.b,t.opacity)}function rgb(t,e,r,n){return 1===arguments.length?rgbConvert(t):new Rgb(t,e,r,null==n?1:n)}function Rgb(t,e,r,n){this.r=+t;this.g=+e;this.b=+r;this.opacity=+n}define(Rgb,rgb,extend(Color,{brighter(t){t=null==t?e:Math.pow(e,t);return new Rgb(this.r*t,this.g*t,this.b*t,this.opacity)},darker(e){e=null==e?t:Math.pow(t,e);return new Rgb(this.r*e,this.g*e,this.b*e,this.opacity)},rgb(){return this},clamp(){return new Rgb(clampi(this.r),clampi(this.g),clampi(this.b),clampa(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:rgb_formatHex,formatHex:rgb_formatHex,formatHex8:rgb_formatHex8,formatRgb:rgb_formatRgb,toString:rgb_formatRgb}));function rgb_formatHex(){return`#${hex(this.r)}${hex(this.g)}${hex(this.b)}`}function rgb_formatHex8(){return`#${hex(this.r)}${hex(this.g)}${hex(this.b)}${hex(255*(isNaN(this.opacity)?1:this.opacity))}`}function rgb_formatRgb(){const t=clampa(this.opacity);return`${1===t?\"rgb(\":\"rgba(\"}${clampi(this.r)}, ${clampi(this.g)}, ${clampi(this.b)}${1===t?\")\":`, ${t})`}`}function clampa(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function clampi(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function hex(t){t=clampi(t);return(t<16?\"0\":\"\")+t.toString(16)}function hsla(t,e,r,n){n<=0?t=e=r=NaN:r<=0||r>=1?t=e=NaN:e<=0&&(t=NaN);return new Hsl(t,e,r,n)}function hslConvert(t){if(t instanceof Hsl)return new Hsl(t.h,t.s,t.l,t.opacity);t instanceof Color||(t=color(t));if(!t)return new Hsl;if(t instanceof Hsl)return t;t=t.rgb();var e=t.r/255,r=t.g/255,n=t.b/255,i=Math.min(e,r,n),a=Math.max(e,r,n),l=NaN,o=a-i,h=(a+i)/2;if(o){l=e===a?(r-n)/o+6*(r<n):r===a?(n-e)/o+2:(e-r)/o+4;o/=h<.5?a+i:2-a-i;l*=60}else o=h>0&&h<1?0:l;return new Hsl(l,o,h,t.opacity)}function hsl(t,e,r,n){return 1===arguments.length?hslConvert(t):new Hsl(t,e,r,null==n?1:n)}function Hsl(t,e,r,n){this.h=+t;this.s=+e;this.l=+r;this.opacity=+n}define(Hsl,hsl,extend(Color,{brighter(t){t=null==t?e:Math.pow(e,t);return new Hsl(this.h,this.s,this.l*t,this.opacity)},darker(e){e=null==e?t:Math.pow(t,e);return new Hsl(this.h,this.s,this.l*e,this.opacity)},rgb(){var t=this.h%360+360*(this.h<0),e=isNaN(t)||isNaN(this.s)?0:this.s,r=this.l,n=r+(r<.5?r:1-r)*e,i=2*r-n;return new Rgb(hsl2rgb(t>=240?t-240:t+120,i,n),hsl2rgb(t,i,n),hsl2rgb(t<120?t+240:t-120,i,n),this.opacity)},clamp(){return new Hsl(clamph(this.h),clampt(this.s),clampt(this.l),clampa(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){const t=clampa(this.opacity);return`${1===t?\"hsl(\":\"hsla(\"}${clamph(this.h)}, ${100*clampt(this.s)}%, ${100*clampt(this.l)}%${1===t?\")\":`, ${t})`}`}}));function clamph(t){t=(t||0)%360;return t<0?t+360:t}function clampt(t){return Math.max(0,Math.min(1,t||0))}function hsl2rgb(t,e,r){return 255*(t<60?e+(r-e)*t/60:t<180?r:t<240?e+(r-e)*(240-t)/60:e)}const g=Math.PI/180;const p=180/Math.PI;const f=18,m=.96422,d=1,y=.82521,w=4/29,x=6/29,$=3*x*x,v=x*x*x;function labConvert(t){if(t instanceof Lab)return new Lab(t.l,t.a,t.b,t.opacity);if(t instanceof Hcl)return hcl2lab(t);t instanceof Rgb||(t=rgbConvert(t));var e,r,n=rgb2lrgb(t.r),i=rgb2lrgb(t.g),a=rgb2lrgb(t.b),l=xyz2lab((.2225045*n+.7168786*i+.0606169*a)/d);if(n===i&&i===a)e=r=l;else{e=xyz2lab((.4360747*n+.3850649*i+.1430804*a)/m);r=xyz2lab((.0139322*n+.0971045*i+.7141733*a)/y)}return new Lab(116*l-16,500*(e-l),200*(l-r),t.opacity)}function gray(t,e){return new Lab(t,0,0,null==e?1:e)}function lab(t,e,r,n){return 1===arguments.length?labConvert(t):new Lab(t,e,r,null==n?1:n)}function Lab(t,e,r,n){this.l=+t;this.a=+e;this.b=+r;this.opacity=+n}define(Lab,lab,extend(Color,{brighter(t){return new Lab(this.l+f*(null==t?1:t),this.a,this.b,this.opacity)},darker(t){return new Lab(this.l-f*(null==t?1:t),this.a,this.b,this.opacity)},rgb(){var t=(this.l+16)/116,e=isNaN(this.a)?t:t+this.a/500,r=isNaN(this.b)?t:t-this.b/200;e=m*lab2xyz(e);t=d*lab2xyz(t);r=y*lab2xyz(r);return new Rgb(lrgb2rgb(3.1338561*e-1.6168667*t-.4906146*r),lrgb2rgb(-.9787684*e+1.9161415*t+.033454*r),lrgb2rgb(.0719453*e-.2289914*t+1.4052427*r),this.opacity)}}));function xyz2lab(t){return t>v?Math.pow(t,1/3):t/$+w}function lab2xyz(t){return t>x?t*t*t:$*(t-w)}function lrgb2rgb(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function rgb2lrgb(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function hclConvert(t){if(t instanceof Hcl)return new Hcl(t.h,t.c,t.l,t.opacity);t instanceof Lab||(t=labConvert(t));if(0===t.a&&0===t.b)return new Hcl(NaN,0<t.l&&t.l<100?0:NaN,t.l,t.opacity);var e=Math.atan2(t.b,t.a)*p;return new Hcl(e<0?e+360:e,Math.sqrt(t.a*t.a+t.b*t.b),t.l,t.opacity)}function lch(t,e,r,n){return 1===arguments.length?hclConvert(t):new Hcl(r,e,t,null==n?1:n)}function hcl(t,e,r,n){return 1===arguments.length?hclConvert(t):new Hcl(t,e,r,null==n?1:n)}function Hcl(t,e,r,n){this.h=+t;this.c=+e;this.l=+r;this.opacity=+n}function hcl2lab(t){if(isNaN(t.h))return new Lab(t.l,0,0,t.opacity);var e=t.h*g;return new Lab(t.l,Math.cos(e)*t.c,Math.sin(e)*t.c,t.opacity)}define(Hcl,hcl,extend(Color,{brighter(t){return new Hcl(this.h,this.c,this.l+f*(null==t?1:t),this.opacity)},darker(t){return new Hcl(this.h,this.c,this.l-f*(null==t?1:t),this.opacity)},rgb(){return hcl2lab(this).rgb()}}));var H=-.14861,N=1.78277,k=-.29227,R=-.90649,C=1.97294,M=C*R,_=C*N,L=N*k-R*H;function cubehelixConvert(t){if(t instanceof Cubehelix)return new Cubehelix(t.h,t.s,t.l,t.opacity);t instanceof Rgb||(t=rgbConvert(t));var e=t.r/255,r=t.g/255,n=t.b/255,i=(L*n+M*e-_*r)/(L+M-_),a=n-i,l=(C*(r-i)-k*a)/R,o=Math.sqrt(l*l+a*a)/(C*i*(1-i)),h=o?Math.atan2(l,a)*p-120:NaN;return new Cubehelix(h<0?h+360:h,o,i,t.opacity)}function cubehelix(t,e,r,n){return 1===arguments.length?cubehelixConvert(t):new Cubehelix(t,e,r,null==n?1:n)}function Cubehelix(t,e,r,n){this.h=+t;this.s=+e;this.l=+r;this.opacity=+n}define(Cubehelix,cubehelix,extend(Color,{brighter(t){t=null==t?e:Math.pow(e,t);return new Cubehelix(this.h,this.s,this.l*t,this.opacity)},darker(e){e=null==e?t:Math.pow(t,e);return new Cubehelix(this.h,this.s,this.l*e,this.opacity)},rgb(){var t=isNaN(this.h)?0:(this.h+120)*g,e=+this.l,r=isNaN(this.s)?0:this.s*e*(1-e),n=Math.cos(t),i=Math.sin(t);return new Rgb(255*(e+r*(H*n+N*i)),255*(e+r*(k*n+R*i)),255*(e+r*(C*n)),this.opacity)}}));export{color,cubehelix,gray,hcl,hsl,lab,lch,rgb};\n\n"
  },
  {
    "path": "vendor/javascript/d3-contour.js",
    "content": "// d3-contour@4.0.2 downloaded from https://ga.jspm.io/npm:d3-contour@4.0.2/src/index.js\n\nimport{thresholdSturges as n,extent as t,ticks as r,nice as o,blur2 as i,max as e}from\"d3-array\";var a=Array.prototype;var s=a.slice;function ascending(n,t){return n-t}function area(n){var t=0,r=n.length,o=n[r-1][1]*n[0][0]-n[r-1][0]*n[0][1];while(++t<r)o+=n[t-1][1]*n[t][0]-n[t-1][0]*n[t][1];return o}var constant=n=>()=>n;function contains(n,t){var r,o=-1,i=t.length;while(++o<i)if(r=ringContains(n,t[o]))return r;return 0}function ringContains(n,t){var r=t[0],o=t[1],i=-1;for(var e=0,a=n.length,s=a-1;e<a;s=e++){var u=n[e],f=u[0],c=u[1],h=n[s],l=h[0],d=h[1];if(segmentContains(u,h,t))return 0;c>o!==d>o&&r<(l-f)*(o-c)/(d-c)+f&&(i=-i)}return i}function segmentContains(n,t,r){var o;return collinear(n,t,r)&&within(n[o=+(n[0]===t[0])],r[o],t[o])}function collinear(n,t,r){return(t[0]-n[0])*(r[1]-n[1])===(r[0]-n[0])*(t[1]-n[1])}function within(n,t,r){return n<=t&&t<=r||r<=t&&t<=n}function noop(){}var u=[[],[[[1,1.5],[.5,1]]],[[[1.5,1],[1,1.5]]],[[[1.5,1],[.5,1]]],[[[1,.5],[1.5,1]]],[[[1,1.5],[.5,1]],[[1,.5],[1.5,1]]],[[[1,.5],[1,1.5]]],[[[1,.5],[.5,1]]],[[[.5,1],[1,.5]]],[[[1,1.5],[1,.5]]],[[[.5,1],[1,.5]],[[1.5,1],[1,1.5]]],[[[1.5,1],[1,.5]]],[[[.5,1],[1.5,1]]],[[[1,1.5],[1.5,1]]],[[[.5,1],[1,1.5]]],[]];function Contours(){var i=1,e=1,a=n,f=smoothLinear;function contours(n){var i=a(n);if(Array.isArray(i))i=i.slice().sort(ascending);else{const e=t(n,finite);i=r(...o(e[0],e[1],i),i);while(i[i.length-1]>=e[1])i.pop();while(i[1]<e[0])i.shift()}return i.map((t=>contour(n,t)))}function contour(n,t){const r=null==t?NaN:+t;if(isNaN(r))throw new Error(`invalid value: ${t}`);var o=[],i=[];isorings(n,r,(function(t){f(t,n,r);area(t)>0?o.push([t]):i.push(t)}));i.forEach((function(n){for(var t,r=0,i=o.length;r<i;++r)if(-1!==contains((t=o[r])[0],n)){t.push(n);return}}));return{type:\"MultiPolygon\",value:t,coordinates:o}}function isorings(n,t,r){var o,a,s,f,c,h,l=new Array,d=new Array;o=a=-1;f=above(n[0],t);u[f<<1].forEach(stitch);while(++o<i-1){s=f,f=above(n[o+1],t);u[s|f<<1].forEach(stitch)}u[f<<0].forEach(stitch);while(++a<e-1){o=-1;f=above(n[a*i+i],t);c=above(n[a*i],t);u[f<<1|c<<2].forEach(stitch);while(++o<i-1){s=f,f=above(n[a*i+i+o+1],t);h=c,c=above(n[a*i+o+1],t);u[s|f<<1|c<<2|h<<3].forEach(stitch)}u[f|c<<3].forEach(stitch)}o=-1;c=n[a*i]>=t;u[c<<2].forEach(stitch);while(++o<i-1){h=c,c=above(n[a*i+o+1],t);u[c<<2|h<<3].forEach(stitch)}u[c<<3].forEach(stitch);function stitch(n){var t,i,e=[n[0][0]+o,n[0][1]+a],s=[n[1][0]+o,n[1][1]+a],u=index(e),f=index(s);if(t=d[u])if(i=l[f]){delete d[t.end];delete l[i.start];if(t===i){t.ring.push(s);r(t.ring)}else l[t.start]=d[i.end]={start:t.start,end:i.end,ring:t.ring.concat(i.ring)}}else{delete d[t.end];t.ring.push(s);d[t.end=f]=t}else if(t=l[f])if(i=d[u]){delete l[t.start];delete d[i.end];if(t===i){t.ring.push(s);r(t.ring)}else l[i.start]=d[t.end]={start:i.start,end:t.end,ring:i.ring.concat(t.ring)}}else{delete l[t.start];t.ring.unshift(e);l[t.start=u]=t}else l[u]=d[f]={start:u,end:f,ring:[e,s]}}}function index(n){return 2*n[0]+n[1]*(i+1)*4}function smoothLinear(n,t,r){n.forEach((function(n){var o=n[0],a=n[1],s=0|o,u=0|a,f=valid(t[u*i+s]);o>0&&o<i&&s===o&&(n[0]=smooth1(o,valid(t[u*i+s-1]),f,r));a>0&&a<e&&u===a&&(n[1]=smooth1(a,valid(t[(u-1)*i+s]),f,r))}))}contours.contour=contour;contours.size=function(n){if(!arguments.length)return[i,e];var t=Math.floor(n[0]),r=Math.floor(n[1]);if(!(t>=0&&r>=0))throw new Error(\"invalid size\");return i=t,e=r,contours};contours.thresholds=function(n){return arguments.length?(a=\"function\"===typeof n?n:Array.isArray(n)?constant(s.call(n)):constant(n),contours):a};contours.smooth=function(n){return arguments.length?(f=n?smoothLinear:noop,contours):f===smoothLinear};return contours}function finite(n){return isFinite(n)?n:NaN}function above(n,t){return null!=n&&+n>=t}function valid(n){return null==n||isNaN(n=+n)?-Infinity:n}function smooth1(n,t,r,o){const i=o-t;const e=r-t;const a=isFinite(i)||isFinite(e)?i/e:Math.sign(i)/Math.sign(e);return isNaN(a)?n:n+a-.5}function defaultX(n){return n[0]}function defaultY(n){return n[1]}function defaultWeight(){return 1}function density(){var n=defaultX,t=defaultY,o=defaultWeight,a=960,u=500,f=20,c=2,h=3*f,l=a+2*h>>c,d=u+2*h>>c,g=constant(20);function grid(r){var e=new Float32Array(l*d),a=Math.pow(2,-c),s=-1;for(const i of r){var u=(n(i,++s,r)+h)*a,g=(t(i,s,r)+h)*a,v=+o(i,s,r);if(v&&u>=0&&u<l&&g>=0&&g<d){var y=Math.floor(u),w=Math.floor(g),p=u-y-.5,m=g-w-.5;e[y+w*l]+=(1-p)*(1-m)*v;e[y+1+w*l]+=p*(1-m)*v;e[y+1+(w+1)*l]+=p*m*v;e[y+(w+1)*l]+=(1-p)*m*v}}i({data:e,width:l,height:d},f*a);return e}function density(n){var t=grid(n),o=g(t),i=Math.pow(2,2*c);Array.isArray(o)||(o=r(Number.MIN_VALUE,e(t)/i,o));return Contours().size([l,d]).thresholds(o.map((n=>n*i)))(t).map(((n,t)=>(n.value=+o[t],transform(n))))}density.contours=function(n){var t=grid(n),r=Contours().size([l,d]),o=Math.pow(2,2*c),contour=n=>{n=+n;var i=transform(r.contour(t,n*o));i.value=n;return i};Object.defineProperty(contour,\"max\",{get:()=>e(t)/o});return contour};function transform(n){n.coordinates.forEach(transformPolygon);return n}function transformPolygon(n){n.forEach(transformRing)}function transformRing(n){n.forEach(transformPoint)}function transformPoint(n){n[0]=n[0]*Math.pow(2,c)-h;n[1]=n[1]*Math.pow(2,c)-h}function resize(){h=3*f;l=a+2*h>>c;d=u+2*h>>c;return density}density.x=function(t){return arguments.length?(n=\"function\"===typeof t?t:constant(+t),density):n};density.y=function(n){return arguments.length?(t=\"function\"===typeof n?n:constant(+n),density):t};density.weight=function(n){return arguments.length?(o=\"function\"===typeof n?n:constant(+n),density):o};density.size=function(n){if(!arguments.length)return[a,u];var t=+n[0],r=+n[1];if(!(t>=0&&r>=0))throw new Error(\"invalid size\");return a=t,u=r,resize()};density.cellSize=function(n){if(!arguments.length)return 1<<c;if(!((n=+n)>=1))throw new Error(\"invalid cell size\");return c=Math.floor(Math.log(n)/Math.LN2),resize()};density.thresholds=function(n){return arguments.length?(g=\"function\"===typeof n?n:Array.isArray(n)?constant(s.call(n)):constant(n),density):g};density.bandwidth=function(n){if(!arguments.length)return Math.sqrt(f*(f+1));if(!((n=+n)>=0))throw new Error(\"invalid bandwidth\");return f=(Math.sqrt(4*n*n+1)-1)/2,resize()};return density}export{density as contourDensity,Contours as contours};\n\n"
  },
  {
    "path": "vendor/javascript/d3-delaunay.js",
    "content": "// d3-delaunay@6.0.4 downloaded from https://ga.jspm.io/npm:d3-delaunay@6.0.4/src/index.js\n\nimport t from\"delaunator\";const n=1e-6;class Path{constructor(){this._x0=this._y0=this._x1=this._y1=null;this._=\"\"}moveTo(t,n){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}`}closePath(){if(null!==this._x1){this._x1=this._x0,this._y1=this._y0;this._+=\"Z\"}}lineTo(t,n){this._+=`L${this._x1=+t},${this._y1=+n}`}arc(t,e,i){t=+t,e=+e,i=+i;const s=t+i;const l=e;if(i<0)throw new Error(\"negative radius\");null===this._x1?this._+=`M${s},${l}`:(Math.abs(this._x1-s)>n||Math.abs(this._y1-l)>n)&&(this._+=\"L\"+s+\",\"+l);i&&(this._+=`A${i},${i},0,1,1,${t-i},${e}A${i},${i},0,1,1,${this._x1=s},${this._y1=l}`)}rect(t,n,e,i){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}h${+e}v${+i}h${-e}Z`}value(){return this._||null}}class Polygon{constructor(){this._=[]}moveTo(t,n){this._.push([t,n])}closePath(){this._.push(this._[0].slice())}lineTo(t,n){this._.push([t,n])}value(){return this._.length?this._:null}}class Voronoi{constructor(t,[n,e,i,s]=[0,0,960,500]){if(!((i=+i)>=(n=+n))||!((s=+s)>=(e=+e)))throw new Error(\"invalid bounds\");this.delaunay=t;this._circumcenters=new Float64Array(2*t.points.length);this.vectors=new Float64Array(2*t.points.length);this.xmax=i,this.xmin=n;this.ymax=s,this.ymin=e;this._init()}update(){this.delaunay.update();this._init();return this}_init(){const{delaunay:{points:t,hull:n,triangles:e},vectors:i}=this;let s,l;const h=this.circumcenters=this._circumcenters.subarray(0,e.length/3*2);for(let i,o,r=0,c=0,a=e.length;r<a;r+=3,c+=2){const a=2*e[r];const u=2*e[r+1];const g=2*e[r+2];const f=t[a];const d=t[a+1];const m=t[u];const _=t[u+1];const y=t[g];const x=t[g+1];const p=m-f;const v=_-d;const w=y-f;const P=x-d;const T=2*(p*P-v*w);if(Math.abs(T)<1e-9){if(void 0===s){s=l=0;for(const e of n)s+=t[2*e],l+=t[2*e+1];s/=n.length,l/=n.length}const e=1e9*Math.sign((s-f)*P-(l-d)*w);i=(f+y)/2-e*P;o=(d+x)/2+e*w}else{const t=1/T;const n=p*p+v*v;const e=w*w+P*P;i=f+(P*n-v*e)*t;o=d+(p*e-w*n)*t}h[c]=i;h[c+1]=o}let o=n[n.length-1];let r,c=4*o;let a,u=t[2*o];let g,f=t[2*o+1];i.fill(0);for(let e=0;e<n.length;++e){o=n[e];r=c,a=u,g=f;c=4*o,u=t[2*o],f=t[2*o+1];i[r+2]=i[c]=g-f;i[r+3]=i[c+1]=u-a}}render(t){const n=null==t?t=new Path:void 0;const{delaunay:{halfedges:e,inedges:i,hull:s},circumcenters:l,vectors:h}=this;if(s.length<=1)return null;for(let n=0,i=e.length;n<i;++n){const i=e[n];if(i<n)continue;const s=2*Math.floor(n/3);const h=2*Math.floor(i/3);const o=l[s];const r=l[s+1];const c=l[h];const a=l[h+1];this._renderSegment(o,r,c,a,t)}let o,r=s[s.length-1];for(let n=0;n<s.length;++n){o=r,r=s[n];const e=2*Math.floor(i[r]/3);const c=l[e];const a=l[e+1];const u=4*o;const g=this._project(c,a,h[u+2],h[u+3]);g&&this._renderSegment(c,a,g[0],g[1],t)}return n&&n.value()}renderBounds(t){const n=null==t?t=new Path:void 0;t.rect(this.xmin,this.ymin,this.xmax-this.xmin,this.ymax-this.ymin);return n&&n.value()}renderCell(t,n){const e=null==n?n=new Path:void 0;const i=this._clip(t);if(null===i||!i.length)return;n.moveTo(i[0],i[1]);let s=i.length;while(i[0]===i[s-2]&&i[1]===i[s-1]&&s>1)s-=2;for(let t=2;t<s;t+=2)i[t]===i[t-2]&&i[t+1]===i[t-1]||n.lineTo(i[t],i[t+1]);n.closePath();return e&&e.value()}*cellPolygons(){const{delaunay:{points:t}}=this;for(let n=0,e=t.length/2;n<e;++n){const t=this.cellPolygon(n);t&&(t.index=n,yield t)}}cellPolygon(t){const n=new Polygon;this.renderCell(t,n);return n.value()}_renderSegment(t,n,e,i,s){let l;const h=this._regioncode(t,n);const o=this._regioncode(e,i);if(0===h&&0===o){s.moveTo(t,n);s.lineTo(e,i)}else if(l=this._clipSegment(t,n,e,i,h,o)){s.moveTo(l[0],l[1]);s.lineTo(l[2],l[3])}}contains(t,n,e){return(n=+n,n===n)&&(e=+e,e===e)&&this.delaunay._step(t,n,e)===t}*neighbors(t){const n=this._clip(t);if(n)for(const e of this.delaunay.neighbors(t)){const t=this._clip(e);if(t)t:for(let i=0,s=n.length;i<s;i+=2)for(let l=0,h=t.length;l<h;l+=2)if(n[i]===t[l]&&n[i+1]===t[l+1]&&n[(i+2)%s]===t[(l+h-2)%h]&&n[(i+3)%s]===t[(l+h-1)%h]){yield e;break t}}}_cell(t){const{circumcenters:n,delaunay:{inedges:e,halfedges:i,triangles:s}}=this;const l=e[t];if(-1===l)return null;const h=[];let o=l;do{const e=Math.floor(o/3);h.push(n[2*e],n[2*e+1]);o=o%3===2?o-2:o+1;if(s[o]!==t)break;o=i[o]}while(o!==l&&-1!==o);return h}_clip(t){if(0===t&&1===this.delaunay.hull.length)return[this.xmax,this.ymin,this.xmax,this.ymax,this.xmin,this.ymax,this.xmin,this.ymin];const n=this._cell(t);if(null===n)return null;const{vectors:e}=this;const i=4*t;return this._simplify(e[i]||e[i+1]?this._clipInfinite(t,n,e[i],e[i+1],e[i+2],e[i+3]):this._clipFinite(t,n))}_clipFinite(t,n){const e=n.length;let i=null;let s,l,h=n[e-2],o=n[e-1];let r,c=this._regioncode(h,o);let a,u=0;for(let g=0;g<e;g+=2){s=h,l=o,h=n[g],o=n[g+1];r=c,c=this._regioncode(h,o);if(0===r&&0===c){a=u,u=0;i?i.push(h,o):i=[h,o]}else{let n,e,g,f,d;if(0===r){if(null===(n=this._clipSegment(s,l,h,o,r,c)))continue;[e,g,f,d]=n}else{if(null===(n=this._clipSegment(h,o,s,l,c,r)))continue;[f,d,e,g]=n;a=u,u=this._edgecode(e,g);a&&u&&this._edge(t,a,u,i,i.length);i?i.push(e,g):i=[e,g]}a=u,u=this._edgecode(f,d);a&&u&&this._edge(t,a,u,i,i.length);i?i.push(f,d):i=[f,d]}}if(i){a=u,u=this._edgecode(i[0],i[1]);a&&u&&this._edge(t,a,u,i,i.length)}else if(this.contains(t,(this.xmin+this.xmax)/2,(this.ymin+this.ymax)/2))return[this.xmax,this.ymin,this.xmax,this.ymax,this.xmin,this.ymax,this.xmin,this.ymin];return i}_clipSegment(t,n,e,i,s,l){const h=s<l;h&&([t,n,e,i,s,l]=[e,i,t,n,l,s]);while(true){if(0===s&&0===l)return h?[e,i,t,n]:[t,n,e,i];if(s&l)return null;let o,r,c=s||l;8&c?(o=t+(e-t)*(this.ymax-n)/(i-n),r=this.ymax):4&c?(o=t+(e-t)*(this.ymin-n)/(i-n),r=this.ymin):2&c?(r=n+(i-n)*(this.xmax-t)/(e-t),o=this.xmax):(r=n+(i-n)*(this.xmin-t)/(e-t),o=this.xmin);s?(t=o,n=r,s=this._regioncode(t,n)):(e=o,i=r,l=this._regioncode(e,i))}}_clipInfinite(t,n,e,i,s,l){let h,o=Array.from(n);(h=this._project(o[0],o[1],e,i))&&o.unshift(h[0],h[1]);(h=this._project(o[o.length-2],o[o.length-1],s,l))&&o.push(h[0],h[1]);if(o=this._clipFinite(t,o))for(let n,e=0,i=o.length,s=this._edgecode(o[i-2],o[i-1]);e<i;e+=2){n=s,s=this._edgecode(o[e],o[e+1]);n&&s&&(e=this._edge(t,n,s,o,e),i=o.length)}else this.contains(t,(this.xmin+this.xmax)/2,(this.ymin+this.ymax)/2)&&(o=[this.xmin,this.ymin,this.xmax,this.ymin,this.xmax,this.ymax,this.xmin,this.ymax]);return o}_edge(t,n,e,i,s){while(n!==e){let e,l;switch(n){case 5:n=4;continue;case 4:n=6,e=this.xmax,l=this.ymin;break;case 6:n=2;continue;case 2:n=10,e=this.xmax,l=this.ymax;break;case 10:n=8;continue;case 8:n=9,e=this.xmin,l=this.ymax;break;case 9:n=1;continue;case 1:n=5,e=this.xmin,l=this.ymin;break}i[s]===e&&i[s+1]===l||!this.contains(t,e,l)||(i.splice(s,0,e,l),s+=2)}return s}_project(t,n,e,i){let s,l,h,o=Infinity;if(i<0){if(n<=this.ymin)return null;(s=(this.ymin-n)/i)<o&&(h=this.ymin,l=t+(o=s)*e)}else if(i>0){if(n>=this.ymax)return null;(s=(this.ymax-n)/i)<o&&(h=this.ymax,l=t+(o=s)*e)}if(e>0){if(t>=this.xmax)return null;(s=(this.xmax-t)/e)<o&&(l=this.xmax,h=n+(o=s)*i)}else if(e<0){if(t<=this.xmin)return null;(s=(this.xmin-t)/e)<o&&(l=this.xmin,h=n+(o=s)*i)}return[l,h]}_edgecode(t,n){return(t===this.xmin?1:t===this.xmax?2:0)|(n===this.ymin?4:n===this.ymax?8:0)}_regioncode(t,n){return(t<this.xmin?1:t>this.xmax?2:0)|(n<this.ymin?4:n>this.ymax?8:0)}_simplify(t){if(t&&t.length>4){for(let n=0;n<t.length;n+=2){const e=(n+2)%t.length,i=(n+4)%t.length;(t[n]===t[e]&&t[e]===t[i]||t[n+1]===t[e+1]&&t[e+1]===t[i+1])&&(t.splice(e,2),n-=2)}t.length||(t=null)}return t}}const e=2*Math.PI,i=Math.pow;function pointX(t){return t[0]}function pointY(t){return t[1]}function collinear(t){const{triangles:n,coords:e}=t;for(let t=0;t<n.length;t+=3){const i=2*n[t],s=2*n[t+1],l=2*n[t+2],h=(e[l]-e[i])*(e[s+1]-e[i+1])-(e[s]-e[i])*(e[l+1]-e[i+1]);if(h>1e-10)return false}return true}function jitter(t,n,e){return[t+Math.sin(t+n)*e,n+Math.cos(t-n)*e]}class Delaunay{static from(t,n=pointX,e=pointY,i){return new Delaunay(\"length\"in t?flatArray(t,n,e,i):Float64Array.from(flatIterable(t,n,e,i)))}constructor(n){this._delaunator=new t(n);this.inedges=new Int32Array(n.length/2);this._hullIndex=new Int32Array(n.length/2);this.points=this._delaunator.coords;this._init()}update(){this._delaunator.update();this._init();return this}_init(){const n=this._delaunator,e=this.points;if(n.hull&&n.hull.length>2&&collinear(n)){this.collinear=Int32Array.from({length:e.length/2},((t,n)=>n)).sort(((t,n)=>e[2*t]-e[2*n]||e[2*t+1]-e[2*n+1]));const n=this.collinear[0],i=this.collinear[this.collinear.length-1],s=[e[2*n],e[2*n+1],e[2*i],e[2*i+1]],l=1e-8*Math.hypot(s[3]-s[1],s[2]-s[0]);for(let t=0,n=e.length/2;t<n;++t){const n=jitter(e[2*t],e[2*t+1],l);e[2*t]=n[0];e[2*t+1]=n[1]}this._delaunator=new t(e)}else delete this.collinear;const i=this.halfedges=this._delaunator.halfedges;const s=this.hull=this._delaunator.hull;const l=this.triangles=this._delaunator.triangles;const h=this.inedges.fill(-1);const o=this._hullIndex.fill(-1);for(let t=0,n=i.length;t<n;++t){const n=l[t%3===2?t-2:t+1];-1!==i[t]&&-1!==h[n]||(h[n]=t)}for(let t=0,n=s.length;t<n;++t)o[s[t]]=t;if(s.length<=2&&s.length>0){this.triangles=new Int32Array(3).fill(-1);this.halfedges=new Int32Array(3).fill(-1);this.triangles[0]=s[0];h[s[0]]=1;if(2===s.length){h[s[1]]=0;this.triangles[1]=s[1];this.triangles[2]=s[1]}}}voronoi(t){return new Voronoi(this,t)}*neighbors(t){const{inedges:n,hull:e,_hullIndex:i,halfedges:s,triangles:l,collinear:h}=this;if(h){const n=h.indexOf(t);n>0&&(yield h[n-1]);n<h.length-1&&(yield h[n+1]);return}const o=n[t];if(-1===o)return;let r=o,c=-1;do{yield c=l[r];r=r%3===2?r-2:r+1;if(l[r]!==t)return;r=s[r];if(-1===r){const n=e[(i[t]+1)%e.length];n!==c&&(yield n);return}}while(r!==o)}find(t,n,e=0){if((t=+t,t!==t)||(n=+n,n!==n))return-1;const i=e;let s;while((s=this._step(e,t,n))>=0&&s!==e&&s!==i)e=s;return s}_step(t,n,e){const{inedges:s,hull:l,_hullIndex:h,halfedges:o,triangles:r,points:c}=this;if(-1===s[t]||!c.length)return(t+1)%(c.length>>1);let a=t;let u=i(n-c[2*t],2)+i(e-c[2*t+1],2);const g=s[t];let f=g;do{let s=r[f];const g=i(n-c[2*s],2)+i(e-c[2*s+1],2);g<u&&(u=g,a=s);f=f%3===2?f-2:f+1;if(r[f]!==t)break;f=o[f];if(-1===f){f=l[(h[t]+1)%l.length];if(f!==s&&i(n-c[2*f],2)+i(e-c[2*f+1],2)<u)return f;break}}while(f!==g);return a}render(t){const n=null==t?t=new Path:void 0;const{points:e,halfedges:i,triangles:s}=this;for(let n=0,l=i.length;n<l;++n){const l=i[n];if(l<n)continue;const h=2*s[n];const o=2*s[l];t.moveTo(e[h],e[h+1]);t.lineTo(e[o],e[o+1])}this.renderHull(t);return n&&n.value()}renderPoints(t,n){void 0!==n||t&&\"function\"===typeof t.moveTo||(n=t,t=null);n=void 0==n?2:+n;const i=null==t?t=new Path:void 0;const{points:s}=this;for(let i=0,l=s.length;i<l;i+=2){const l=s[i],h=s[i+1];t.moveTo(l+n,h);t.arc(l,h,n,0,e)}return i&&i.value()}renderHull(t){const n=null==t?t=new Path:void 0;const{hull:e,points:i}=this;const s=2*e[0],l=e.length;t.moveTo(i[s],i[s+1]);for(let n=1;n<l;++n){const s=2*e[n];t.lineTo(i[s],i[s+1])}t.closePath();return n&&n.value()}hullPolygon(){const t=new Polygon;this.renderHull(t);return t.value()}renderTriangle(t,n){const e=null==n?n=new Path:void 0;const{points:i,triangles:s}=this;const l=2*s[t*=3];const h=2*s[t+1];const o=2*s[t+2];n.moveTo(i[l],i[l+1]);n.lineTo(i[h],i[h+1]);n.lineTo(i[o],i[o+1]);n.closePath();return e&&e.value()}*trianglePolygons(){const{triangles:t}=this;for(let n=0,e=t.length/3;n<e;++n)yield this.trianglePolygon(n)}trianglePolygon(t){const n=new Polygon;this.renderTriangle(t,n);return n.value()}}function flatArray(t,n,e,i){const s=t.length;const l=new Float64Array(2*s);for(let h=0;h<s;++h){const s=t[h];l[2*h]=n.call(i,s,h,t);l[2*h+1]=e.call(i,s,h,t)}return l}function*flatIterable(t,n,e,i){let s=0;for(const l of t){yield n.call(i,l,s,t);yield e.call(i,l,s,t);++s}}export{Delaunay,Voronoi};\n\n"
  },
  {
    "path": "vendor/javascript/d3-dispatch.js",
    "content": "// d3-dispatch@3.0.1 downloaded from https://ga.jspm.io/npm:d3-dispatch@3.0.1/src/index.js\n\nvar n={value:()=>{}};function dispatch(){for(var n,t=0,e=arguments.length,r={};t<e;++t){if(!(n=arguments[t]+\"\")||n in r||/[\\s.]/.test(n))throw new Error(\"illegal type: \"+n);r[n]=[]}return new Dispatch(r)}function Dispatch(n){this._=n}function parseTypenames(n,t){return n.trim().split(/^|\\s+/).map((function(n){var e=\"\",r=n.indexOf(\".\");r>=0&&(e=n.slice(r+1),n=n.slice(0,r));if(n&&!t.hasOwnProperty(n))throw new Error(\"unknown type: \"+n);return{type:n,name:e}}))}Dispatch.prototype=dispatch.prototype={constructor:Dispatch,on:function(n,t){var e,r=this._,i=parseTypenames(n+\"\",r),a=-1,o=i.length;if(!(arguments.length<2)){if(null!=t&&\"function\"!==typeof t)throw new Error(\"invalid callback: \"+t);while(++a<o)if(e=(n=i[a]).type)r[e]=set(r[e],n.name,t);else if(null==t)for(e in r)r[e]=set(r[e],n.name,null);return this}while(++a<o)if((e=(n=i[a]).type)&&(e=get(r[e],n.name)))return e},copy:function(){var n={},t=this._;for(var e in t)n[e]=t[e].slice();return new Dispatch(n)},call:function(n,t){if((e=arguments.length-2)>0)for(var e,r,i=new Array(e),a=0;a<e;++a)i[a]=arguments[a+2];if(!this._.hasOwnProperty(n))throw new Error(\"unknown type: \"+n);for(r=this._[n],a=0,e=r.length;a<e;++a)r[a].value.apply(t,i)},apply:function(n,t,e){if(!this._.hasOwnProperty(n))throw new Error(\"unknown type: \"+n);for(var r=this._[n],i=0,a=r.length;i<a;++i)r[i].value.apply(t,e)}};function get(n,t){for(var e,r=0,i=n.length;r<i;++r)if((e=n[r]).name===t)return e.value}function set(t,e,r){for(var i=0,a=t.length;i<a;++i)if(t[i].name===e){t[i]=n,t=t.slice(0,i).concat(t.slice(i+1));break}null!=r&&t.push({name:e,value:r});return t}export{dispatch};\n\n"
  },
  {
    "path": "vendor/javascript/d3-drag.js",
    "content": "// d3-drag@3.0.0 downloaded from https://ga.jspm.io/npm:d3-drag@3.0.0/src/index.js\n\nimport{dispatch as e}from\"d3-dispatch\";import{select as t,pointer as n}from\"d3-selection\";const r={passive:false};const a={capture:true,passive:false};function nopropagation(e){e.stopImmediatePropagation()}function noevent(e){e.preventDefault();e.stopImmediatePropagation()}function nodrag(e){var n=e.document.documentElement,r=t(e).on(\"dragstart.drag\",noevent,a);if(\"onselectstart\"in n)r.on(\"selectstart.drag\",noevent,a);else{n.__noselect=n.style.MozUserSelect;n.style.MozUserSelect=\"none\"}}function yesdrag(e,n){var r=e.document.documentElement,o=t(e).on(\"dragstart.drag\",null);if(n){o.on(\"click.drag\",noevent,a);setTimeout((function(){o.on(\"click.drag\",null)}),0)}if(\"onselectstart\"in r)o.on(\"selectstart.drag\",null);else{r.style.MozUserSelect=r.__noselect;delete r.__noselect}}var constant=e=>()=>e;function DragEvent(e,{sourceEvent:t,subject:n,target:r,identifier:a,active:o,x:u,y:i,dx:c,dy:l,dispatch:d}){Object.defineProperties(this,{type:{value:e,enumerable:true,configurable:true},sourceEvent:{value:t,enumerable:true,configurable:true},subject:{value:n,enumerable:true,configurable:true},target:{value:r,enumerable:true,configurable:true},identifier:{value:a,enumerable:true,configurable:true},active:{value:o,enumerable:true,configurable:true},x:{value:u,enumerable:true,configurable:true},y:{value:i,enumerable:true,configurable:true},dx:{value:c,enumerable:true,configurable:true},dy:{value:l,enumerable:true,configurable:true},_:{value:d}})}DragEvent.prototype.on=function(){var e=this._.on.apply(this._,arguments);return e===this._?this:e};function defaultFilter(e){return!e.ctrlKey&&!e.button}function defaultContainer(){return this.parentNode}function defaultSubject(e,t){return null==t?{x:e.x,y:e.y}:t}function defaultTouchable(){return navigator.maxTouchPoints||\"ontouchstart\"in this}function drag(){var o,u,i,c,l=defaultFilter,d=defaultContainer,s=defaultSubject,f=defaultTouchable,g={},v=e(\"start\",\"drag\",\"end\"),h=0,m=0;function drag(e){e.on(\"mousedown.drag\",mousedowned).filter(f).on(\"touchstart.drag\",touchstarted).on(\"touchmove.drag\",touchmoved,r).on(\"touchend.drag touchcancel.drag\",touchended).style(\"touch-action\",\"none\").style(\"-webkit-tap-highlight-color\",\"rgba(0,0,0,0)\")}function mousedowned(e,n){if(!c&&l.call(this,e,n)){var r=beforestart(this,d.call(this,e,n),e,n,\"mouse\");if(r){t(e.view).on(\"mousemove.drag\",mousemoved,a).on(\"mouseup.drag\",mouseupped,a);nodrag(e.view);nopropagation(e);i=false;o=e.clientX;u=e.clientY;r(\"start\",e)}}}function mousemoved(e){noevent(e);if(!i){var t=e.clientX-o,n=e.clientY-u;i=t*t+n*n>m}g.mouse(\"drag\",e)}function mouseupped(e){t(e.view).on(\"mousemove.drag mouseup.drag\",null);yesdrag(e.view,i);noevent(e);g.mouse(\"end\",e)}function touchstarted(e,t){if(l.call(this,e,t)){var n,r,a=e.changedTouches,o=d.call(this,e,t),u=a.length;for(n=0;n<u;++n)if(r=beforestart(this,o,e,t,a[n].identifier,a[n])){nopropagation(e);r(\"start\",e,a[n])}}}function touchmoved(e){var t,n,r=e.changedTouches,a=r.length;for(t=0;t<a;++t)if(n=g[r[t].identifier]){noevent(e);n(\"drag\",e,r[t])}}function touchended(e){var t,n,r=e.changedTouches,a=r.length;c&&clearTimeout(c);c=setTimeout((function(){c=null}),500);for(t=0;t<a;++t)if(n=g[r[t].identifier]){nopropagation(e);n(\"end\",e,r[t])}}function beforestart(e,t,r,a,o,u){var i,c,l,d=v.copy(),f=n(u||r,t);if(null!=(l=s.call(e,new DragEvent(\"beforestart\",{sourceEvent:r,target:drag,identifier:o,active:h,x:f[0],y:f[1],dx:0,dy:0,dispatch:d}),a))){i=l.x-f[0]||0;c=l.y-f[1]||0;return function gesture(r,u,s){var v,m=f;switch(r){case\"start\":g[o]=gesture,v=h++;break;case\"end\":delete g[o],--h;case\"drag\":f=n(s||u,t),v=h;break}d.call(r,e,new DragEvent(r,{sourceEvent:u,subject:l,target:drag,identifier:o,active:v,x:f[0]+i,y:f[1]+c,dx:f[0]-m[0],dy:f[1]-m[1],dispatch:d}),a)}}}drag.filter=function(e){return arguments.length?(l=\"function\"===typeof e?e:constant(!!e),drag):l};drag.container=function(e){return arguments.length?(d=\"function\"===typeof e?e:constant(e),drag):d};drag.subject=function(e){return arguments.length?(s=\"function\"===typeof e?e:constant(e),drag):s};drag.touchable=function(e){return arguments.length?(f=\"function\"===typeof e?e:constant(!!e),drag):f};drag.on=function(){var e=v.on.apply(v,arguments);return e===v?drag:e};drag.clickDistance=function(e){return arguments.length?(m=(e=+e)*e,drag):Math.sqrt(m)};return drag}export{drag,nodrag as dragDisable,yesdrag as dragEnable};\n\n"
  },
  {
    "path": "vendor/javascript/d3-dsv.js",
    "content": "// d3-dsv@3.0.1 downloaded from https://ga.jspm.io/npm:d3-dsv@3.0.1/src/index.js\n\nvar r={},e={},t=34,a=10,o=13;function objectConverter(r){return new Function(\"d\",\"return {\"+r.map((function(r,e){return JSON.stringify(r)+\": d[\"+e+'] || \"\"'})).join(\",\")+\"}\")}function customConverter(r,e){var t=objectConverter(r);return function(a,o){return e(t(a),o,r)}}function inferColumns(r){var e=Object.create(null),t=[];r.forEach((function(r){for(var a in r)a in e||t.push(e[a]=a)}));return t}function pad(r,e){var t=r+\"\",a=t.length;return a<e?new Array(e-a+1).join(0)+t:t}function formatYear(r){return r<0?\"-\"+pad(-r,6):r>9999?\"+\"+pad(r,6):pad(r,4)}function formatDate(r){var e=r.getUTCHours(),t=r.getUTCMinutes(),a=r.getUTCSeconds(),o=r.getUTCMilliseconds();return isNaN(r)?\"Invalid Date\":formatYear(r.getUTCFullYear(),4)+\"-\"+pad(r.getUTCMonth()+1,2)+\"-\"+pad(r.getUTCDate(),2)+(o?\"T\"+pad(e,2)+\":\"+pad(t,2)+\":\"+pad(a,2)+\".\"+pad(o,3)+\"Z\":a?\"T\"+pad(e,2)+\":\"+pad(t,2)+\":\"+pad(a,2)+\"Z\":t||e?\"T\"+pad(e,2)+\":\"+pad(t,2)+\"Z\":\"\")}function dsv(n){var u=new RegExp('[\"'+n+\"\\n\\r]\"),f=n.charCodeAt(0);function parse(r,e){var t,a,o=parseRows(r,(function(r,o){if(t)return t(r,o-1);a=r,t=e?customConverter(r,e):objectConverter(r)}));o.columns=a||[];return o}function parseRows(n,u){var i,s=[],c=n.length,l=0,d=0,m=c<=0,p=false;n.charCodeAt(c-1)===a&&--c;n.charCodeAt(c-1)===o&&--c;function token(){if(m)return e;if(p)return p=false,r;var u,i,s=l;if(n.charCodeAt(s)===t){while(l++<c&&n.charCodeAt(l)!==t||n.charCodeAt(++l)===t);if((u=l)>=c)m=true;else if((i=n.charCodeAt(l++))===a)p=true;else if(i===o){p=true;n.charCodeAt(l)===a&&++l}return n.slice(s+1,u-1).replace(/\"\"/g,'\"')}while(l<c){if((i=n.charCodeAt(u=l++))===a)p=true;else if(i===o){p=true;n.charCodeAt(l)===a&&++l}else if(i!==f)continue;return n.slice(s,u)}return m=true,n.slice(s,c)}while((i=token())!==e){var v=[];while(i!==r&&i!==e)v.push(i),i=token();u&&null==(v=u(v,d++))||s.push(v)}return s}function preformatBody(r,e){return r.map((function(r){return e.map((function(e){return formatValue(r[e])})).join(n)}))}function format(r,e){null==e&&(e=inferColumns(r));return[e.map(formatValue).join(n)].concat(preformatBody(r,e)).join(\"\\n\")}function formatBody(r,e){null==e&&(e=inferColumns(r));return preformatBody(r,e).join(\"\\n\")}function formatRows(r){return r.map(formatRow).join(\"\\n\")}function formatRow(r){return r.map(formatValue).join(n)}function formatValue(r){return null==r?\"\":r instanceof Date?formatDate(r):u.test(r+=\"\")?'\"'+r.replace(/\"/g,'\"\"')+'\"':r}return{parse:parse,parseRows:parseRows,format:format,formatBody:formatBody,formatRows:formatRows,formatRow:formatRow,formatValue:formatValue}}var n=dsv(\",\");var u=n.parse;var f=n.parseRows;var i=n.format;var s=n.formatBody;var c=n.formatRows;var l=n.formatRow;var d=n.formatValue;var m=dsv(\"\\t\");var p=m.parse;var v=m.parseRows;var w=m.format;var C=m.formatBody;var h=m.formatRows;var R=m.formatRow;var g=m.formatValue;function autoType(r){for(var e in r){var t,a,o=r[e].trim();if(o)if(\"true\"===o)o=true;else if(\"false\"===o)o=false;else if(\"NaN\"===o)o=NaN;else if(isNaN(t=+o)){if(!(a=o.match(/^([-+]\\d{2})?\\d{4}(-\\d{2}(-\\d{2})?)?(T\\d{2}:\\d{2}(:\\d{2}(\\.\\d{3})?)?(Z|[-+]\\d{2}:\\d{2})?)?$/)))continue;!T||!a[4]||a[7]||(o=o.replace(/-/g,\"/\").replace(/T/,\" \"));o=new Date(o)}else o=t;else o=null;r[e]=o}return r}const T=new Date(\"2019-01-01T00:00\").getHours()||new Date(\"2019-07-01T00:00\").getHours();export{autoType,i as csvFormat,s as csvFormatBody,l as csvFormatRow,c as csvFormatRows,d as csvFormatValue,u as csvParse,f as csvParseRows,dsv as dsvFormat,w as tsvFormat,C as tsvFormatBody,R as tsvFormatRow,h as tsvFormatRows,g as tsvFormatValue,p as tsvParse,v as tsvParseRows};\n\n"
  },
  {
    "path": "vendor/javascript/d3-ease.js",
    "content": "// d3-ease@3.0.1 downloaded from https://ga.jspm.io/npm:d3-ease@3.0.1/src/index.js\n\nconst linear=t=>+t;function quadIn(t){return t*t}function quadOut(t){return t*(2-t)}function quadInOut(t){return((t*=2)<=1?t*t:--t*(2-t)+1)/2}function cubicIn(t){return t*t*t}function cubicOut(t){return--t*t*t+1}function cubicInOut(t){return((t*=2)<=1?t*t*t:(t-=2)*t*t+2)/2}var t=3;var n=function custom(t){t=+t;function polyIn(n){return Math.pow(n,t)}polyIn.exponent=custom;return polyIn}(t);var u=function custom(t){t=+t;function polyOut(n){return 1-Math.pow(1-n,t)}polyOut.exponent=custom;return polyOut}(t);var e=function custom(t){t=+t;function polyInOut(n){return((n*=2)<=1?Math.pow(n,t):2-Math.pow(2-n,t))/2}polyInOut.exponent=custom;return polyInOut}(t);var a=Math.PI,c=a/2;function sinIn(t){return 1===+t?1:1-Math.cos(t*c)}function sinOut(t){return Math.sin(t*c)}function sinInOut(t){return(1-Math.cos(a*t))/2}function tpmt(t){return 1.0009775171065494*(Math.pow(2,-10*t)-.0009765625)}function expIn(t){return tpmt(1-+t)}function expOut(t){return 1-tpmt(t)}function expInOut(t){return((t*=2)<=1?tpmt(1-t):2-tpmt(t-1))/2}function circleIn(t){return 1-Math.sqrt(1-t*t)}function circleOut(t){return Math.sqrt(1- --t*t)}function circleInOut(t){return((t*=2)<=1?1-Math.sqrt(1-t*t):Math.sqrt(1-(t-=2)*t)+1)/2}var s=4/11,r=6/11,o=8/11,i=3/4,O=9/11,I=10/11,p=15/16,f=21/22,l=63/64,m=1/s/s;function bounceIn(t){return 1-bounceOut(1-t)}function bounceOut(t){return(t=+t)<s?m*t*t:t<o?m*(t-=r)*t+i:t<I?m*(t-=O)*t+p:m*(t-=f)*t+l}function bounceInOut(t){return((t*=2)<=1?1-bounceOut(1-t):bounceOut(t-1)+1)/2}var b=1.70158;var h=function custom(t){t=+t;function backIn(n){return(n=+n)*n*(t*(n-1)+n)}backIn.overshoot=custom;return backIn}(b);var M=function custom(t){t=+t;function backOut(n){return--n*n*((n+1)*t+n)+1}backOut.overshoot=custom;return backOut}(b);var v=function custom(t){t=+t;function backInOut(n){return((n*=2)<1?n*n*((t+1)*n-t):(n-=2)*n*((t+1)*n+t)+2)/2}backInOut.overshoot=custom;return backInOut}(b);var x=2*Math.PI,d=1,k=.3;var y=function custom(t,n){var u=Math.asin(1/(t=Math.max(1,t)))*(n/=x);function elasticIn(e){return t*tpmt(- --e)*Math.sin((u-e)/n)}elasticIn.amplitude=function(t){return custom(t,n*x)};elasticIn.period=function(n){return custom(t,n)};return elasticIn}(d,k);var q=function custom(t,n){var u=Math.asin(1/(t=Math.max(1,t)))*(n/=x);function elasticOut(e){return 1-t*tpmt(e=+e)*Math.sin((e+u)/n)}elasticOut.amplitude=function(t){return custom(t,n*x)};elasticOut.period=function(n){return custom(t,n)};return elasticOut}(d,k);var B=function custom(t,n){var u=Math.asin(1/(t=Math.max(1,t)))*(n/=x);function elasticInOut(e){return((e=2*e-1)<0?t*tpmt(-e)*Math.sin((u-e)/n):2-t*tpmt(e)*Math.sin((u+e)/n))/2}elasticInOut.amplitude=function(t){return custom(t,n*x)};elasticInOut.period=function(n){return custom(t,n)};return elasticInOut}(d,k);export{v as easeBack,h as easeBackIn,v as easeBackInOut,M as easeBackOut,bounceOut as easeBounce,bounceIn as easeBounceIn,bounceInOut as easeBounceInOut,bounceOut as easeBounceOut,circleInOut as easeCircle,circleIn as easeCircleIn,circleInOut as easeCircleInOut,circleOut as easeCircleOut,cubicInOut as easeCubic,cubicIn as easeCubicIn,cubicInOut as easeCubicInOut,cubicOut as easeCubicOut,q as easeElastic,y as easeElasticIn,B as easeElasticInOut,q as easeElasticOut,expInOut as easeExp,expIn as easeExpIn,expInOut as easeExpInOut,expOut as easeExpOut,linear as easeLinear,e as easePoly,n as easePolyIn,e as easePolyInOut,u as easePolyOut,quadInOut as easeQuad,quadIn as easeQuadIn,quadInOut as easeQuadInOut,quadOut as easeQuadOut,sinInOut as easeSin,sinIn as easeSinIn,sinInOut as easeSinInOut,sinOut as easeSinOut};\n\n"
  },
  {
    "path": "vendor/javascript/d3-fetch.js",
    "content": "// d3-fetch@3.0.1 downloaded from https://ga.jspm.io/npm:d3-fetch@3.0.1/src/index.js\n\nimport{dsvFormat as r,csvParse as t,tsvParse as e}from\"d3-dsv\";function responseBlob(r){if(!r.ok)throw new Error(r.status+\" \"+r.statusText);return r.blob()}function blob(r,t){return fetch(r,t).then(responseBlob)}function responseArrayBuffer(r){if(!r.ok)throw new Error(r.status+\" \"+r.statusText);return r.arrayBuffer()}function buffer(r,t){return fetch(r,t).then(responseArrayBuffer)}function responseText(r){if(!r.ok)throw new Error(r.status+\" \"+r.statusText);return r.text()}function text(r,t){return fetch(r,t).then(responseText)}function dsvParse(r){return function(t,e,n){2===arguments.length&&\"function\"===typeof e&&(n=e,e=void 0);return text(t,e).then((function(t){return r(t,n)}))}}function dsv(t,e,n,o){3===arguments.length&&\"function\"===typeof n&&(o=n,n=void 0);var s=r(t);return text(e,n).then((function(r){return s.parse(r,o)}))}var n=dsvParse(t);var o=dsvParse(e);function image(r,t){return new Promise((function(e,n){var o=new Image;for(var s in t)o[s]=t[s];o.onerror=n;o.onload=function(){e(o)};o.src=r}))}function responseJson(r){if(!r.ok)throw new Error(r.status+\" \"+r.statusText);if(204!==r.status&&205!==r.status)return r.json()}function json(r,t){return fetch(r,t).then(responseJson)}function parser(r){return(t,e)=>text(t,e).then((t=>(new DOMParser).parseFromString(t,r)))}var s=parser(\"application/xml\");var u=parser(\"text/html\");var f=parser(\"image/svg+xml\");export{blob,buffer,n as csv,dsv,u as html,image,json,f as svg,text,o as tsv,s as xml};\n\n"
  },
  {
    "path": "vendor/javascript/d3-force.js",
    "content": "// d3-force@3.0.0 downloaded from https://ga.jspm.io/npm:d3-force@3.0.0/src/index.js\n\nimport{quadtree as n}from\"d3-quadtree\";import{dispatch as t}from\"d3-dispatch\";import{timer as e}from\"d3-timer\";function center(n,t){var e,i=1;null==n&&(n=0);null==t&&(t=0);function force(){var r,o,f=e.length,c=0,a=0;for(r=0;r<f;++r)o=e[r],c+=o.x,a+=o.y;for(c=(c/f-n)*i,a=(a/f-t)*i,r=0;r<f;++r)o=e[r],o.x-=c,o.y-=a}force.initialize=function(n){e=n};force.x=function(t){return arguments.length?(n=+t,force):n};force.y=function(n){return arguments.length?(t=+n,force):t};force.strength=function(n){return arguments.length?(i=+n,force):i};return force}function constant(n){return function(){return n}}function jiggle(n){return 1e-6*(n()-.5)}function x$2(n){return n.x+n.vx}function y$2(n){return n.y+n.vy}function collide(t){var e,i,r,o=1,f=1;\"function\"!==typeof t&&(t=constant(null==t?1:+t));function force(){var t,c,a,u,l,s,g,h=e.length;for(var v=0;v<f;++v){c=n(e,x$2,y$2).visitAfter(prepare);for(t=0;t<h;++t){a=e[t];s=i[a.index],g=s*s;u=a.x+a.vx;l=a.y+a.vy;c.visit(apply)}}function apply(n,t,e,i,f){var c=n.data,h=n.r,v=s+h;if(!c)return t>u+v||i<u-v||e>l+v||f<l-v;if(c.index>a.index){var d=u-c.x-c.vx,p=l-c.y-c.vy,z=d*d+p*p;if(z<v*v){0===d&&(d=jiggle(r),z+=d*d);0===p&&(p=jiggle(r),z+=p*p);z=(v-(z=Math.sqrt(z)))/z*o;a.vx+=(d*=z)*(v=(h*=h)/(g+h));a.vy+=(p*=z)*v;c.vx-=d*(v=1-v);c.vy-=p*v}}}}function prepare(n){if(n.data)return n.r=i[n.data.index];for(var t=n.r=0;t<4;++t)n[t]&&n[t].r>n.r&&(n.r=n[t].r)}function initialize(){if(e){var n,r,o=e.length;i=new Array(o);for(n=0;n<o;++n)r=e[n],i[r.index]=+t(r,n,e)}}force.initialize=function(n,t){e=n;r=t;initialize()};force.iterations=function(n){return arguments.length?(f=+n,force):f};force.strength=function(n){return arguments.length?(o=+n,force):o};force.radius=function(n){return arguments.length?(t=\"function\"===typeof n?n:constant(+n),initialize(),force):t};return force}function index(n){return n.index}function find(n,t){var e=n.get(t);if(!e)throw new Error(\"node not found: \"+t);return e}function link(n){var t,e,i,r,o,f,c=index,a=defaultStrength,u=constant(30),l=1;null==n&&(n=[]);function defaultStrength(n){return 1/Math.min(r[n.source.index],r[n.target.index])}function force(i){for(var r=0,c=n.length;r<l;++r)for(var a,u,s,g,h,v,d,p=0;p<c;++p){a=n[p],u=a.source,s=a.target;g=s.x+s.vx-u.x-u.vx||jiggle(f);h=s.y+s.vy-u.y-u.vy||jiggle(f);v=Math.sqrt(g*g+h*h);v=(v-e[p])/v*i*t[p];g*=v,h*=v;s.vx-=g*(d=o[p]);s.vy-=h*d;u.vx+=g*(d=1-d);u.vy+=h*d}}function initialize(){if(i){var f,a,u=i.length,l=n.length,s=new Map(i.map(((n,t)=>[c(n,t,i),n])));for(f=0,r=new Array(u);f<l;++f){a=n[f],a.index=f;\"object\"!==typeof a.source&&(a.source=find(s,a.source));\"object\"!==typeof a.target&&(a.target=find(s,a.target));r[a.source.index]=(r[a.source.index]||0)+1;r[a.target.index]=(r[a.target.index]||0)+1}for(f=0,o=new Array(l);f<l;++f)a=n[f],o[f]=r[a.source.index]/(r[a.source.index]+r[a.target.index]);t=new Array(l),initializeStrength();e=new Array(l),initializeDistance()}}function initializeStrength(){if(i)for(var e=0,r=n.length;e<r;++e)t[e]=+a(n[e],e,n)}function initializeDistance(){if(i)for(var t=0,r=n.length;t<r;++t)e[t]=+u(n[t],t,n)}force.initialize=function(n,t){i=n;f=t;initialize()};force.links=function(t){return arguments.length?(n=t,initialize(),force):n};force.id=function(n){return arguments.length?(c=n,force):c};force.iterations=function(n){return arguments.length?(l=+n,force):l};force.strength=function(n){return arguments.length?(a=\"function\"===typeof n?n:constant(+n),initializeStrength(),force):a};force.distance=function(n){return arguments.length?(u=\"function\"===typeof n?n:constant(+n),initializeDistance(),force):u};return force}const i=1664525;const r=1013904223;const o=4294967296;function lcg(){let n=1;return()=>(n=(i*n+r)%o)/o}function x$1(n){return n.x}function y$1(n){return n.y}var f=10,c=Math.PI*(3-Math.sqrt(5));function simulation(n){var i,r=1,o=.001,a=1-Math.pow(o,1/300),u=0,l=.6,s=new Map,g=e(step),h=t(\"tick\",\"end\"),v=lcg();null==n&&(n=[]);function step(){tick();h.call(\"tick\",i);if(r<o){g.stop();h.call(\"end\",i)}}function tick(t){var e,o,f=n.length;void 0===t&&(t=1);for(var c=0;c<t;++c){r+=(u-r)*a;s.forEach((function(n){n(r)}));for(e=0;e<f;++e){o=n[e];null==o.fx?o.x+=o.vx*=l:(o.x=o.fx,o.vx=0);null==o.fy?o.y+=o.vy*=l:(o.y=o.fy,o.vy=0)}}return i}function initializeNodes(){for(var t,e=0,i=n.length;e<i;++e){t=n[e],t.index=e;null!=t.fx&&(t.x=t.fx);null!=t.fy&&(t.y=t.fy);if(isNaN(t.x)||isNaN(t.y)){var r=f*Math.sqrt(.5+e),o=e*c;t.x=r*Math.cos(o);t.y=r*Math.sin(o)}(isNaN(t.vx)||isNaN(t.vy))&&(t.vx=t.vy=0)}}function initializeForce(t){t.initialize&&t.initialize(n,v);return t}initializeNodes();return i={tick:tick,restart:function(){return g.restart(step),i},stop:function(){return g.stop(),i},nodes:function(t){return arguments.length?(n=t,initializeNodes(),s.forEach(initializeForce),i):n},alpha:function(n){return arguments.length?(r=+n,i):r},alphaMin:function(n){return arguments.length?(o=+n,i):o},alphaDecay:function(n){return arguments.length?(a=+n,i):+a},alphaTarget:function(n){return arguments.length?(u=+n,i):u},velocityDecay:function(n){return arguments.length?(l=1-n,i):1-l},randomSource:function(n){return arguments.length?(v=n,s.forEach(initializeForce),i):v},force:function(n,t){return arguments.length>1?(null==t?s.delete(n):s.set(n,initializeForce(t)),i):s.get(n)},find:function(t,e,i){var r,o,f,c,a,u=0,l=n.length;null==i?i=Infinity:i*=i;for(u=0;u<l;++u){c=n[u];r=t-c.x;o=e-c.y;f=r*r+o*o;f<i&&(a=c,i=f)}return a},on:function(n,t){return arguments.length>1?(h.on(n,t),i):h.on(n)}}}function manyBody(){var t,e,i,r,o,f=constant(-30),c=1,a=Infinity,u=.81;function force(i){var o,f=t.length,c=n(t,x$1,y$1).visitAfter(accumulate);for(r=i,o=0;o<f;++o)e=t[o],c.visit(apply)}function initialize(){if(t){var n,e,i=t.length;o=new Array(i);for(n=0;n<i;++n)e=t[n],o[e.index]=+f(e,n,t)}}function accumulate(n){var t,e,i,r,f,c=0,a=0;if(n.length){for(i=r=f=0;f<4;++f)(t=n[f])&&(e=Math.abs(t.value))&&(c+=t.value,a+=e,i+=e*t.x,r+=e*t.y);n.x=i/a;n.y=r/a}else{t=n;t.x=t.data.x;t.y=t.data.y;do{c+=o[t.data.index]}while(t=t.next)}n.value=c}function apply(n,t,f,l){if(!n.value)return true;var s=n.x-e.x,g=n.y-e.y,h=l-t,v=s*s+g*g;if(h*h/u<v){if(v<a){0===s&&(s=jiggle(i),v+=s*s);0===g&&(g=jiggle(i),v+=g*g);v<c&&(v=Math.sqrt(c*v));e.vx+=s*n.value*r/v;e.vy+=g*n.value*r/v}return true}if(!(n.length||v>=a)){if(n.data!==e||n.next){0===s&&(s=jiggle(i),v+=s*s);0===g&&(g=jiggle(i),v+=g*g);v<c&&(v=Math.sqrt(c*v))}do{if(n.data!==e){h=o[n.data.index]*r/v;e.vx+=s*h;e.vy+=g*h}}while(n=n.next)}}force.initialize=function(n,e){t=n;i=e;initialize()};force.strength=function(n){return arguments.length?(f=\"function\"===typeof n?n:constant(+n),initialize(),force):f};force.distanceMin=function(n){return arguments.length?(c=n*n,force):Math.sqrt(c)};force.distanceMax=function(n){return arguments.length?(a=n*n,force):Math.sqrt(a)};force.theta=function(n){return arguments.length?(u=n*n,force):Math.sqrt(u)};return force}function radial(n,t,e){var i,r,o,f=constant(.1);\"function\"!==typeof n&&(n=constant(+n));null==t&&(t=0);null==e&&(e=0);function force(n){for(var f=0,c=i.length;f<c;++f){var a=i[f],u=a.x-t||1e-6,l=a.y-e||1e-6,s=Math.sqrt(u*u+l*l),g=(o[f]-s)*r[f]*n/s;a.vx+=u*g;a.vy+=l*g}}function initialize(){if(i){var t,e=i.length;r=new Array(e);o=new Array(e);for(t=0;t<e;++t){o[t]=+n(i[t],t,i);r[t]=isNaN(o[t])?0:+f(i[t],t,i)}}}force.initialize=function(n){i=n,initialize()};force.strength=function(n){return arguments.length?(f=\"function\"===typeof n?n:constant(+n),initialize(),force):f};force.radius=function(t){return arguments.length?(n=\"function\"===typeof t?t:constant(+t),initialize(),force):n};force.x=function(n){return arguments.length?(t=+n,force):t};force.y=function(n){return arguments.length?(e=+n,force):e};return force}function x(n){var t,e,i,r=constant(.1);\"function\"!==typeof n&&(n=constant(null==n?0:+n));function force(n){for(var r,o=0,f=t.length;o<f;++o)r=t[o],r.vx+=(i[o]-r.x)*e[o]*n}function initialize(){if(t){var o,f=t.length;e=new Array(f);i=new Array(f);for(o=0;o<f;++o)e[o]=isNaN(i[o]=+n(t[o],o,t))?0:+r(t[o],o,t)}}force.initialize=function(n){t=n;initialize()};force.strength=function(n){return arguments.length?(r=\"function\"===typeof n?n:constant(+n),initialize(),force):r};force.x=function(t){return arguments.length?(n=\"function\"===typeof t?t:constant(+t),initialize(),force):n};return force}function y(n){var t,e,i,r=constant(.1);\"function\"!==typeof n&&(n=constant(null==n?0:+n));function force(n){for(var r,o=0,f=t.length;o<f;++o)r=t[o],r.vy+=(i[o]-r.y)*e[o]*n}function initialize(){if(t){var o,f=t.length;e=new Array(f);i=new Array(f);for(o=0;o<f;++o)e[o]=isNaN(i[o]=+n(t[o],o,t))?0:+r(t[o],o,t)}}force.initialize=function(n){t=n;initialize()};force.strength=function(n){return arguments.length?(r=\"function\"===typeof n?n:constant(+n),initialize(),force):r};force.y=function(t){return arguments.length?(n=\"function\"===typeof t?t:constant(+t),initialize(),force):n};return force}export{center as forceCenter,collide as forceCollide,link as forceLink,manyBody as forceManyBody,radial as forceRadial,simulation as forceSimulation,x as forceX,y as forceY};\n\n"
  },
  {
    "path": "vendor/javascript/d3-format.js",
    "content": "// d3-format@3.1.0 downloaded from https://ga.jspm.io/npm:d3-format@3.1.0/src/index.js\n\nfunction formatDecimal(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString(\"en\").replace(/,/g,\"\"):t.toString(10)}function formatDecimalParts(t,r){if((i=(t=r?t.toExponential(r-1):t.toExponential()).indexOf(\"e\"))<0)return null;var i,e=t.slice(0,i);return[e.length>1?e[0]+e.slice(2):e,+t.slice(i+1)]}function exponent(t){return t=formatDecimalParts(Math.abs(t)),t?t[1]:NaN}function formatGroup(t,r){return function(i,e){var n=i.length,a=[],o=0,c=t[0],f=0;while(n>0&&c>0){f+c+1>e&&(c=Math.max(1,e-f));a.push(i.substring(n-=c,n+c));if((f+=c+1)>e)break;c=t[o=(o+1)%t.length]}return a.reverse().join(r)}}function formatNumerals(t){return function(r){return r.replace(/[0-9]/g,(function(r){return t[+r]}))}}var t=/^(?:(.)?([<>=^]))?([+\\-( ])?([$#])?(0)?(\\d+)?(,)?(\\.\\d+)?(~)?([a-z%])?$/i;function formatSpecifier(r){if(!(i=t.exec(r)))throw new Error(\"invalid format: \"+r);var i;return new FormatSpecifier({fill:i[1],align:i[2],sign:i[3],symbol:i[4],zero:i[5],width:i[6],comma:i[7],precision:i[8]&&i[8].slice(1),trim:i[9],type:i[10]})}formatSpecifier.prototype=FormatSpecifier.prototype;function FormatSpecifier(t){this.fill=void 0===t.fill?\" \":t.fill+\"\";this.align=void 0===t.align?\">\":t.align+\"\";this.sign=void 0===t.sign?\"-\":t.sign+\"\";this.symbol=void 0===t.symbol?\"\":t.symbol+\"\";this.zero=!!t.zero;this.width=void 0===t.width?void 0:+t.width;this.comma=!!t.comma;this.precision=void 0===t.precision?void 0:+t.precision;this.trim=!!t.trim;this.type=void 0===t.type?\"\":t.type+\"\"}FormatSpecifier.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?\"0\":\"\")+(void 0===this.width?\"\":Math.max(1,0|this.width))+(this.comma?\",\":\"\")+(void 0===this.precision?\"\":\".\"+Math.max(0,0|this.precision))+(this.trim?\"~\":\"\")+this.type};function formatTrim(t){t:for(var r,i=t.length,e=1,n=-1;e<i;++e)switch(t[e]){case\".\":n=r=e;break;case\"0\":0===n&&(n=e);r=e;break;default:if(!+t[e])break t;n>0&&(n=0);break}return n>0?t.slice(0,n)+t.slice(r+1):t}var r;function formatPrefixAuto(t,i){var e=formatDecimalParts(t,i);if(!e)return t+\"\";var n=e[0],a=e[1],o=a-(r=3*Math.max(-8,Math.min(8,Math.floor(a/3))))+1,c=n.length;return o===c?n:o>c?n+new Array(o-c+1).join(\"0\"):o>0?n.slice(0,o)+\".\"+n.slice(o):\"0.\"+new Array(1-o).join(\"0\")+formatDecimalParts(t,Math.max(0,i+o-1))[0]}function formatRounded(t,r){var i=formatDecimalParts(t,r);if(!i)return t+\"\";var e=i[0],n=i[1];return n<0?\"0.\"+new Array(-n).join(\"0\")+e:e.length>n+1?e.slice(0,n+1)+\".\"+e.slice(n+1):e+new Array(n-e.length+2).join(\"0\")}var i={\"%\":(t,r)=>(100*t).toFixed(r),b:t=>Math.round(t).toString(2),c:t=>t+\"\",d:formatDecimal,e:(t,r)=>t.toExponential(r),f:(t,r)=>t.toFixed(r),g:(t,r)=>t.toPrecision(r),o:t=>Math.round(t).toString(8),p:(t,r)=>formatRounded(100*t,r),r:formatRounded,s:formatPrefixAuto,X:t=>Math.round(t).toString(16).toUpperCase(),x:t=>Math.round(t).toString(16)};function identity(t){return t}var e=Array.prototype.map,n=[\"y\",\"z\",\"a\",\"f\",\"p\",\"n\",\"µ\",\"m\",\"\",\"k\",\"M\",\"G\",\"T\",\"P\",\"E\",\"Z\",\"Y\"];function formatLocale(t){var a=void 0===t.grouping||void 0===t.thousands?identity:formatGroup(e.call(t.grouping,Number),t.thousands+\"\"),o=void 0===t.currency?\"\":t.currency[0]+\"\",c=void 0===t.currency?\"\":t.currency[1]+\"\",f=void 0===t.decimal?\".\":t.decimal+\"\",s=void 0===t.numerals?identity:formatNumerals(e.call(t.numerals,String)),m=void 0===t.percent?\"%\":t.percent+\"\",l=void 0===t.minus?\"−\":t.minus+\"\",u=void 0===t.nan?\"NaN\":t.nan+\"\";function newFormat(t){t=formatSpecifier(t);var e=t.fill,h=t.align,p=t.sign,d=t.symbol,g=t.zero,v=t.width,x=t.comma,y=t.precision,M=t.trim,b=t.type;\"n\"===b?(x=true,b=\"g\"):i[b]||(void 0===y&&(y=12),M=true,b=\"g\");(g||\"0\"===e&&\"=\"===h)&&(g=true,e=\"0\",h=\"=\");var w=\"$\"===d?o:\"#\"===d&&/[boxX]/.test(b)?\"0\"+b.toLowerCase():\"\",S=\"$\"===d?c:/[%p]/.test(b)?m:\"\";var P=i[b],F=/[defgprs%]/.test(b);y=void 0===y?6:/[gprs]/.test(b)?Math.max(1,Math.min(21,y)):Math.max(0,Math.min(20,y));function format(t){var i,o,c,m=w,d=S;if(\"c\"===b){d=P(t)+d;t=\"\"}else{t=+t;var k=t<0||1/t<0;t=isNaN(t)?u:P(Math.abs(t),y);M&&(t=formatTrim(t));k&&0===+t&&\"+\"!==p&&(k=false);m=(k?\"(\"===p?p:l:\"-\"===p||\"(\"===p?\"\":p)+m;d=(\"s\"===b?n[8+r/3]:\"\")+d+(k&&\"(\"===p?\")\":\"\");if(F){i=-1,o=t.length;while(++i<o)if(c=t.charCodeAt(i),48>c||c>57){d=(46===c?f+t.slice(i+1):t.slice(i))+d;t=t.slice(0,i);break}}}x&&!g&&(t=a(t,Infinity));var A=m.length+t.length+d.length,L=A<v?new Array(v-A+1).join(e):\"\";x&&g&&(t=a(L+t,L.length?v-d.length:Infinity),L=\"\");switch(h){case\"<\":t=m+t+d+L;break;case\"=\":t=m+L+t+d;break;case\"^\":t=L.slice(0,A=L.length>>1)+m+t+d+L.slice(A);break;default:t=L+m+t+d;break}return s(t)}format.toString=function(){return t+\"\"};return format}function formatPrefix(t,r){var i=newFormat((t=formatSpecifier(t),t.type=\"f\",t)),e=3*Math.max(-8,Math.min(8,Math.floor(exponent(r)/3))),a=Math.pow(10,-e),o=n[8+e/3];return function(t){return i(a*t)+o}}return{format:newFormat,formatPrefix:formatPrefix}}var a;var o;var c;defaultLocale({thousands:\",\",grouping:[3],currency:[\"$\",\"\"]});function defaultLocale(t){a=formatLocale(t);o=a.format;c=a.formatPrefix;return a}function precisionFixed(t){return Math.max(0,-exponent(Math.abs(t)))}function precisionPrefix(t,r){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor(exponent(r)/3)))-exponent(Math.abs(t)))}function precisionRound(t,r){t=Math.abs(t),r=Math.abs(r)-t;return Math.max(0,exponent(r)-exponent(t))+1}export{FormatSpecifier,o as format,defaultLocale as formatDefaultLocale,formatLocale,c as formatPrefix,formatSpecifier,precisionFixed,precisionPrefix,precisionRound};\n\n"
  },
  {
    "path": "vendor/javascript/d3-geo.js",
    "content": "// d3-geo@3.1.1 downloaded from https://ga.jspm.io/npm:d3-geo@3.1.1/src/index.js\n\nimport{Adder as n,merge as t,range as r}from\"d3-array\";var e=1e-6;var i=1e-12;var o=Math.PI;var a=o/2;var c=o/4;var u=o*2;var l=180/o;var s=o/180;var f=Math.abs;var p=Math.atan;var g=Math.atan2;var h=Math.cos;var d=Math.ceil;var v=Math.exp;Math.floor;var m=Math.hypot;var E=Math.log;var S=Math.pow;var y=Math.sin;var R=Math.sign||function(n){return n>0?1:n<0?-1:0};var w=Math.sqrt;var P=Math.tan;function acos(n){return n>1?0:n<-1?o:Math.acos(n)}function asin(n){return n>1?a:n<-1?-a:Math.asin(n)}function haversin(n){return(n=y(n/2))*n}function noop(){}function streamGeometry(n,t){n&&M.hasOwnProperty(n.type)&&M[n.type](n,t)}var j={Feature:function(n,t){streamGeometry(n.geometry,t)},FeatureCollection:function(n,t){var r=n.features,e=-1,i=r.length;while(++e<i)streamGeometry(r[e].geometry,t)}};var M={Sphere:function(n,t){t.sphere()},Point:function(n,t){n=n.coordinates;t.point(n[0],n[1],n[2])},MultiPoint:function(n,t){var r=n.coordinates,e=-1,i=r.length;while(++e<i)n=r[e],t.point(n[0],n[1],n[2])},LineString:function(n,t){streamLine(n.coordinates,t,0)},MultiLineString:function(n,t){var r=n.coordinates,e=-1,i=r.length;while(++e<i)streamLine(r[e],t,0)},Polygon:function(n,t){streamPolygon(n.coordinates,t)},MultiPolygon:function(n,t){var r=n.coordinates,e=-1,i=r.length;while(++e<i)streamPolygon(r[e],t)},GeometryCollection:function(n,t){var r=n.geometries,e=-1,i=r.length;while(++e<i)streamGeometry(r[e],t)}};function streamLine(n,t,r){var e,i=-1,o=n.length-r;t.lineStart();while(++i<o)e=n[i],t.point(e[0],e[1],e[2]);t.lineEnd()}function streamPolygon(n,t){var r=-1,e=n.length;t.polygonStart();while(++r<e)streamLine(n[r],t,1);t.polygonEnd()}function geoStream(n,t){n&&j.hasOwnProperty(n.type)?j[n.type](n,t):streamGeometry(n,t)}var b=new n;var L,x,C,q,$,_=new n;var N={point:noop,lineStart:noop,lineEnd:noop,polygonStart:function(){b=new n;N.lineStart=areaRingStart$1;N.lineEnd=areaRingEnd$1},polygonEnd:function(){var n=+b;_.add(n<0?u+n:n);this.lineStart=this.lineEnd=this.point=noop},sphere:function(){_.add(u)}};function areaRingStart$1(){N.point=areaPointFirst$1}function areaRingEnd$1(){areaPoint$1(L,x)}function areaPointFirst$1(n,t){N.point=areaPoint$1;L=n,x=t;n*=s,t*=s;C=n,q=h(t=t/2+c),$=y(t)}function areaPoint$1(n,t){n*=s,t*=s;t=t/2+c;var r=n-C,e=r>=0?1:-1,i=e*r,o=h(t),a=y(t),u=$*a,l=q*o+u*h(i),f=u*e*y(i);b.add(g(f,l));C=n,q=o,$=a}function area(t){_=new n;geoStream(t,N);return _*2}function spherical(n){return[g(n[1],n[0]),asin(n[2])]}function cartesian(n){var t=n[0],r=n[1],e=h(r);return[e*h(t),e*y(t),y(r)]}function cartesianDot(n,t){return n[0]*t[0]+n[1]*t[1]+n[2]*t[2]}function cartesianCross(n,t){return[n[1]*t[2]-n[2]*t[1],n[2]*t[0]-n[0]*t[2],n[0]*t[1]-n[1]*t[0]]}function cartesianAddInPlace(n,t){n[0]+=t[0],n[1]+=t[1],n[2]+=t[2]}function cartesianScale(n,t){return[n[0]*t,n[1]*t,n[2]*t]}function cartesianNormalizeInPlace(n){var t=w(n[0]*n[0]+n[1]*n[1]+n[2]*n[2]);n[0]/=t,n[1]/=t,n[2]/=t}var I,A,z,F,T,U,G,k,H,W,D;var O={point:boundsPoint$1,lineStart:boundsLineStart,lineEnd:boundsLineEnd,polygonStart:function(){O.point=boundsRingPoint;O.lineStart=boundsRingStart;O.lineEnd=boundsRingEnd;H=new n;N.polygonStart()},polygonEnd:function(){N.polygonEnd();O.point=boundsPoint$1;O.lineStart=boundsLineStart;O.lineEnd=boundsLineEnd;b<0?(I=-(z=180),A=-(F=90)):H>e?F=90:H<-e&&(A=-90);D[0]=I,D[1]=z},sphere:function(){I=-(z=180),A=-(F=90)}};function boundsPoint$1(n,t){W.push(D=[I=n,z=n]);t<A&&(A=t);t>F&&(F=t)}function linePoint(n,t){var r=cartesian([n*s,t*s]);if(k){var e=cartesianCross(k,r),i=[e[1],-e[0],0],o=cartesianCross(i,e);cartesianNormalizeInPlace(o);o=spherical(o);var a,c=n-T,u=c>0?1:-1,p=o[0]*l*u,g=f(c)>180;if(g^(u*T<p&&p<u*n)){a=o[1]*l;a>F&&(F=a)}else if(p=(p+360)%360-180,g^(u*T<p&&p<u*n)){a=-o[1]*l;a<A&&(A=a)}else{t<A&&(A=t);t>F&&(F=t)}if(g)n<T?angle(I,n)>angle(I,z)&&(z=n):angle(n,z)>angle(I,z)&&(I=n);else if(z>=I){n<I&&(I=n);n>z&&(z=n)}else n>T?angle(I,n)>angle(I,z)&&(z=n):angle(n,z)>angle(I,z)&&(I=n)}else W.push(D=[I=n,z=n]);t<A&&(A=t);t>F&&(F=t);k=r,T=n}function boundsLineStart(){O.point=linePoint}function boundsLineEnd(){D[0]=I,D[1]=z;O.point=boundsPoint$1;k=null}function boundsRingPoint(n,t){if(k){var r=n-T;H.add(f(r)>180?r+(r>0?360:-360):r)}else U=n,G=t;N.point(n,t);linePoint(n,t)}function boundsRingStart(){N.lineStart()}function boundsRingEnd(){boundsRingPoint(U,G);N.lineEnd();f(H)>e&&(I=-(z=180));D[0]=I,D[1]=z;k=null}function angle(n,t){return(t-=n)<0?t+360:t}function rangeCompare(n,t){return n[0]-t[0]}function rangeContains(n,t){return n[0]<=n[1]?n[0]<=t&&t<=n[1]:t<n[0]||n[1]<t}function bounds(n){var t,r,e,i,o,a,c;F=z=-(I=A=Infinity);W=[];geoStream(n,O);if(r=W.length){W.sort(rangeCompare);for(t=1,e=W[0],o=[e];t<r;++t){i=W[t];if(rangeContains(e,i[0])||rangeContains(e,i[1])){angle(e[0],i[1])>angle(e[0],e[1])&&(e[1]=i[1]);angle(i[0],e[1])>angle(e[0],e[1])&&(e[0]=i[0])}else o.push(e=i)}for(a=-Infinity,r=o.length-1,t=0,e=o[r];t<=r;e=i,++t){i=o[t];(c=angle(e[1],i[0]))>a&&(a=c,I=i[0],z=e[1])}}W=D=null;return I===Infinity||A===Infinity?[[NaN,NaN],[NaN,NaN]]:[[I,A],[z,F]]}var X,Y,B,Z,J,K,Q,V,nn,tn,rn,en,on,an,cn,un;var ln={sphere:noop,point:centroidPoint$1,lineStart:centroidLineStart$1,lineEnd:centroidLineEnd$1,polygonStart:function(){ln.lineStart=centroidRingStart$1;ln.lineEnd=centroidRingEnd$1},polygonEnd:function(){ln.lineStart=centroidLineStart$1;ln.lineEnd=centroidLineEnd$1}};function centroidPoint$1(n,t){n*=s,t*=s;var r=h(t);centroidPointCartesian(r*h(n),r*y(n),y(t))}function centroidPointCartesian(n,t,r){++X;B+=(n-B)/X;Z+=(t-Z)/X;J+=(r-J)/X}function centroidLineStart$1(){ln.point=centroidLinePointFirst}function centroidLinePointFirst(n,t){n*=s,t*=s;var r=h(t);an=r*h(n);cn=r*y(n);un=y(t);ln.point=centroidLinePoint;centroidPointCartesian(an,cn,un)}function centroidLinePoint(n,t){n*=s,t*=s;var r=h(t),e=r*h(n),i=r*y(n),o=y(t),a=g(w((a=cn*o-un*i)*a+(a=un*e-an*o)*a+(a=an*i-cn*e)*a),an*e+cn*i+un*o);Y+=a;K+=a*(an+(an=e));Q+=a*(cn+(cn=i));V+=a*(un+(un=o));centroidPointCartesian(an,cn,un)}function centroidLineEnd$1(){ln.point=centroidPoint$1}function centroidRingStart$1(){ln.point=centroidRingPointFirst}function centroidRingEnd$1(){centroidRingPoint(en,on);ln.point=centroidPoint$1}function centroidRingPointFirst(n,t){en=n,on=t;n*=s,t*=s;ln.point=centroidRingPoint;var r=h(t);an=r*h(n);cn=r*y(n);un=y(t);centroidPointCartesian(an,cn,un)}function centroidRingPoint(n,t){n*=s,t*=s;var r=h(t),e=r*h(n),i=r*y(n),o=y(t),a=cn*o-un*i,c=un*e-an*o,u=an*i-cn*e,l=m(a,c,u),f=asin(l),p=l&&-f/l;nn.add(p*a);tn.add(p*c);rn.add(p*u);Y+=f;K+=f*(an+(an=e));Q+=f*(cn+(cn=i));V+=f*(un+(un=o));centroidPointCartesian(an,cn,un)}function centroid(t){X=Y=B=Z=J=K=Q=V=0;nn=new n;tn=new n;rn=new n;geoStream(t,ln);var r=+nn,o=+tn,a=+rn,c=m(r,o,a);if(c<i){r=K,o=Q,a=V;Y<e&&(r=B,o=Z,a=J);c=m(r,o,a);if(c<i)return[NaN,NaN]}return[g(o,r)*l,asin(a/c)*l]}function constant(n){return function(){return n}}function compose(n,t){function compose(r,e){return r=n(r,e),t(r[0],r[1])}n.invert&&t.invert&&(compose.invert=function(r,e){return r=t.invert(r,e),r&&n.invert(r[0],r[1])});return compose}function rotationIdentity(n,t){f(n)>o&&(n-=Math.round(n/u)*u);return[n,t]}rotationIdentity.invert=rotationIdentity;function rotateRadians(n,t,r){return(n%=u)?t||r?compose(rotationLambda(n),rotationPhiGamma(t,r)):rotationLambda(n):t||r?rotationPhiGamma(t,r):rotationIdentity}function forwardRotationLambda(n){return function(t,r){t+=n;f(t)>o&&(t-=Math.round(t/u)*u);return[t,r]}}function rotationLambda(n){var t=forwardRotationLambda(n);t.invert=forwardRotationLambda(-n);return t}function rotationPhiGamma(n,t){var r=h(n),e=y(n),i=h(t),o=y(t);function rotation(n,t){var a=h(t),c=h(n)*a,u=y(n)*a,l=y(t),s=l*r+c*e;return[g(u*i-s*o,c*r-l*e),asin(s*i+u*o)]}rotation.invert=function(n,t){var a=h(t),c=h(n)*a,u=y(n)*a,l=y(t),s=l*i-u*o;return[g(u*i+l*o,c*r+s*e),asin(s*r-c*e)]};return rotation}function rotation(n){n=rotateRadians(n[0]*s,n[1]*s,n.length>2?n[2]*s:0);function forward(t){t=n(t[0]*s,t[1]*s);return t[0]*=l,t[1]*=l,t}forward.invert=function(t){t=n.invert(t[0]*s,t[1]*s);return t[0]*=l,t[1]*=l,t};return forward}function circleStream(n,t,r,e,i,o){if(r){var a=h(t),c=y(t),l=e*r;if(i==null){i=t+e*u;o=t-l/2}else{i=circleRadius(a,i);o=circleRadius(a,o);(e>0?i<o:i>o)&&(i+=e*u)}for(var s,f=i;e>0?f>o:f<o;f-=l){s=spherical([a,-c*h(f),-c*y(f)]);n.point(s[0],s[1])}}}function circleRadius(n,t){t=cartesian(t),t[0]-=n;cartesianNormalizeInPlace(t);var r=acos(-t[1]);return((-t[2]<0?-r:r)+u-e)%u}function circle(){var n,t,r=constant([0,0]),e=constant(90),i=constant(2),o={point:point};function point(r,e){n.push(r=t(r,e));r[0]*=l,r[1]*=l}function circle(){var a=r.apply(this,arguments),c=e.apply(this,arguments)*s,u=i.apply(this,arguments)*s;n=[];t=rotateRadians(-a[0]*s,-a[1]*s,0).invert;circleStream(o,c,u,1);a={type:\"Polygon\",coordinates:[n]};n=t=null;return a}circle.center=function(n){return arguments.length?(r=typeof n===\"function\"?n:constant([+n[0],+n[1]]),circle):r};circle.radius=function(n){return arguments.length?(e=typeof n===\"function\"?n:constant(+n),circle):e};circle.precision=function(n){return arguments.length?(i=typeof n===\"function\"?n:constant(+n),circle):i};return circle}function clipBuffer(){var n,t=[];return{point:function(t,r,e){n.push([t,r,e])},lineStart:function(){t.push(n=[])},lineEnd:noop,rejoin:function(){t.length>1&&t.push(t.pop().concat(t.shift()))},result:function(){var r=t;t=[];n=null;return r}}}function pointEqual(n,t){return f(n[0]-t[0])<e&&f(n[1]-t[1])<e}function Intersection(n,t,r,e){this.x=n;this.z=t;this.o=r;this.e=e;this.v=false;this.n=this.p=null}function clipRejoin(n,t,r,i,o){var a,c,u=[],l=[];n.forEach((function(n){if(!((t=n.length-1)<=0)){var t,r,i=n[0],c=n[t];if(pointEqual(i,c)){if(!i[2]&&!c[2]){o.lineStart();for(a=0;a<t;++a)o.point((i=n[a])[0],i[1]);o.lineEnd();return}c[0]+=2*e}u.push(r=new Intersection(i,n,null,true));l.push(r.o=new Intersection(i,null,r,false));u.push(r=new Intersection(c,n,null,false));l.push(r.o=new Intersection(c,null,r,true))}}));if(u.length){l.sort(t);link(u);link(l);for(a=0,c=l.length;a<c;++a)l[a].e=r=!r;var s,f,p=u[0];while(1){var g=p,h=true;while(g.v)if((g=g.n)===p)return;s=g.z;o.lineStart();do{g.v=g.o.v=true;if(g.e){if(h)for(a=0,c=s.length;a<c;++a)o.point((f=s[a])[0],f[1]);else i(g.x,g.n.x,1,o);g=g.n}else{if(h){s=g.p.z;for(a=s.length-1;a>=0;--a)o.point((f=s[a])[0],f[1])}else i(g.x,g.p.x,-1,o);g=g.p}g=g.o;s=g.z;h=!h}while(!g.v);o.lineEnd()}}}function link(n){if(t=n.length){var t,r,e=0,i=n[0];while(++e<t){i.n=r=n[e];r.p=i;i=r}i.n=r=n[0];r.p=i}}function longitude(n){return f(n[0])<=o?n[0]:R(n[0])*((f(n[0])+o)%u-o)}function polygonContains(t,r){var l=longitude(r),s=r[1],f=y(s),p=[y(l),-h(l),0],d=0,v=0;var m=new n;f===1?s=a+e:f===-1&&(s=-a-e);for(var E=0,S=t.length;E<S;++E)if(w=(R=t[E]).length){var R,w,P=R[w-1],j=longitude(P),M=P[1]/2+c,b=y(M),L=h(M);for(var x=0;x<w;++x,j=q,b=_,L=N,P=C){var C=R[x],q=longitude(C),$=C[1]/2+c,_=y($),N=h($),I=q-j,A=I>=0?1:-1,z=A*I,F=z>o,T=b*_;m.add(g(T*A*y(z),L*N+T*h(z)));d+=F?I+A*u:I;if(F^j>=l^q>=l){var U=cartesianCross(cartesian(P),cartesian(C));cartesianNormalizeInPlace(U);var G=cartesianCross(p,U);cartesianNormalizeInPlace(G);var k=(F^I>=0?-1:1)*asin(G[2]);(s>k||s===k&&(U[0]||U[1]))&&(v+=F^I>=0?1:-1)}}}return(d<-e||d<e&&m<-i)^v&1}function clip(n,r,e,i){return function(o){var a,c,u,l=r(o),s=clipBuffer(),f=r(s),p=false;var g={point:point,lineStart:lineStart,lineEnd:lineEnd,polygonStart:function(){g.point=pointRing;g.lineStart=ringStart;g.lineEnd=ringEnd;c=[];a=[]},polygonEnd:function(){g.point=point;g.lineStart=lineStart;g.lineEnd=lineEnd;c=t(c);var n=polygonContains(a,i);if(c.length){p||(o.polygonStart(),p=true);clipRejoin(c,compareIntersection,n,e,o)}else if(n){p||(o.polygonStart(),p=true);o.lineStart();e(null,null,1,o);o.lineEnd()}p&&(o.polygonEnd(),p=false);c=a=null},sphere:function(){o.polygonStart();o.lineStart();e(null,null,1,o);o.lineEnd();o.polygonEnd()}};function point(t,r){n(t,r)&&o.point(t,r)}function pointLine(n,t){l.point(n,t)}function lineStart(){g.point=pointLine;l.lineStart()}function lineEnd(){g.point=point;l.lineEnd()}function pointRing(n,t){u.push([n,t]);f.point(n,t)}function ringStart(){f.lineStart();u=[]}function ringEnd(){pointRing(u[0][0],u[0][1]);f.lineEnd();var n,t,r,e,i=f.clean(),l=s.result(),g=l.length;u.pop();a.push(u);u=null;if(g)if(i&1){r=l[0];if((t=r.length-1)>0){p||(o.polygonStart(),p=true);o.lineStart();for(n=0;n<t;++n)o.point((e=r[n])[0],e[1]);o.lineEnd()}}else{g>1&&i&2&&l.push(l.pop().concat(l.shift()));c.push(l.filter(validSegment))}}return g}}function validSegment(n){return n.length>1}function compareIntersection(n,t){return((n=n.x)[0]<0?n[1]-a-e:a-n[1])-((t=t.x)[0]<0?t[1]-a-e:a-t[1])}var sn=clip((function(){return true}),clipAntimeridianLine,clipAntimeridianInterpolate,[-o,-a]);function clipAntimeridianLine(n){var t,r=NaN,i=NaN,c=NaN;return{lineStart:function(){n.lineStart();t=1},point:function(u,l){var s=u>0?o:-o,p=f(u-r);if(f(p-o)<e){n.point(r,i=(i+l)/2>0?a:-a);n.point(c,i);n.lineEnd();n.lineStart();n.point(s,i);n.point(u,i);t=0}else if(c!==s&&p>=o){f(r-c)<e&&(r-=c*e);f(u-s)<e&&(u-=s*e);i=clipAntimeridianIntersect(r,i,u,l);n.point(c,i);n.lineEnd();n.lineStart();n.point(s,i);t=0}n.point(r=u,i=l);c=s},lineEnd:function(){n.lineEnd();r=i=NaN},clean:function(){return 2-t}}}function clipAntimeridianIntersect(n,t,r,i){var o,a,c=y(n-r);return f(c)>e?p((y(t)*(a=h(i))*y(r)-y(i)*(o=h(t))*y(n))/(o*a*c)):(t+i)/2}function clipAntimeridianInterpolate(n,t,r,i){var c;if(n==null){c=r*a;i.point(-o,c);i.point(0,c);i.point(o,c);i.point(o,0);i.point(o,-c);i.point(0,-c);i.point(-o,-c);i.point(-o,0);i.point(-o,c)}else if(f(n[0]-t[0])>e){var u=n[0]<t[0]?o:-o;c=r*u/2;i.point(-u,c);i.point(0,c);i.point(u,c)}else i.point(t[0],t[1])}function clipCircle(n){var t=h(n),r=2*s,i=t>0,a=f(t)>e;function interpolate(t,e,i,o){circleStream(o,n,r,i,t,e)}function visible(n,r){return h(n)*h(r)>t}function clipLine(n){var t,r,e,c,u;return{lineStart:function(){c=e=false;u=1},point:function(l,s){var f,p=[l,s],g=visible(l,s),h=i?g?0:code(l,s):g?code(l+(l<0?o:-o),s):0;!t&&(c=e=g)&&n.lineStart();if(g!==e){f=intersect(t,p);(!f||pointEqual(t,f)||pointEqual(p,f))&&(p[2]=1)}if(g!==e){u=0;if(g){n.lineStart();f=intersect(p,t);n.point(f[0],f[1])}else{f=intersect(t,p);n.point(f[0],f[1],2);n.lineEnd()}t=f}else if(a&&t&&i^g){var d;if(!(h&r)&&(d=intersect(p,t,true))){u=0;if(i){n.lineStart();n.point(d[0][0],d[0][1]);n.point(d[1][0],d[1][1]);n.lineEnd()}else{n.point(d[1][0],d[1][1]);n.lineEnd();n.lineStart();n.point(d[0][0],d[0][1],3)}}}!g||t&&pointEqual(t,p)||n.point(p[0],p[1]);t=p,e=g,r=h},lineEnd:function(){e&&n.lineEnd();t=null},clean:function(){return u|(c&&e)<<1}}}function intersect(n,r,i){var a=cartesian(n),c=cartesian(r);var u=[1,0,0],l=cartesianCross(a,c),s=cartesianDot(l,l),p=l[0],g=s-p*p;if(!g)return!i&&n;var h=t*s/g,d=-t*p/g,v=cartesianCross(u,l),m=cartesianScale(u,h),E=cartesianScale(l,d);cartesianAddInPlace(m,E);var S=v,y=cartesianDot(m,S),R=cartesianDot(S,S),P=y*y-R*(cartesianDot(m,m)-1);if(!(P<0)){var j=w(P),M=cartesianScale(S,(-y-j)/R);cartesianAddInPlace(M,m);M=spherical(M);if(!i)return M;var b,L=n[0],x=r[0],C=n[1],q=r[1];x<L&&(b=L,L=x,x=b);var $=x-L,_=f($-o)<e,N=_||$<e;!_&&q<C&&(b=C,C=q,q=b);if(N?_?C+q>0^M[1]<(f(M[0]-L)<e?C:q):C<=M[1]&&M[1]<=q:$>o^(L<=M[0]&&M[0]<=x)){var I=cartesianScale(S,(-y+j)/R);cartesianAddInPlace(I,m);return[M,spherical(I)]}}}function code(t,r){var e=i?n:o-n,a=0;t<-e?a|=1:t>e&&(a|=2);r<-e?a|=4:r>e&&(a|=8);return a}return clip(visible,clipLine,interpolate,i?[0,-n]:[-o,n-o])}function clipLine(n,t,r,e,i,o){var a,c=n[0],u=n[1],l=t[0],s=t[1],f=0,p=1,g=l-c,h=s-u;a=r-c;if(g||!(a>0)){a/=g;if(g<0){if(a<f)return;a<p&&(p=a)}else if(g>0){if(a>p)return;a>f&&(f=a)}a=i-c;if(g||!(a<0)){a/=g;if(g<0){if(a>p)return;a>f&&(f=a)}else if(g>0){if(a<f)return;a<p&&(p=a)}a=e-u;if(h||!(a>0)){a/=h;if(h<0){if(a<f)return;a<p&&(p=a)}else if(h>0){if(a>p)return;a>f&&(f=a)}a=o-u;if(h||!(a<0)){a/=h;if(h<0){if(a>p)return;a>f&&(f=a)}else if(h>0){if(a<f)return;a<p&&(p=a)}f>0&&(n[0]=c+f*g,n[1]=u+f*h);p<1&&(t[0]=c+p*g,t[1]=u+p*h);return true}}}}}var fn=1e9,pn=-fn;function clipRectangle(n,r,i,o){function visible(t,e){return n<=t&&t<=i&&r<=e&&e<=o}function interpolate(t,e,a,c){var u=0,l=0;if(t==null||(u=corner(t,a))!==(l=corner(e,a))||comparePoint(t,e)<0^a>0)do{c.point(u===0||u===3?n:i,u>1?o:r)}while((u=(u+a+4)%4)!==l);else c.point(e[0],e[1])}function corner(t,o){return f(t[0]-n)<e?o>0?0:3:f(t[0]-i)<e?o>0?2:1:f(t[1]-r)<e?o>0?1:0:o>0?3:2}function compareIntersection(n,t){return comparePoint(n.x,t.x)}function comparePoint(n,t){var r=corner(n,1),e=corner(t,1);return r!==e?r-e:r===0?t[1]-n[1]:r===1?n[0]-t[0]:r===2?n[1]-t[1]:t[0]-n[0]}return function(e){var a,c,u,l,s,f,p,g,h,d,v,m=e,E=clipBuffer();var S={point:point,lineStart:lineStart,lineEnd:lineEnd,polygonStart:polygonStart,polygonEnd:polygonEnd};function point(n,t){visible(n,t)&&m.point(n,t)}function polygonInside(){var t=0;for(var r=0,e=c.length;r<e;++r)for(var i,a,u=c[r],l=1,s=u.length,f=u[0],p=f[0],g=f[1];l<s;++l){i=p,a=g,f=u[l],p=f[0],g=f[1];a<=o?g>o&&(p-i)*(o-a)>(g-a)*(n-i)&&++t:g<=o&&(p-i)*(o-a)<(g-a)*(n-i)&&--t}return t}function polygonStart(){m=E,a=[],c=[],v=true}function polygonEnd(){var n=polygonInside(),r=v&&n,i=(a=t(a)).length;if(r||i){e.polygonStart();if(r){e.lineStart();interpolate(null,null,1,e);e.lineEnd()}i&&clipRejoin(a,compareIntersection,n,interpolate,e);e.polygonEnd()}m=e,a=c=u=null}function lineStart(){S.point=linePoint;c&&c.push(u=[]);d=true;h=false;p=g=NaN}function lineEnd(){if(a){linePoint(l,s);f&&h&&E.rejoin();a.push(E.result())}S.point=point;h&&m.lineEnd()}function linePoint(t,e){var a=visible(t,e);c&&u.push([t,e]);if(d){l=t,s=e,f=a;d=false;if(a){m.lineStart();m.point(t,e)}}else if(a&&h)m.point(t,e);else{var E=[p=Math.max(pn,Math.min(fn,p)),g=Math.max(pn,Math.min(fn,g))],S=[t=Math.max(pn,Math.min(fn,t)),e=Math.max(pn,Math.min(fn,e))];if(clipLine(E,S,n,r,i,o)){if(!h){m.lineStart();m.point(E[0],E[1])}m.point(S[0],S[1]);a||m.lineEnd();v=false}else if(a){m.lineStart();m.point(t,e);v=false}}p=t,g=e,h=a}return S}}function extent(){var n,t,r,e=0,i=0,o=960,a=500;return r={stream:function(r){return n&&t===r?n:n=clipRectangle(e,i,o,a)(t=r)},extent:function(c){return arguments.length?(e=+c[0][0],i=+c[0][1],o=+c[1][0],a=+c[1][1],n=t=null,r):[[e,i],[o,a]]}}}var gn,hn,dn,vn;var mn={sphere:noop,point:noop,lineStart:lengthLineStart,lineEnd:noop,polygonStart:noop,polygonEnd:noop};function lengthLineStart(){mn.point=lengthPointFirst$1;mn.lineEnd=lengthLineEnd}function lengthLineEnd(){mn.point=mn.lineEnd=noop}function lengthPointFirst$1(n,t){n*=s,t*=s;hn=n,dn=y(t),vn=h(t);mn.point=lengthPoint$1}function lengthPoint$1(n,t){n*=s,t*=s;var r=y(t),e=h(t),i=f(n-hn),o=h(i),a=y(i),c=e*a,u=vn*r-dn*e*o,l=dn*r+vn*e*o;gn.add(g(w(c*c+u*u),l));hn=n,dn=r,vn=e}function length(t){gn=new n;geoStream(t,mn);return+gn}var En=[null,null],Sn={type:\"LineString\",coordinates:En};function distance(n,t){En[0]=n;En[1]=t;return length(Sn)}var yn={Feature:function(n,t){return containsGeometry(n.geometry,t)},FeatureCollection:function(n,t){var r=n.features,e=-1,i=r.length;while(++e<i)if(containsGeometry(r[e].geometry,t))return true;return false}};var Rn={Sphere:function(){return true},Point:function(n,t){return containsPoint(n.coordinates,t)},MultiPoint:function(n,t){var r=n.coordinates,e=-1,i=r.length;while(++e<i)if(containsPoint(r[e],t))return true;return false},LineString:function(n,t){return containsLine(n.coordinates,t)},MultiLineString:function(n,t){var r=n.coordinates,e=-1,i=r.length;while(++e<i)if(containsLine(r[e],t))return true;return false},Polygon:function(n,t){return containsPolygon(n.coordinates,t)},MultiPolygon:function(n,t){var r=n.coordinates,e=-1,i=r.length;while(++e<i)if(containsPolygon(r[e],t))return true;return false},GeometryCollection:function(n,t){var r=n.geometries,e=-1,i=r.length;while(++e<i)if(containsGeometry(r[e],t))return true;return false}};function containsGeometry(n,t){return!(!n||!Rn.hasOwnProperty(n.type))&&Rn[n.type](n,t)}function containsPoint(n,t){return distance(n,t)===0}function containsLine(n,t){var r,e,o;for(var a=0,c=n.length;a<c;a++){e=distance(n[a],t);if(e===0)return true;if(a>0){o=distance(n[a],n[a-1]);if(o>0&&r<=o&&e<=o&&(r+e-o)*(1-Math.pow((r-e)/o,2))<i*o)return true}r=e}return false}function containsPolygon(n,t){return!!polygonContains(n.map(ringRadians),pointRadians(t))}function ringRadians(n){return n=n.map(pointRadians),n.pop(),n}function pointRadians(n){return[n[0]*s,n[1]*s]}function contains(n,t){return(n&&yn.hasOwnProperty(n.type)?yn[n.type]:containsGeometry)(n,t)}function graticuleX(n,t,i){var o=r(n,t-e,i).concat(t);return function(n){return o.map((function(t){return[n,t]}))}}function graticuleY(n,t,i){var o=r(n,t-e,i).concat(t);return function(n){return o.map((function(t){return[t,n]}))}}function graticule(){var n,t,i,o,a,c,u,l,s,p,g,h,v=10,m=v,E=90,S=360,y=2.5;function graticule(){return{type:\"MultiLineString\",coordinates:lines()}}function lines(){return r(d(o/E)*E,i,E).map(g).concat(r(d(l/S)*S,u,S).map(h)).concat(r(d(t/v)*v,n,v).filter((function(n){return f(n%E)>e})).map(s)).concat(r(d(c/m)*m,a,m).filter((function(n){return f(n%S)>e})).map(p))}graticule.lines=function(){return lines().map((function(n){return{type:\"LineString\",coordinates:n}}))};graticule.outline=function(){return{type:\"Polygon\",coordinates:[g(o).concat(h(u).slice(1),g(i).reverse().slice(1),h(l).reverse().slice(1))]}};graticule.extent=function(n){return arguments.length?graticule.extentMajor(n).extentMinor(n):graticule.extentMinor()};graticule.extentMajor=function(n){if(!arguments.length)return[[o,l],[i,u]];o=+n[0][0],i=+n[1][0];l=+n[0][1],u=+n[1][1];o>i&&(n=o,o=i,i=n);l>u&&(n=l,l=u,u=n);return graticule.precision(y)};graticule.extentMinor=function(r){if(!arguments.length)return[[t,c],[n,a]];t=+r[0][0],n=+r[1][0];c=+r[0][1],a=+r[1][1];t>n&&(r=t,t=n,n=r);c>a&&(r=c,c=a,a=r);return graticule.precision(y)};graticule.step=function(n){return arguments.length?graticule.stepMajor(n).stepMinor(n):graticule.stepMinor()};graticule.stepMajor=function(n){if(!arguments.length)return[E,S];E=+n[0],S=+n[1];return graticule};graticule.stepMinor=function(n){if(!arguments.length)return[v,m];v=+n[0],m=+n[1];return graticule};graticule.precision=function(r){if(!arguments.length)return y;y=+r;s=graticuleX(c,a,90);p=graticuleY(t,n,y);g=graticuleX(l,u,90);h=graticuleY(o,i,y);return graticule};return graticule.extentMajor([[-180,-90+e],[180,90-e]]).extentMinor([[-180,-80-e],[180,80+e]])}function graticule10(){return graticule()()}function interpolate(n,t){var r=n[0]*s,e=n[1]*s,i=t[0]*s,o=t[1]*s,a=h(e),c=y(e),u=h(o),f=y(o),p=a*h(r),d=a*y(r),v=u*h(i),m=u*y(i),E=2*asin(w(haversin(o-e)+a*u*haversin(i-r))),S=y(E);var R=E?function(n){var t=y(n*=E)/S,r=y(E-n)/S,e=r*p+t*v,i=r*d+t*m,o=r*c+t*f;return[g(i,e)*l,g(o,w(e*e+i*i))*l]}:function(){return[r*l,e*l]};R.distance=E;return R}var identity$1=n=>n;var wn,Pn,jn,Mn,bn=new n,Ln=new n;var xn={point:noop,lineStart:noop,lineEnd:noop,polygonStart:function(){xn.lineStart=areaRingStart;xn.lineEnd=areaRingEnd},polygonEnd:function(){xn.lineStart=xn.lineEnd=xn.point=noop;bn.add(f(Ln));Ln=new n},result:function(){var t=bn/2;bn=new n;return t}};function areaRingStart(){xn.point=areaPointFirst}function areaPointFirst(n,t){xn.point=areaPoint;wn=jn=n,Pn=Mn=t}function areaPoint(n,t){Ln.add(Mn*n-jn*t);jn=n,Mn=t}function areaRingEnd(){areaPoint(wn,Pn)}var Cn=Infinity,qn=Cn,$n=-Cn,_n=$n;var Nn={point:boundsPoint,lineStart:noop,lineEnd:noop,polygonStart:noop,polygonEnd:noop,result:function(){var n=[[Cn,qn],[$n,_n]];$n=_n=-(qn=Cn=Infinity);return n}};function boundsPoint(n,t){n<Cn&&(Cn=n);n>$n&&($n=n);t<qn&&(qn=t);t>_n&&(_n=t)}var In,An,zn,Fn,Tn=0,Un=0,Gn=0,kn=0,Hn=0,Wn=0,Dn=0,On=0,Xn=0;var Yn={point:centroidPoint,lineStart:centroidLineStart,lineEnd:centroidLineEnd,polygonStart:function(){Yn.lineStart=centroidRingStart;Yn.lineEnd=centroidRingEnd},polygonEnd:function(){Yn.point=centroidPoint;Yn.lineStart=centroidLineStart;Yn.lineEnd=centroidLineEnd},result:function(){var n=Xn?[Dn/Xn,On/Xn]:Wn?[kn/Wn,Hn/Wn]:Gn?[Tn/Gn,Un/Gn]:[NaN,NaN];Tn=Un=Gn=kn=Hn=Wn=Dn=On=Xn=0;return n}};function centroidPoint(n,t){Tn+=n;Un+=t;++Gn}function centroidLineStart(){Yn.point=centroidPointFirstLine}function centroidPointFirstLine(n,t){Yn.point=centroidPointLine;centroidPoint(zn=n,Fn=t)}function centroidPointLine(n,t){var r=n-zn,e=t-Fn,i=w(r*r+e*e);kn+=i*(zn+n)/2;Hn+=i*(Fn+t)/2;Wn+=i;centroidPoint(zn=n,Fn=t)}function centroidLineEnd(){Yn.point=centroidPoint}function centroidRingStart(){Yn.point=centroidPointFirstRing}function centroidRingEnd(){centroidPointRing(In,An)}function centroidPointFirstRing(n,t){Yn.point=centroidPointRing;centroidPoint(In=zn=n,An=Fn=t)}function centroidPointRing(n,t){var r=n-zn,e=t-Fn,i=w(r*r+e*e);kn+=i*(zn+n)/2;Hn+=i*(Fn+t)/2;Wn+=i;i=Fn*n-zn*t;Dn+=i*(zn+n);On+=i*(Fn+t);Xn+=i*3;centroidPoint(zn=n,Fn=t)}function PathContext(n){this._context=n}PathContext.prototype={_radius:4.5,pointRadius:function(n){return this._radius=n,this},polygonStart:function(){this._line=0},polygonEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){this._line===0&&this._context.closePath();this._point=NaN},point:function(n,t){switch(this._point){case 0:this._context.moveTo(n,t);this._point=1;break;case 1:this._context.lineTo(n,t);break;default:this._context.moveTo(n+this._radius,t);this._context.arc(n,t,this._radius,0,u);break}},result:noop};var Bn,Zn,Jn,Kn,Qn,Vn=new n;var nt={point:noop,lineStart:function(){nt.point=lengthPointFirst},lineEnd:function(){Bn&&lengthPoint(Zn,Jn);nt.point=noop},polygonStart:function(){Bn=true},polygonEnd:function(){Bn=null},result:function(){var t=+Vn;Vn=new n;return t}};function lengthPointFirst(n,t){nt.point=lengthPoint;Zn=Kn=n,Jn=Qn=t}function lengthPoint(n,t){Kn-=n,Qn-=t;Vn.add(w(Kn*Kn+Qn*Qn));Kn=n,Qn=t}let tt,rt,et,it;class PathString{constructor(n){this._append=n==null?append:appendRound(n);this._radius=4.5;this._=\"\"}pointRadius(n){this._radius=+n;return this}polygonStart(){this._line=0}polygonEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){this._line===0&&(this._+=\"Z\");this._point=NaN}point(n,t){switch(this._point){case 0:this._append`M${n},${t}`;this._point=1;break;case 1:this._append`L${n},${t}`;break;default:this._append`M${n},${t}`;if(this._radius!==et||this._append!==rt){const n=this._radius;const t=this._;this._=\"\";this._append`m0,${n}a${n},${n} 0 1,1 0,${-2*n}a${n},${n} 0 1,1 0,${2*n}z`;et=n;rt=this._append;it=this._;this._=t}this._+=it;break}}result(){const n=this._;this._=\"\";return n.length?n:null}}function append(n){let t=1;this._+=n[0];for(const r=n.length;t<r;++t)this._+=arguments[t]+n[t]}function appendRound(n){const t=Math.floor(n);if(!(t>=0))throw new RangeError(`invalid digits: ${n}`);if(t>15)return append;if(t!==tt){const n=10**t;tt=t;rt=function append(t){let r=1;this._+=t[0];for(const e=t.length;r<e;++r)this._+=Math.round(arguments[r]*n)/n+t[r]}}return rt}function index(n,t){let r,e,i=3,o=4.5;function path(n){if(n){typeof o===\"function\"&&e.pointRadius(+o.apply(this,arguments));geoStream(n,r(e))}return e.result()}path.area=function(n){geoStream(n,r(xn));return xn.result()};path.measure=function(n){geoStream(n,r(nt));return nt.result()};path.bounds=function(n){geoStream(n,r(Nn));return Nn.result()};path.centroid=function(n){geoStream(n,r(Yn));return Yn.result()};path.projection=function(t){if(!arguments.length)return n;r=t==null?(n=null,identity$1):(n=t).stream;return path};path.context=function(n){if(!arguments.length)return t;e=n==null?(t=null,new PathString(i)):new PathContext(t=n);typeof o!==\"function\"&&e.pointRadius(o);return path};path.pointRadius=function(n){if(!arguments.length)return o;o=typeof n===\"function\"?n:(e.pointRadius(+n),+n);return path};path.digits=function(n){if(!arguments.length)return i;if(n==null)i=null;else{const t=Math.floor(n);if(!(t>=0))throw new RangeError(`invalid digits: ${n}`);i=t}t===null&&(e=new PathString(i));return path};return path.projection(n).digits(i).context(t)}function transform(n){return{stream:transformer(n)}}function transformer(n){return function(t){var r=new TransformStream;for(var e in n)r[e]=n[e];r.stream=t;return r}}function TransformStream(){}TransformStream.prototype={constructor:TransformStream,point:function(n,t){this.stream.point(n,t)},sphere:function(){this.stream.sphere()},lineStart:function(){this.stream.lineStart()},lineEnd:function(){this.stream.lineEnd()},polygonStart:function(){this.stream.polygonStart()},polygonEnd:function(){this.stream.polygonEnd()}};function fit(n,t,r){var e=n.clipExtent&&n.clipExtent();n.scale(150).translate([0,0]);e!=null&&n.clipExtent(null);geoStream(r,n.stream(Nn));t(Nn.result());e!=null&&n.clipExtent(e);return n}function fitExtent(n,t,r){return fit(n,(function(r){var e=t[1][0]-t[0][0],i=t[1][1]-t[0][1],o=Math.min(e/(r[1][0]-r[0][0]),i/(r[1][1]-r[0][1])),a=+t[0][0]+(e-o*(r[1][0]+r[0][0]))/2,c=+t[0][1]+(i-o*(r[1][1]+r[0][1]))/2;n.scale(150*o).translate([a,c])}),r)}function fitSize(n,t,r){return fitExtent(n,[[0,0],t],r)}function fitWidth(n,t,r){return fit(n,(function(r){var e=+t,i=e/(r[1][0]-r[0][0]),o=(e-i*(r[1][0]+r[0][0]))/2,a=-i*r[0][1];n.scale(150*i).translate([o,a])}),r)}function fitHeight(n,t,r){return fit(n,(function(r){var e=+t,i=e/(r[1][1]-r[0][1]),o=-i*r[0][0],a=(e-i*(r[1][1]+r[0][1]))/2;n.scale(150*i).translate([o,a])}),r)}var ot=16,at=h(30*s);function resample(n,t){return+t?resample$1(n,t):resampleNone(n)}function resampleNone(n){return transformer({point:function(t,r){t=n(t,r);this.stream.point(t[0],t[1])}})}function resample$1(n,t){function resampleLineTo(r,i,o,a,c,u,l,s,p,h,d,v,m,E){var S=l-r,y=s-i,R=S*S+y*y;if(R>4*t&&m--){var P=a+h,j=c+d,M=u+v,b=w(P*P+j*j+M*M),L=asin(M/=b),x=f(f(M)-1)<e||f(o-p)<e?(o+p)/2:g(j,P),C=n(x,L),q=C[0],$=C[1],_=q-r,N=$-i,I=y*_-S*N;if(I*I/R>t||f((S*_+y*N)/R-.5)>.3||a*h+c*d+u*v<at){resampleLineTo(r,i,o,a,c,u,q,$,x,P/=b,j/=b,M,m,E);E.point(q,$);resampleLineTo(q,$,x,P,j,M,l,s,p,h,d,v,m,E)}}}return function(t){var r,e,i,o,a,c,u,l,s,f,p,g;var h={point:point,lineStart:lineStart,lineEnd:lineEnd,polygonStart:function(){t.polygonStart();h.lineStart=ringStart},polygonEnd:function(){t.polygonEnd();h.lineStart=lineStart}};function point(r,e){r=n(r,e);t.point(r[0],r[1])}function lineStart(){l=NaN;h.point=linePoint;t.lineStart()}function linePoint(r,e){var i=cartesian([r,e]),o=n(r,e);resampleLineTo(l,s,u,f,p,g,l=o[0],s=o[1],u=r,f=i[0],p=i[1],g=i[2],ot,t);t.point(l,s)}function lineEnd(){h.point=point;t.lineEnd()}function ringStart(){lineStart();h.point=ringPoint;h.lineEnd=ringEnd}function ringPoint(n,t){linePoint(r=n,t),e=l,i=s,o=f,a=p,c=g;h.point=linePoint}function ringEnd(){resampleLineTo(l,s,u,f,p,g,e,i,r,o,a,c,ot,t);h.lineEnd=lineEnd;lineEnd()}return h}}var ct=transformer({point:function(n,t){this.stream.point(n*s,t*s)}});function transformRotate(n){return transformer({point:function(t,r){var e=n(t,r);return this.stream.point(e[0],e[1])}})}function scaleTranslate(n,t,r,e,i){function transform(o,a){o*=e;a*=i;return[t+n*o,r-n*a]}transform.invert=function(o,a){return[(o-t)/n*e,(r-a)/n*i]};return transform}function scaleTranslateRotate(n,t,r,e,i,o){if(!o)return scaleTranslate(n,t,r,e,i);var a=h(o),c=y(o),u=a*n,l=c*n,s=a/n,f=c/n,p=(c*r-a*t)/n,g=(c*t+a*r)/n;function transform(n,o){n*=e;o*=i;return[u*n-l*o+t,r-l*n-u*o]}transform.invert=function(n,t){return[e*(s*n-f*t+p),i*(g-f*n-s*t)]};return transform}function projection(n){return projectionMutator((function(){return n}))()}function projectionMutator(n){var t,r,e,i,o,a,c,u,f,p,g=150,h=480,d=250,v=0,m=0,E=0,S=0,y=0,R=0,P=1,j=1,M=null,b=sn,L=null,x=identity$1,C=.5;function projection(n){return u(n[0]*s,n[1]*s)}function invert(n){n=u.invert(n[0],n[1]);return n&&[n[0]*l,n[1]*l]}projection.stream=function(n){return f&&p===n?f:f=ct(transformRotate(r)(b(a(x(p=n)))))};projection.preclip=function(n){return arguments.length?(b=n,M=void 0,reset()):b};projection.postclip=function(n){return arguments.length?(x=n,L=e=i=o=null,reset()):x};projection.clipAngle=function(n){return arguments.length?(b=+n?clipCircle(M=n*s):(M=null,sn),reset()):M*l};projection.clipExtent=function(n){return arguments.length?(x=n==null?(L=e=i=o=null,identity$1):clipRectangle(L=+n[0][0],e=+n[0][1],i=+n[1][0],o=+n[1][1]),reset()):L==null?null:[[L,e],[i,o]]};projection.scale=function(n){return arguments.length?(g=+n,recenter()):g};projection.translate=function(n){return arguments.length?(h=+n[0],d=+n[1],recenter()):[h,d]};projection.center=function(n){return arguments.length?(v=n[0]%360*s,m=n[1]%360*s,recenter()):[v*l,m*l]};projection.rotate=function(n){return arguments.length?(E=n[0]%360*s,S=n[1]%360*s,y=n.length>2?n[2]%360*s:0,recenter()):[E*l,S*l,y*l]};projection.angle=function(n){return arguments.length?(R=n%360*s,recenter()):R*l};projection.reflectX=function(n){return arguments.length?(P=n?-1:1,recenter()):P<0};projection.reflectY=function(n){return arguments.length?(j=n?-1:1,recenter()):j<0};projection.precision=function(n){return arguments.length?(a=resample(c,C=n*n),reset()):w(C)};projection.fitExtent=function(n,t){return fitExtent(projection,n,t)};projection.fitSize=function(n,t){return fitSize(projection,n,t)};projection.fitWidth=function(n,t){return fitWidth(projection,n,t)};projection.fitHeight=function(n,t){return fitHeight(projection,n,t)};function recenter(){var n=scaleTranslateRotate(g,0,0,P,j,R).apply(null,t(v,m)),e=scaleTranslateRotate(g,h-n[0],d-n[1],P,j,R);r=rotateRadians(E,S,y);c=compose(t,e);u=compose(r,c);a=resample(c,C);return reset()}function reset(){f=p=null;return projection}return function(){t=n.apply(this,arguments);projection.invert=t.invert&&invert;return recenter()}}function conicProjection(n){var t=0,r=o/3,e=projectionMutator(n),i=e(t,r);i.parallels=function(n){return arguments.length?e(t=n[0]*s,r=n[1]*s):[t*l,r*l]};return i}function cylindricalEqualAreaRaw(n){var t=h(n);function forward(n,r){return[n*t,y(r)/t]}forward.invert=function(n,r){return[n/t,asin(r*t)]};return forward}function conicEqualAreaRaw(n,t){var r=y(n),i=(r+y(t))/2;if(f(i)<e)return cylindricalEqualAreaRaw(n);var a=1+r*(2*i-r),c=w(a)/i;function project(n,t){var r=w(a-2*i*y(t))/i;return[r*y(n*=i),c-r*h(n)]}project.invert=function(n,t){var r=c-t,e=g(n,f(r))*R(r);r*i<0&&(e-=o*R(n)*R(r));return[e/i,asin((a-(n*n+r*r)*i*i)/(2*i))]};return project}function conicEqualArea(){return conicProjection(conicEqualAreaRaw).scale(155.424).center([0,33.6442])}function albers(){return conicEqualArea().parallels([29.5,45.5]).scale(1070).translate([480,250]).rotate([96,0]).center([-.6,38.7])}function multiplex(n){var t=n.length;return{point:function(r,e){var i=-1;while(++i<t)n[i].point(r,e)},sphere:function(){var r=-1;while(++r<t)n[r].sphere()},lineStart:function(){var r=-1;while(++r<t)n[r].lineStart()},lineEnd:function(){var r=-1;while(++r<t)n[r].lineEnd()},polygonStart:function(){var r=-1;while(++r<t)n[r].polygonStart()},polygonEnd:function(){var r=-1;while(++r<t)n[r].polygonEnd()}}}function albersUsa(){var n,t,r,i,o,a,c=albers(),u=conicEqualArea().rotate([154,0]).center([-2,58.5]).parallels([55,65]),l=conicEqualArea().rotate([157,0]).center([-3,19.9]).parallels([8,18]),s={point:function(n,t){a=[n,t]}};function albersUsa(n){var t=n[0],e=n[1];return a=null,(r.point(t,e),a)||(i.point(t,e),a)||(o.point(t,e),a)}albersUsa.invert=function(n){var t=c.scale(),r=c.translate(),e=(n[0]-r[0])/t,i=(n[1]-r[1])/t;return(i>=.12&&i<.234&&e>=-.425&&e<-.214?u:i>=.166&&i<.234&&e>=-.214&&e<-.115?l:c).invert(n)};albersUsa.stream=function(r){return n&&t===r?n:n=multiplex([c.stream(t=r),u.stream(r),l.stream(r)])};albersUsa.precision=function(n){if(!arguments.length)return c.precision();c.precision(n),u.precision(n),l.precision(n);return reset()};albersUsa.scale=function(n){if(!arguments.length)return c.scale();c.scale(n),u.scale(n*.35),l.scale(n);return albersUsa.translate(c.translate())};albersUsa.translate=function(n){if(!arguments.length)return c.translate();var t=c.scale(),a=+n[0],f=+n[1];r=c.translate(n).clipExtent([[a-.455*t,f-.238*t],[a+.455*t,f+.238*t]]).stream(s);i=u.translate([a-.307*t,f+.201*t]).clipExtent([[a-.425*t+e,f+.12*t+e],[a-.214*t-e,f+.234*t-e]]).stream(s);o=l.translate([a-.205*t,f+.212*t]).clipExtent([[a-.214*t+e,f+.166*t+e],[a-.115*t-e,f+.234*t-e]]).stream(s);return reset()};albersUsa.fitExtent=function(n,t){return fitExtent(albersUsa,n,t)};albersUsa.fitSize=function(n,t){return fitSize(albersUsa,n,t)};albersUsa.fitWidth=function(n,t){return fitWidth(albersUsa,n,t)};albersUsa.fitHeight=function(n,t){return fitHeight(albersUsa,n,t)};function reset(){n=t=null;return albersUsa}return albersUsa.scale(1070)}function azimuthalRaw(n){return function(t,r){var e=h(t),i=h(r),o=n(e*i);return o===Infinity?[2,0]:[o*i*y(t),o*y(r)]}}function azimuthalInvert(n){return function(t,r){var e=w(t*t+r*r),i=n(e),o=y(i),a=h(i);return[g(t*o,e*a),asin(e&&r*o/e)]}}var ut=azimuthalRaw((function(n){return w(2/(1+n))}));ut.invert=azimuthalInvert((function(n){return 2*asin(n/2)}));function azimuthalEqualArea(){return projection(ut).scale(124.75).clipAngle(179.999)}var lt=azimuthalRaw((function(n){return(n=acos(n))&&n/y(n)}));lt.invert=azimuthalInvert((function(n){return n}));function azimuthalEquidistant(){return projection(lt).scale(79.4188).clipAngle(179.999)}function mercatorRaw(n,t){return[n,E(P((a+t)/2))]}mercatorRaw.invert=function(n,t){return[n,2*p(v(t))-a]};function mercator(){return mercatorProjection(mercatorRaw).scale(961/u)}function mercatorProjection(n){var t,r,e,i=projection(n),a=i.center,c=i.scale,u=i.translate,l=i.clipExtent,s=null;i.scale=function(n){return arguments.length?(c(n),reclip()):c()};i.translate=function(n){return arguments.length?(u(n),reclip()):u()};i.center=function(n){return arguments.length?(a(n),reclip()):a()};i.clipExtent=function(n){return arguments.length?(n==null?s=t=r=e=null:(s=+n[0][0],t=+n[0][1],r=+n[1][0],e=+n[1][1]),reclip()):s==null?null:[[s,t],[r,e]]};function reclip(){var a=o*c(),u=i(rotation(i.rotate()).invert([0,0]));return l(s==null?[[u[0]-a,u[1]-a],[u[0]+a,u[1]+a]]:n===mercatorRaw?[[Math.max(u[0]-a,s),t],[Math.min(u[0]+a,r),e]]:[[s,Math.max(u[1]-a,t)],[r,Math.min(u[1]+a,e)]])}return reclip()}function tany(n){return P((a+n)/2)}function conicConformalRaw(n,t){var r=h(n),i=n===t?y(n):E(r/h(t))/E(tany(t)/tany(n)),c=r*S(tany(n),i)/i;if(!i)return mercatorRaw;function project(n,t){c>0?t<-a+e&&(t=-a+e):t>a-e&&(t=a-e);var r=c/S(tany(t),i);return[r*y(i*n),c-r*h(i*n)]}project.invert=function(n,t){var r=c-t,e=R(i)*w(n*n+r*r),u=g(n,f(r))*R(r);r*i<0&&(u-=o*R(n)*R(r));return[u/i,2*p(S(c/e,1/i))-a]};return project}function conicConformal(){return conicProjection(conicConformalRaw).scale(109.5).parallels([30,30])}function equirectangularRaw(n,t){return[n,t]}equirectangularRaw.invert=equirectangularRaw;function equirectangular(){return projection(equirectangularRaw).scale(152.63)}function conicEquidistantRaw(n,t){var r=h(n),i=n===t?y(n):(r-h(t))/(t-n),a=r/i+n;if(f(i)<e)return equirectangularRaw;function project(n,t){var r=a-t,e=i*n;return[r*y(e),a-r*h(e)]}project.invert=function(n,t){var r=a-t,e=g(n,f(r))*R(r);r*i<0&&(e-=o*R(n)*R(r));return[e/i,a-R(i)*w(n*n+r*r)]};return project}function conicEquidistant(){return conicProjection(conicEquidistantRaw).scale(131.154).center([0,13.9389])}var st=1.340264,ft=-.081106,pt=893e-6,gt=.003796,ht=w(3)/2,dt=12;function equalEarthRaw(n,t){var r=asin(ht*y(t)),e=r*r,i=e*e*e;return[n*h(r)/(ht*(st+3*ft*e+i*(7*pt+9*gt*e))),r*(st+ft*e+i*(pt+gt*e))]}equalEarthRaw.invert=function(n,t){var r=t,e=r*r,o=e*e*e;for(var a,c,u,l=0;l<dt;++l){c=r*(st+ft*e+o*(pt+gt*e))-t;u=st+3*ft*e+o*(7*pt+9*gt*e);r-=a=c/u,e=r*r,o=e*e*e;if(f(a)<i)break}return[ht*n*(st+3*ft*e+o*(7*pt+9*gt*e))/h(r),asin(y(r)/ht)]};function equalEarth(){return projection(equalEarthRaw).scale(177.158)}function gnomonicRaw(n,t){var r=h(t),e=h(n)*r;return[r*y(n)/e,y(t)/e]}gnomonicRaw.invert=azimuthalInvert(p);function gnomonic(){return projection(gnomonicRaw).scale(144.049).clipAngle(60)}function identity(){var n,t,r,e,i,o,a,c=1,u=0,f=0,p=1,g=1,d=0,v=null,m=1,E=1,S=transformer({point:function(n,t){var r=projection([n,t]);this.stream.point(r[0],r[1])}}),R=identity$1;function reset(){m=c*p;E=c*g;o=a=null;return projection}function projection(r){var e=r[0]*m,i=r[1]*E;if(d){var o=i*n-e*t;e=e*n+i*t;i=o}return[e+u,i+f]}projection.invert=function(r){var e=r[0]-u,i=r[1]-f;if(d){var o=i*n+e*t;e=e*n-i*t;i=o}return[e/m,i/E]};projection.stream=function(n){return o&&a===n?o:o=S(R(a=n))};projection.postclip=function(n){return arguments.length?(R=n,v=r=e=i=null,reset()):R};projection.clipExtent=function(n){return arguments.length?(R=n==null?(v=r=e=i=null,identity$1):clipRectangle(v=+n[0][0],r=+n[0][1],e=+n[1][0],i=+n[1][1]),reset()):v==null?null:[[v,r],[e,i]]};projection.scale=function(n){return arguments.length?(c=+n,reset()):c};projection.translate=function(n){return arguments.length?(u=+n[0],f=+n[1],reset()):[u,f]};projection.angle=function(r){return arguments.length?(d=r%360*s,t=y(d),n=h(d),reset()):d*l};projection.reflectX=function(n){return arguments.length?(p=n?-1:1,reset()):p<0};projection.reflectY=function(n){return arguments.length?(g=n?-1:1,reset()):g<0};projection.fitExtent=function(n,t){return fitExtent(projection,n,t)};projection.fitSize=function(n,t){return fitSize(projection,n,t)};projection.fitWidth=function(n,t){return fitWidth(projection,n,t)};projection.fitHeight=function(n,t){return fitHeight(projection,n,t)};return projection}function naturalEarth1Raw(n,t){var r=t*t,e=r*r;return[n*(.8707-.131979*r+e*(e*(.003971*r-.001529*e)-.013791)),t*(1.007226+r*(.015085+e*(.028874*r-.044475-.005916*e)))]}naturalEarth1Raw.invert=function(n,t){var r,i=t,o=25;do{var a=i*i,c=a*a;i-=r=(i*(1.007226+a*(.015085+c*(.028874*a-.044475-.005916*c)))-t)/(1.007226+a*(.045255+c*(.259866*a-.311325-.005916*11*c)))}while(f(r)>e&&--o>0);return[n/(.8707+(a=i*i)*(a*(a*a*a*(.003971-.001529*a)-.013791)-.131979)),i]};function naturalEarth1(){return projection(naturalEarth1Raw).scale(175.295)}function orthographicRaw(n,t){return[h(t)*y(n),y(t)]}orthographicRaw.invert=azimuthalInvert(asin);function orthographic(){return projection(orthographicRaw).scale(249.5).clipAngle(90+e)}function stereographicRaw(n,t){var r=h(t),e=1+h(n)*r;return[r*y(n)/e,y(t)/e]}stereographicRaw.invert=azimuthalInvert((function(n){return 2*p(n)}));function stereographic(){return projection(stereographicRaw).scale(250).clipAngle(142)}function transverseMercatorRaw(n,t){return[E(P((a+t)/2)),-n]}transverseMercatorRaw.invert=function(n,t){return[-t,2*p(v(n))-a]};function transverseMercator(){var n=mercatorProjection(transverseMercatorRaw),t=n.center,r=n.rotate;n.center=function(n){return arguments.length?t([-n[1],n[0]]):(n=t(),[n[1],-n[0]])};n.rotate=function(n){return arguments.length?r([n[0],n[1],n.length>2?n[2]+90:90]):(n=r(),[n[0],n[1],n[2]-90])};return r([0,0,90]).scale(159.155)}export{albers as geoAlbers,albersUsa as geoAlbersUsa,area as geoArea,azimuthalEqualArea as geoAzimuthalEqualArea,ut as geoAzimuthalEqualAreaRaw,azimuthalEquidistant as geoAzimuthalEquidistant,lt as geoAzimuthalEquidistantRaw,bounds as geoBounds,centroid as geoCentroid,circle as geoCircle,sn as geoClipAntimeridian,clipCircle as geoClipCircle,extent as geoClipExtent,clipRectangle as geoClipRectangle,conicConformal as geoConicConformal,conicConformalRaw as geoConicConformalRaw,conicEqualArea as geoConicEqualArea,conicEqualAreaRaw as geoConicEqualAreaRaw,conicEquidistant as geoConicEquidistant,conicEquidistantRaw as geoConicEquidistantRaw,contains as geoContains,distance as geoDistance,equalEarth as geoEqualEarth,equalEarthRaw as geoEqualEarthRaw,equirectangular as geoEquirectangular,equirectangularRaw as geoEquirectangularRaw,gnomonic as geoGnomonic,gnomonicRaw as geoGnomonicRaw,graticule as geoGraticule,graticule10 as geoGraticule10,identity as geoIdentity,interpolate as geoInterpolate,length as geoLength,mercator as geoMercator,mercatorRaw as geoMercatorRaw,naturalEarth1 as geoNaturalEarth1,naturalEarth1Raw as geoNaturalEarth1Raw,orthographic as geoOrthographic,orthographicRaw as geoOrthographicRaw,index as geoPath,projection as geoProjection,projectionMutator as geoProjectionMutator,rotation as geoRotation,stereographic as geoStereographic,stereographicRaw as geoStereographicRaw,geoStream,transform as geoTransform,transverseMercator as geoTransverseMercator,transverseMercatorRaw as geoTransverseMercatorRaw};\n\n"
  },
  {
    "path": "vendor/javascript/d3-hierarchy.js",
    "content": "// d3-hierarchy@3.1.2 downloaded from https://ga.jspm.io/npm:d3-hierarchy@3.1.2/src/index.js\n\nfunction defaultSeparation$1(e,n){return e.parent===n.parent?1:2}function meanX(e){return e.reduce(meanXReduce,0)/e.length}function meanXReduce(e,n){return e+n.x}function maxY(e){return 1+e.reduce(maxYReduce,0)}function maxYReduce(e,n){return Math.max(e,n.y)}function leafLeft(e){var n;while(n=e.children)e=n[0];return e}function leafRight(e){var n;while(n=e.children)e=n[n.length-1];return e}function cluster(){var e=defaultSeparation$1,n=1,t=1,r=false;function cluster(i){var a,o=0;i.eachAfter((function(n){var t=n.children;if(t){n.x=meanX(t);n.y=maxY(t)}else{n.x=a?o+=e(n,a):0;n.y=0;a=n}}));var u=leafLeft(i),c=leafRight(i),l=u.x-e(u,c)/2,s=c.x+e(c,u)/2;return i.eachAfter(r?function(e){e.x=(e.x-i.x)*n;e.y=(i.y-e.y)*t}:function(e){e.x=(e.x-l)/(s-l)*n;e.y=(1-(i.y?e.y/i.y:1))*t})}cluster.separation=function(n){return arguments.length?(e=n,cluster):e};cluster.size=function(e){return arguments.length?(r=false,n=+e[0],t=+e[1],cluster):r?null:[n,t]};cluster.nodeSize=function(e){return arguments.length?(r=true,n=+e[0],t=+e[1],cluster):r?[n,t]:null};return cluster}function count(e){var n=0,t=e.children,r=t&&t.length;if(r)while(--r>=0)n+=t[r].value;else n=1;e.value=n}function node_count(){return this.eachAfter(count)}function node_each(e,n){let t=-1;for(const r of this)e.call(n,r,++t,this);return this}function node_eachBefore(e,n){var t,r,i=this,a=[i],o=-1;while(i=a.pop()){e.call(n,i,++o,this);if(t=i.children)for(r=t.length-1;r>=0;--r)a.push(t[r])}return this}function node_eachAfter(e,n){var t,r,i,a=this,o=[a],u=[],c=-1;while(a=o.pop()){u.push(a);if(t=a.children)for(r=0,i=t.length;r<i;++r)o.push(t[r])}while(a=u.pop())e.call(n,a,++c,this);return this}function node_find(e,n){let t=-1;for(const r of this)if(e.call(n,r,++t,this))return r}function node_sum(e){return this.eachAfter((function(n){var t=+e(n.data)||0,r=n.children,i=r&&r.length;while(--i>=0)t+=r[i].value;n.value=t}))}function node_sort(e){return this.eachBefore((function(n){n.children&&n.children.sort(e)}))}function node_path(e){var n=this,t=leastCommonAncestor(n,e),r=[n];while(n!==t){n=n.parent;r.push(n)}var i=r.length;while(e!==t){r.splice(i,0,e);e=e.parent}return r}function leastCommonAncestor(e,n){if(e===n)return e;var t=e.ancestors(),r=n.ancestors(),i=null;e=t.pop();n=r.pop();while(e===n){i=e;e=t.pop();n=r.pop()}return i}function node_ancestors(){var e=this,n=[e];while(e=e.parent)n.push(e);return n}function node_descendants(){return Array.from(this)}function node_leaves(){var e=[];this.eachBefore((function(n){n.children||e.push(n)}));return e}function node_links(){var e=this,n=[];e.each((function(t){t!==e&&n.push({source:t.parent,target:t})}));return n}function*node_iterator(){var e,n,t,r,i=this,a=[i];do{e=a.reverse(),a=[];while(i=e.pop()){yield i;if(n=i.children)for(t=0,r=n.length;t<r;++t)a.push(n[t])}}while(a.length)}function hierarchy(e,n){if(e instanceof Map){e=[void 0,e];void 0===n&&(n=mapChildren)}else void 0===n&&(n=objectChildren);var t,r,i,a,o,u=new Node$1(e),c=[u];while(t=c.pop())if((i=n(t.data))&&(o=(i=Array.from(i)).length)){t.children=i;for(a=o-1;a>=0;--a){c.push(r=i[a]=new Node$1(i[a]));r.parent=t;r.depth=t.depth+1}}return u.eachBefore(computeHeight)}function node_copy(){return hierarchy(this).eachBefore(copyData)}function objectChildren(e){return e.children}function mapChildren(e){return Array.isArray(e)?e[1]:null}function copyData(e){void 0!==e.data.value&&(e.value=e.data.value);e.data=e.data.data}function computeHeight(e){var n=0;do{e.height=n}while((e=e.parent)&&e.height<++n)}function Node$1(e){this.data=e;this.depth=this.height=0;this.parent=null}Node$1.prototype=hierarchy.prototype={constructor:Node$1,count:node_count,each:node_each,eachAfter:node_eachAfter,eachBefore:node_eachBefore,find:node_find,sum:node_sum,sort:node_sort,path:node_path,ancestors:node_ancestors,descendants:node_descendants,leaves:node_leaves,links:node_links,copy:node_copy,[Symbol.iterator]:node_iterator};function optional(e){return null==e?null:required(e)}function required(e){if(\"function\"!==typeof e)throw new Error;return e}function constantZero(){return 0}function constant(e){return function(){return e}}const e=1664525;const n=1013904223;const t=4294967296;function lcg(){let r=1;return()=>(r=(e*r+n)%t)/t}function array(e){return\"object\"===typeof e&&\"length\"in e?e:Array.from(e)}function shuffle(e,n){let t,r,i=e.length;while(i){r=n()*i--|0;t=e[i];e[i]=e[r];e[r]=t}return e}function enclose(e){return packEncloseRandom(e,lcg())}function packEncloseRandom(e,n){var t,r,i=0,a=(e=shuffle(Array.from(e),n)).length,o=[];while(i<a){t=e[i];r&&enclosesWeak(r,t)?++i:(r=encloseBasis(o=extendBasis(o,t)),i=0)}return r}function extendBasis(e,n){var t,r;if(enclosesWeakAll(n,e))return[n];for(t=0;t<e.length;++t)if(enclosesNot(n,e[t])&&enclosesWeakAll(encloseBasis2(e[t],n),e))return[e[t],n];for(t=0;t<e.length-1;++t)for(r=t+1;r<e.length;++r)if(enclosesNot(encloseBasis2(e[t],e[r]),n)&&enclosesNot(encloseBasis2(e[t],n),e[r])&&enclosesNot(encloseBasis2(e[r],n),e[t])&&enclosesWeakAll(encloseBasis3(e[t],e[r],n),e))return[e[t],e[r],n];throw new Error}function enclosesNot(e,n){var t=e.r-n.r,r=n.x-e.x,i=n.y-e.y;return t<0||t*t<r*r+i*i}function enclosesWeak(e,n){var t=e.r-n.r+1e-9*Math.max(e.r,n.r,1),r=n.x-e.x,i=n.y-e.y;return t>0&&t*t>r*r+i*i}function enclosesWeakAll(e,n){for(var t=0;t<n.length;++t)if(!enclosesWeak(e,n[t]))return false;return true}function encloseBasis(e){switch(e.length){case 1:return encloseBasis1(e[0]);case 2:return encloseBasis2(e[0],e[1]);case 3:return encloseBasis3(e[0],e[1],e[2])}}function encloseBasis1(e){return{x:e.x,y:e.y,r:e.r}}function encloseBasis2(e,n){var t=e.x,r=e.y,i=e.r,a=n.x,o=n.y,u=n.r,c=a-t,l=o-r,s=u-i,f=Math.sqrt(c*c+l*l);return{x:(t+a+c/f*s)/2,y:(r+o+l/f*s)/2,r:(f+i+u)/2}}function encloseBasis3(e,n,t){var r=e.x,i=e.y,a=e.r,o=n.x,u=n.y,c=n.r,l=t.x,s=t.y,f=t.r,h=r-o,d=r-l,p=i-u,y=i-s,x=c-a,m=f-a,v=r*r+i*i-a*a,g=v-o*o-u*u+c*c,w=v-l*l-s*s+f*f,_=d*p-h*y,B=(p*w-y*g)/(2*_)-r,k=(y*x-p*m)/_,N=(d*g-h*w)/(2*_)-i,A=(h*m-d*x)/_,R=k*k+A*A-1,z=2*(a+B*k+N*A),M=B*B+N*N-a*a,S=-(Math.abs(R)>1e-6?(z+Math.sqrt(z*z-4*R*M))/(2*R):M/z);return{x:r+B+k*S,y:i+N+A*S,r:S}}function place(e,n,t){var r,i,a,o,u=e.x-n.x,c=e.y-n.y,l=u*u+c*c;if(l){i=n.r+t.r,i*=i;o=e.r+t.r,o*=o;if(i>o){r=(l+o-i)/(2*l);a=Math.sqrt(Math.max(0,o/l-r*r));t.x=e.x-r*u-a*c;t.y=e.y-r*c+a*u}else{r=(l+i-o)/(2*l);a=Math.sqrt(Math.max(0,i/l-r*r));t.x=n.x+r*u-a*c;t.y=n.y+r*c+a*u}}else{t.x=n.x+t.r;t.y=n.y}}function intersects(e,n){var t=e.r+n.r-1e-6,r=n.x-e.x,i=n.y-e.y;return t>0&&t*t>r*r+i*i}function score(e){var n=e._,t=e.next._,r=n.r+t.r,i=(n.x*t.r+t.x*n.r)/r,a=(n.y*t.r+t.y*n.r)/r;return i*i+a*a}function Node(e){this._=e;this.next=null;this.previous=null}function packSiblingsRandom(e,n){if(!(a=(e=array(e)).length))return 0;var t,r,i,a,o,u,c,l,s,f,h;t=e[0],t.x=0,t.y=0;if(!(a>1))return t.r;r=e[1],t.x=-r.r,r.x=t.r,r.y=0;if(!(a>2))return t.r+r.r;place(r,t,i=e[2]);t=new Node(t),r=new Node(r),i=new Node(i);t.next=i.previous=r;r.next=t.previous=i;i.next=r.previous=t;e:for(c=3;c<a;++c){place(t._,r._,i=e[c]),i=new Node(i);l=r.next,s=t.previous,f=r._.r,h=t._.r;do{if(f<=h){if(intersects(l._,i._)){r=l,t.next=r,r.previous=t,--c;continue e}f+=l._.r,l=l.next}else{if(intersects(s._,i._)){t=s,t.next=r,r.previous=t,--c;continue e}h+=s._.r,s=s.previous}}while(l!==s.next);i.previous=t,i.next=r,t.next=r.previous=r=i;o=score(t);while((i=i.next)!==r)(u=score(i))<o&&(t=i,o=u);r=t.next}t=[r._],i=r;while((i=i.next)!==r)t.push(i._);i=packEncloseRandom(t,n);for(c=0;c<a;++c)t=e[c],t.x-=i.x,t.y-=i.y;return i.r}function siblings(e){packSiblingsRandom(e,lcg());return e}function defaultRadius(e){return Math.sqrt(e.value)}function index$1(){var e=null,n=1,t=1,r=constantZero;function pack(i){const a=lcg();i.x=n/2,i.y=t/2;e?i.eachBefore(radiusLeaf(e)).eachAfter(packChildrenRandom(r,.5,a)).eachBefore(translateChild(1)):i.eachBefore(radiusLeaf(defaultRadius)).eachAfter(packChildrenRandom(constantZero,1,a)).eachAfter(packChildrenRandom(r,i.r/Math.min(n,t),a)).eachBefore(translateChild(Math.min(n,t)/(2*i.r)));return i}pack.radius=function(n){return arguments.length?(e=optional(n),pack):e};pack.size=function(e){return arguments.length?(n=+e[0],t=+e[1],pack):[n,t]};pack.padding=function(e){return arguments.length?(r=\"function\"===typeof e?e:constant(+e),pack):r};return pack}function radiusLeaf(e){return function(n){n.children||(n.r=Math.max(0,+e(n)||0))}}function packChildrenRandom(e,n,t){return function(r){if(i=r.children){var i,a,o,u=i.length,c=e(r)*n||0;if(c)for(a=0;a<u;++a)i[a].r+=c;o=packSiblingsRandom(i,t);if(c)for(a=0;a<u;++a)i[a].r-=c;r.r=o+c}}}function translateChild(e){return function(n){var t=n.parent;n.r*=e;if(t){n.x=t.x+e*n.x;n.y=t.y+e*n.y}}}function roundNode(e){e.x0=Math.round(e.x0);e.y0=Math.round(e.y0);e.x1=Math.round(e.x1);e.y1=Math.round(e.y1)}function treemapDice(e,n,t,r,i){var a,o=e.children,u=-1,c=o.length,l=e.value&&(r-n)/e.value;while(++u<c){a=o[u],a.y0=t,a.y1=i;a.x0=n,a.x1=n+=a.value*l}}function partition(){var e=1,n=1,t=0,r=false;function partition(i){var a=i.height+1;i.x0=i.y0=t;i.x1=e;i.y1=n/a;i.eachBefore(positionNode(n,a));r&&i.eachBefore(roundNode);return i}function positionNode(e,n){return function(r){r.children&&treemapDice(r,r.x0,e*(r.depth+1)/n,r.x1,e*(r.depth+2)/n);var i=r.x0,a=r.y0,o=r.x1-t,u=r.y1-t;o<i&&(i=o=(i+o)/2);u<a&&(a=u=(a+u)/2);r.x0=i;r.y0=a;r.x1=o;r.y1=u}}partition.round=function(e){return arguments.length?(r=!!e,partition):r};partition.size=function(t){return arguments.length?(e=+t[0],n=+t[1],partition):[e,n]};partition.padding=function(e){return arguments.length?(t=+e,partition):t};return partition}var r={depth:-1},i={},a={};function defaultId(e){return e.id}function defaultParentId(e){return e.parentId}function stratify(){var e,n=defaultId,t=defaultParentId;function stratify(o){var u,c,l,s,f,h,d,p,y=Array.from(o),x=n,m=t,v=new Map;if(null!=e){const n=y.map(((n,t)=>normalize(e(n,t,o))));const t=n.map(parentof);const r=new Set(n).add(\"\");for(const e of t)if(!r.has(e)){r.add(e);n.push(e);t.push(parentof(e));y.push(a)}x=(e,t)=>n[t];m=(e,n)=>t[n]}for(l=0,u=y.length;l<u;++l){c=y[l],h=y[l]=new Node$1(c);if(null!=(d=x(c,l,o))&&(d+=\"\")){p=h.id=d;v.set(p,v.has(p)?i:h)}null!=(d=m(c,l,o))&&(d+=\"\")&&(h.parent=d)}for(l=0;l<u;++l){h=y[l];if(d=h.parent){f=v.get(d);if(!f)throw new Error(\"missing: \"+d);if(f===i)throw new Error(\"ambiguous: \"+d);f.children?f.children.push(h):f.children=[h];h.parent=f}else{if(s)throw new Error(\"multiple roots\");s=h}}if(!s)throw new Error(\"no root\");if(null!=e){while(s.data===a&&1===s.children.length)s=s.children[0],--u;for(let e=y.length-1;e>=0;--e){h=y[e];if(h.data!==a)break;h.data=null}}s.parent=r;s.eachBefore((function(e){e.depth=e.parent.depth+1;--u})).eachBefore(computeHeight);s.parent=null;if(u>0)throw new Error(\"cycle\");return s}stratify.id=function(e){return arguments.length?(n=optional(e),stratify):n};stratify.parentId=function(e){return arguments.length?(t=optional(e),stratify):t};stratify.path=function(n){return arguments.length?(e=optional(n),stratify):e};return stratify}function normalize(e){e=`${e}`;let n=e.length;slash(e,n-1)&&!slash(e,n-2)&&(e=e.slice(0,-1));return\"/\"===e[0]?e:`/${e}`}function parentof(e){let n=e.length;if(n<2)return\"\";while(--n>1)if(slash(e,n))break;return e.slice(0,n)}function slash(e,n){if(\"/\"===e[n]){let t=0;while(n>0&&\"\\\\\"===e[--n])++t;if(0===(1&t))return true}return false}function defaultSeparation(e,n){return e.parent===n.parent?1:2}function nextLeft(e){var n=e.children;return n?n[0]:e.t}function nextRight(e){var n=e.children;return n?n[n.length-1]:e.t}function moveSubtree(e,n,t){var r=t/(n.i-e.i);n.c-=r;n.s+=t;e.c+=r;n.z+=t;n.m+=t}function executeShifts(e){var n,t=0,r=0,i=e.children,a=i.length;while(--a>=0){n=i[a];n.z+=t;n.m+=t;t+=n.s+(r+=n.c)}}function nextAncestor(e,n,t){return e.a.parent===n.parent?e.a:t}function TreeNode(e,n){this._=e;this.parent=null;this.children=null;this.A=null;this.a=this;this.z=0;this.m=0;this.c=0;this.s=0;this.t=null;this.i=n}TreeNode.prototype=Object.create(Node$1.prototype);function treeRoot(e){var n,t,r,i,a,o=new TreeNode(e,0),u=[o];while(n=u.pop())if(r=n._.children){n.children=new Array(a=r.length);for(i=a-1;i>=0;--i){u.push(t=n.children[i]=new TreeNode(r[i],i));t.parent=n}}(o.parent=new TreeNode(null,0)).children=[o];return o}function tree(){var e=defaultSeparation,n=1,t=1,r=null;function tree(i){var a=treeRoot(i);a.eachAfter(firstWalk),a.parent.m=-a.z;a.eachBefore(secondWalk);if(r)i.eachBefore(sizeNode);else{var o=i,u=i,c=i;i.eachBefore((function(e){e.x<o.x&&(o=e);e.x>u.x&&(u=e);e.depth>c.depth&&(c=e)}));var l=o===u?1:e(o,u)/2,s=l-o.x,f=n/(u.x+l+s),h=t/(c.depth||1);i.eachBefore((function(e){e.x=(e.x+s)*f;e.y=e.depth*h}))}return i}function firstWalk(n){var t=n.children,r=n.parent.children,i=n.i?r[n.i-1]:null;if(t){executeShifts(n);var a=(t[0].z+t[t.length-1].z)/2;if(i){n.z=i.z+e(n._,i._);n.m=n.z-a}else n.z=a}else i&&(n.z=i.z+e(n._,i._));n.parent.A=apportion(n,i,n.parent.A||r[0])}function secondWalk(e){e._.x=e.z+e.parent.m;e.m+=e.parent.m}function apportion(n,t,r){if(t){var i,a=n,o=n,u=t,c=a.parent.children[0],l=a.m,s=o.m,f=u.m,h=c.m;while(u=nextRight(u),a=nextLeft(a),u&&a){c=nextLeft(c);o=nextRight(o);o.a=n;i=u.z+f-a.z-l+e(u._,a._);if(i>0){moveSubtree(nextAncestor(u,n,r),n,i);l+=i;s+=i}f+=u.m;l+=a.m;h+=c.m;s+=o.m}if(u&&!nextRight(o)){o.t=u;o.m+=f-s}if(a&&!nextLeft(c)){c.t=a;c.m+=l-h;r=n}}return r}function sizeNode(e){e.x*=n;e.y=e.depth*t}tree.separation=function(n){return arguments.length?(e=n,tree):e};tree.size=function(e){return arguments.length?(r=false,n=+e[0],t=+e[1],tree):r?null:[n,t]};tree.nodeSize=function(e){return arguments.length?(r=true,n=+e[0],t=+e[1],tree):r?[n,t]:null};return tree}function treemapSlice(e,n,t,r,i){var a,o=e.children,u=-1,c=o.length,l=e.value&&(i-t)/e.value;while(++u<c){a=o[u],a.x0=n,a.x1=r;a.y0=t,a.y1=t+=a.value*l}}var o=(1+Math.sqrt(5))/2;function squarifyRatio(e,n,t,r,i,a){var o,u,c,l,s,f,h,d,p,y,x,m=[],v=n.children,g=0,w=0,_=v.length,B=n.value;while(g<_){c=i-t,l=a-r;do{s=v[w++].value}while(!s&&w<_);f=h=s;y=Math.max(l/c,c/l)/(B*e);x=s*s*y;p=Math.max(h/x,x/f);for(;w<_;++w){s+=u=v[w].value;u<f&&(f=u);u>h&&(h=u);x=s*s*y;d=Math.max(h/x,x/f);if(d>p){s-=u;break}p=d}m.push(o={value:s,dice:c<l,children:v.slice(g,w)});o.dice?treemapDice(o,t,r,i,B?r+=l*s/B:a):treemapSlice(o,t,r,B?t+=c*s/B:i,a);B-=s,g=w}return m}var u=function custom(e){function squarify(n,t,r,i,a){squarifyRatio(e,n,t,r,i,a)}squarify.ratio=function(e){return custom((e=+e)>1?e:1)};return squarify}(o);function index(){var e=u,n=false,t=1,r=1,i=[0],a=constantZero,o=constantZero,c=constantZero,l=constantZero,s=constantZero;function treemap(e){e.x0=e.y0=0;e.x1=t;e.y1=r;e.eachBefore(positionNode);i=[0];n&&e.eachBefore(roundNode);return e}function positionNode(n){var t=i[n.depth],r=n.x0+t,u=n.y0+t,f=n.x1-t,h=n.y1-t;f<r&&(r=f=(r+f)/2);h<u&&(u=h=(u+h)/2);n.x0=r;n.y0=u;n.x1=f;n.y1=h;if(n.children){t=i[n.depth+1]=a(n)/2;r+=s(n)-t;u+=o(n)-t;f-=c(n)-t;h-=l(n)-t;f<r&&(r=f=(r+f)/2);h<u&&(u=h=(u+h)/2);e(n,r,u,f,h)}}treemap.round=function(e){return arguments.length?(n=!!e,treemap):n};treemap.size=function(e){return arguments.length?(t=+e[0],r=+e[1],treemap):[t,r]};treemap.tile=function(n){return arguments.length?(e=required(n),treemap):e};treemap.padding=function(e){return arguments.length?treemap.paddingInner(e).paddingOuter(e):treemap.paddingInner()};treemap.paddingInner=function(e){return arguments.length?(a=\"function\"===typeof e?e:constant(+e),treemap):a};treemap.paddingOuter=function(e){return arguments.length?treemap.paddingTop(e).paddingRight(e).paddingBottom(e).paddingLeft(e):treemap.paddingTop()};treemap.paddingTop=function(e){return arguments.length?(o=\"function\"===typeof e?e:constant(+e),treemap):o};treemap.paddingRight=function(e){return arguments.length?(c=\"function\"===typeof e?e:constant(+e),treemap):c};treemap.paddingBottom=function(e){return arguments.length?(l=\"function\"===typeof e?e:constant(+e),treemap):l};treemap.paddingLeft=function(e){return arguments.length?(s=\"function\"===typeof e?e:constant(+e),treemap):s};return treemap}function binary(e,n,t,r,i){var a,o,u=e.children,c=u.length,l=new Array(c+1);for(l[0]=o=a=0;a<c;++a)l[a+1]=o+=u[a].value;partition(0,c,e.value,n,t,r,i);function partition(e,n,t,r,i,a,o){if(e>=n-1){var c=u[e];c.x0=r,c.y0=i;c.x1=a,c.y1=o}else{var s=l[e],f=t/2+s,h=e+1,d=n-1;while(h<d){var p=h+d>>>1;l[p]<f?h=p+1:d=p}f-l[h-1]<l[h]-f&&e+1<h&&--h;var y=l[h]-s,x=t-y;if(a-r>o-i){var m=t?(r*x+a*y)/t:a;partition(e,h,y,r,i,m,o);partition(h,n,x,m,i,a,o)}else{var v=t?(i*x+o*y)/t:o;partition(e,h,y,r,i,a,v);partition(h,n,x,r,v,a,o)}}}}function sliceDice(e,n,t,r,i){(1&e.depth?treemapSlice:treemapDice)(e,n,t,r,i)}var c=function custom(e){function resquarify(n,t,r,i,a){if((o=n._squarify)&&o.ratio===e){var o,u,c,l,s,f=-1,h=o.length,d=n.value;while(++f<h){u=o[f],c=u.children;for(l=u.value=0,s=c.length;l<s;++l)u.value+=c[l].value;u.dice?treemapDice(u,t,r,i,d?r+=(a-r)*u.value/d:a):treemapSlice(u,t,r,d?t+=(i-t)*u.value/d:i,a);d-=u.value}}else{n._squarify=o=squarifyRatio(e,n,t,r,i,a);o.ratio=e}}resquarify.ratio=function(e){return custom((e=+e)>1?e:1)};return resquarify}(o);export{Node$1 as Node,cluster,hierarchy,index$1 as pack,enclose as packEnclose,siblings as packSiblings,partition,stratify,tree,index as treemap,binary as treemapBinary,treemapDice,c as treemapResquarify,treemapSlice,sliceDice as treemapSliceDice,u as treemapSquarify};\n\n"
  },
  {
    "path": "vendor/javascript/d3-interpolate.js",
    "content": "// d3-interpolate@3.0.1 downloaded from https://ga.jspm.io/npm:d3-interpolate@3.0.1/src/index.js\n\nimport{rgb as n,color as r,hsl as t,lab as e,hcl as a,cubehelix as o}from\"d3-color\";function basis(n,r,t,e,a){var o=n*n,u=o*n;return((1-3*n+3*o-u)*r+(4-6*o+3*u)*t+(1+3*n+3*o-3*u)*e+u*a)/6}function basis$1(n){var r=n.length-1;return function(t){var e=t<=0?t=0:t>=1?(t=1,r-1):Math.floor(t*r),a=n[e],o=n[e+1],u=e>0?n[e-1]:2*a-o,i=e<r-1?n[e+2]:2*o-a;return basis((t-e/r)*r,u,a,o,i)}}function basisClosed(n){var r=n.length;return function(t){var e=Math.floor(((t%=1)<0?++t:t)*r),a=n[(e+r-1)%r],o=n[e%r],u=n[(e+1)%r],i=n[(e+2)%r];return basis((t-e/r)*r,a,o,u,i)}}var constant=n=>()=>n;function linear(n,r){return function(t){return n+t*r}}function exponential(n,r,t){return n=Math.pow(n,t),r=Math.pow(r,t)-n,t=1/t,function(e){return Math.pow(n+e*r,t)}}function hue$1(n,r){var t=r-n;return t?linear(n,t>180||t<-180?t-360*Math.round(t/360):t):constant(isNaN(n)?r:n)}function gamma(n){return 1===(n=+n)?nogamma:function(r,t){return t-r?exponential(r,t,n):constant(isNaN(r)?t:r)}}function nogamma(n,r){var t=r-n;return t?linear(n,t):constant(isNaN(n)?r:n)}var u=function rgbGamma(r){var t=gamma(r);function rgb(r,e){var a=t((r=n(r)).r,(e=n(e)).r),o=t(r.g,e.g),u=t(r.b,e.b),i=nogamma(r.opacity,e.opacity);return function(n){r.r=a(n);r.g=o(n);r.b=u(n);r.opacity=i(n);return r+\"\"}}rgb.gamma=rgbGamma;return rgb}(1);function rgbSpline(r){return function(t){var e,a,o=t.length,u=new Array(o),i=new Array(o),s=new Array(o);for(e=0;e<o;++e){a=n(t[e]);u[e]=a.r||0;i[e]=a.g||0;s[e]=a.b||0}u=r(u);i=r(i);s=r(s);a.opacity=1;return function(n){a.r=u(n);a.g=i(n);a.b=s(n);return a+\"\"}}}var i=rgbSpline(basis$1);var s=rgbSpline(basisClosed);function numberArray(n,r){r||(r=[]);var t,e=n?Math.min(r.length,n.length):0,a=r.slice();return function(o){for(t=0;t<e;++t)a[t]=n[t]*(1-o)+r[t]*o;return a}}function isNumberArray(n){return ArrayBuffer.isView(n)&&!(n instanceof DataView)}function array(n,r){return(isNumberArray(r)?numberArray:genericArray)(n,r)}function genericArray(n,r){var t,e=r?r.length:0,a=n?Math.min(e,n.length):0,o=new Array(a),u=new Array(e);for(t=0;t<a;++t)o[t]=value(n[t],r[t]);for(;t<e;++t)u[t]=r[t];return function(n){for(t=0;t<a;++t)u[t]=o[t](n);return u}}function date(n,r){var t=new Date;return n=+n,r=+r,function(e){return t.setTime(n*(1-e)+r*e),t}}function number(n,r){return n=+n,r=+r,function(t){return n*(1-t)+r*t}}function object(n,r){var t,e={},a={};null!==n&&\"object\"===typeof n||(n={});null!==r&&\"object\"===typeof r||(r={});for(t in r)t in n?e[t]=value(n[t],r[t]):a[t]=r[t];return function(n){for(t in e)a[t]=e[t](n);return a}}var l=/[-+]?(?:\\d+\\.?\\d*|\\.?\\d+)(?:[eE][-+]?\\d+)?/g,c=new RegExp(l.source,\"g\");function zero(n){return function(){return n}}function one(n){return function(r){return n(r)+\"\"}}function string(n,r){var t,e,a,o=l.lastIndex=c.lastIndex=0,u=-1,i=[],s=[];n+=\"\",r+=\"\";while((t=l.exec(n))&&(e=c.exec(r))){if((a=e.index)>o){a=r.slice(o,a);i[u]?i[u]+=a:i[++u]=a}if((t=t[0])===(e=e[0]))i[u]?i[u]+=e:i[++u]=e;else{i[++u]=null;s.push({i:u,x:number(t,e)})}o=c.lastIndex}if(o<r.length){a=r.slice(o);i[u]?i[u]+=a:i[++u]=a}return i.length<2?s[0]?one(s[0].x):zero(r):(r=s.length,function(n){for(var t,e=0;e<r;++e)i[(t=s[e]).i]=t.x(n);return i.join(\"\")})}function value(n,t){var e,a=typeof t;return null==t||\"boolean\"===a?constant(t):(\"number\"===a?number:\"string\"===a?(e=r(t))?(t=e,u):string:t instanceof r?u:t instanceof Date?date:isNumberArray(t)?numberArray:Array.isArray(t)?genericArray:\"function\"!==typeof t.valueOf&&\"function\"!==typeof t.toString||isNaN(t)?object:number)(n,t)}function discrete(n){var r=n.length;return function(t){return n[Math.max(0,Math.min(r-1,Math.floor(t*r)))]}}function hue(n,r){var t=hue$1(+n,+r);return function(n){var r=t(n);return r-360*Math.floor(r/360)}}function round(n,r){return n=+n,r=+r,function(t){return Math.round(n*(1-t)+r*t)}}var f=180/Math.PI;var h={translateX:0,translateY:0,rotate:0,skewX:0,scaleX:1,scaleY:1};function decompose(n,r,t,e,a,o){var u,i,s;(u=Math.sqrt(n*n+r*r))&&(n/=u,r/=u);(s=n*t+r*e)&&(t-=n*s,e-=r*s);(i=Math.sqrt(t*t+e*e))&&(t/=i,e/=i,s/=i);n*e<r*t&&(n=-n,r=-r,s=-s,u=-u);return{translateX:a,translateY:o,rotate:Math.atan2(r,n)*f,skewX:Math.atan(s)*f,scaleX:u,scaleY:i}}var p;function parseCss(n){const r=new(\"function\"===typeof DOMMatrix?DOMMatrix:WebKitCSSMatrix)(n+\"\");return r.isIdentity?h:decompose(r.a,r.b,r.c,r.d,r.e,r.f)}function parseSvg(n){if(null==n)return h;p||(p=document.createElementNS(\"http://www.w3.org/2000/svg\",\"g\"));p.setAttribute(\"transform\",n);if(!(n=p.transform.baseVal.consolidate()))return h;n=n.matrix;return decompose(n.a,n.b,n.c,n.d,n.e,n.f)}function interpolateTransform(n,r,t,e){function pop(n){return n.length?n.pop()+\" \":\"\"}function translate(n,e,a,o,u,i){if(n!==a||e!==o){var s=u.push(\"translate(\",null,r,null,t);i.push({i:s-4,x:number(n,a)},{i:s-2,x:number(e,o)})}else(a||o)&&u.push(\"translate(\"+a+r+o+t)}function rotate(n,r,t,a){if(n!==r){n-r>180?r+=360:r-n>180&&(n+=360);a.push({i:t.push(pop(t)+\"rotate(\",null,e)-2,x:number(n,r)})}else r&&t.push(pop(t)+\"rotate(\"+r+e)}function skewX(n,r,t,a){n!==r?a.push({i:t.push(pop(t)+\"skewX(\",null,e)-2,x:number(n,r)}):r&&t.push(pop(t)+\"skewX(\"+r+e)}function scale(n,r,t,e,a,o){if(n!==t||r!==e){var u=a.push(pop(a)+\"scale(\",null,\",\",null,\")\");o.push({i:u-4,x:number(n,t)},{i:u-2,x:number(r,e)})}else 1===t&&1===e||a.push(pop(a)+\"scale(\"+t+\",\"+e+\")\")}return function(r,t){var e=[],a=[];r=n(r),t=n(t);translate(r.translateX,r.translateY,t.translateX,t.translateY,e,a);rotate(r.rotate,t.rotate,e,a);skewX(r.skewX,t.skewX,e,a);scale(r.scaleX,r.scaleY,t.scaleX,t.scaleY,e,a);r=t=null;return function(n){var r,t=-1,o=a.length;while(++t<o)e[(r=a[t]).i]=r.x(n);return e.join(\"\")}}}var m=interpolateTransform(parseCss,\"px, \",\"px)\",\"deg)\");var g=interpolateTransform(parseSvg,\", \",\")\",\")\");var b=1e-12;function cosh(n){return((n=Math.exp(n))+1/n)/2}function sinh(n){return((n=Math.exp(n))-1/n)/2}function tanh(n){return((n=Math.exp(2*n))-1)/(n+1)}var v=function zoomRho(n,r,t){function zoom(e,a){var o,u,i=e[0],s=e[1],l=e[2],c=a[0],f=a[1],h=a[2],p=c-i,m=f-s,g=p*p+m*m;if(g<b){u=Math.log(h/l)/n;o=function(r){return[i+r*p,s+r*m,l*Math.exp(n*r*u)]}}else{var v=Math.sqrt(g),y=(h*h-l*l+t*g)/(2*l*r*v),x=(h*h-l*l-t*g)/(2*h*r*v),M=Math.log(Math.sqrt(y*y+1)-y),d=Math.log(Math.sqrt(x*x+1)-x);u=(d-M)/n;o=function(t){var e=t*u,a=cosh(M),o=l/(r*v)*(a*tanh(n*e+M)-sinh(M));return[i+o*p,s+o*m,l*a/cosh(n*e+M)]}}o.duration=1e3*u*n/Math.SQRT2;return o}zoom.rho=function(n){var r=Math.max(.001,+n),t=r*r,e=t*t;return zoomRho(r,t,e)};return zoom}(Math.SQRT2,2,4);function hsl(n){return function(r,e){var a=n((r=t(r)).h,(e=t(e)).h),o=nogamma(r.s,e.s),u=nogamma(r.l,e.l),i=nogamma(r.opacity,e.opacity);return function(n){r.h=a(n);r.s=o(n);r.l=u(n);r.opacity=i(n);return r+\"\"}}}var y=hsl(hue$1);var x=hsl(nogamma);function lab(n,r){var t=nogamma((n=e(n)).l,(r=e(r)).l),a=nogamma(n.a,r.a),o=nogamma(n.b,r.b),u=nogamma(n.opacity,r.opacity);return function(r){n.l=t(r);n.a=a(r);n.b=o(r);n.opacity=u(r);return n+\"\"}}function hcl(n){return function(r,t){var e=n((r=a(r)).h,(t=a(t)).h),o=nogamma(r.c,t.c),u=nogamma(r.l,t.l),i=nogamma(r.opacity,t.opacity);return function(n){r.h=e(n);r.c=o(n);r.l=u(n);r.opacity=i(n);return r+\"\"}}}var M=hcl(hue$1);var d=hcl(nogamma);function cubehelix(n){return function cubehelixGamma(r){r=+r;function cubehelix(t,e){var a=n((t=o(t)).h,(e=o(e)).h),u=nogamma(t.s,e.s),i=nogamma(t.l,e.l),s=nogamma(t.opacity,e.opacity);return function(n){t.h=a(n);t.s=u(n);t.l=i(Math.pow(n,r));t.opacity=s(n);return t+\"\"}}cubehelix.gamma=cubehelixGamma;return cubehelix}(1)}var w=cubehelix(hue$1);var A=cubehelix(nogamma);function piecewise(n,r){void 0===r&&(r=n,n=value);var t=0,e=r.length-1,a=r[0],o=new Array(e<0?0:e);while(t<e)o[t]=n(a,a=r[++t]);return function(n){var r=Math.max(0,Math.min(e-1,Math.floor(n*=e)));return o[r](n-r)}}function quantize(n,r){var t=new Array(r);for(var e=0;e<r;++e)t[e]=n(e/(r-1));return t}export{value as interpolate,array as interpolateArray,basis$1 as interpolateBasis,basisClosed as interpolateBasisClosed,w as interpolateCubehelix,A as interpolateCubehelixLong,date as interpolateDate,discrete as interpolateDiscrete,M as interpolateHcl,d as interpolateHclLong,y as interpolateHsl,x as interpolateHslLong,hue as interpolateHue,lab as interpolateLab,number as interpolateNumber,numberArray as interpolateNumberArray,object as interpolateObject,u as interpolateRgb,i as interpolateRgbBasis,s as interpolateRgbBasisClosed,round as interpolateRound,string as interpolateString,m as interpolateTransformCss,g as interpolateTransformSvg,v as interpolateZoom,piecewise,quantize};\n\n"
  },
  {
    "path": "vendor/javascript/d3-path.js",
    "content": "// d3-path@1.0.9 downloaded from https://ga.jspm.io/npm:d3-path@1.0.9/dist/d3-path.js\n\nvar t=\"undefined\"!==typeof globalThis?globalThis:\"undefined\"!==typeof self?self:global;var i={};(function(t,h){h(i)})(i,(function(i){var h=Math.PI,s=2*h,_=1e-6,n=s-_;function Path(){(this||t)._x0=(this||t)._y0=(this||t)._x1=(this||t)._y1=null;(this||t)._=\"\"}function path(){return new Path}Path.prototype=path.prototype={constructor:Path,moveTo:function(i,h){(this||t)._+=\"M\"+((this||t)._x0=(this||t)._x1=+i)+\",\"+((this||t)._y0=(this||t)._y1=+h)},closePath:function(){if(null!==(this||t)._x1){(this||t)._x1=(this||t)._x0,(this||t)._y1=(this||t)._y0;(this||t)._+=\"Z\"}},lineTo:function(i,h){(this||t)._+=\"L\"+((this||t)._x1=+i)+\",\"+((this||t)._y1=+h)},quadraticCurveTo:function(i,h,s,_){(this||t)._+=\"Q\"+ +i+\",\"+ +h+\",\"+((this||t)._x1=+s)+\",\"+((this||t)._y1=+_)},bezierCurveTo:function(i,h,s,_,n,a){(this||t)._+=\"C\"+ +i+\",\"+ +h+\",\"+ +s+\",\"+ +_+\",\"+((this||t)._x1=+n)+\",\"+((this||t)._y1=+a)},arcTo:function(i,s,n,a,e){i=+i,s=+s,n=+n,a=+a,e=+e;var o=(this||t)._x1,r=(this||t)._y1,u=n-i,f=a-s,c=o-i,l=r-s,x=c*c+l*l;if(e<0)throw new Error(\"negative radius: \"+e);if(null===(this||t)._x1)(this||t)._+=\"M\"+((this||t)._x1=i)+\",\"+((this||t)._y1=s);else if(x>_)if(Math.abs(l*u-f*c)>_&&e){var y=n-o,M=a-r,p=u*u+f*f,v=y*y+M*M,d=Math.sqrt(p),b=Math.sqrt(x),P=e*Math.tan((h-Math.acos((p+x-v)/(2*d*b)))/2),T=P/b,g=P/d;Math.abs(T-1)>_&&((this||t)._+=\"L\"+(i+T*c)+\",\"+(s+T*l));(this||t)._+=\"A\"+e+\",\"+e+\",0,0,\"+ +(l*y>c*M)+\",\"+((this||t)._x1=i+g*u)+\",\"+((this||t)._y1=s+g*f)}else(this||t)._+=\"L\"+((this||t)._x1=i)+\",\"+((this||t)._y1=s);else;},arc:function(i,a,e,o,r,u){i=+i,a=+a,e=+e,u=!!u;var f=e*Math.cos(o),c=e*Math.sin(o),l=i+f,x=a+c,y=1^u,M=u?o-r:r-o;if(e<0)throw new Error(\"negative radius: \"+e);null===(this||t)._x1?(this||t)._+=\"M\"+l+\",\"+x:(Math.abs((this||t)._x1-l)>_||Math.abs((this||t)._y1-x)>_)&&((this||t)._+=\"L\"+l+\",\"+x);if(e){M<0&&(M=M%s+s);M>n?(this||t)._+=\"A\"+e+\",\"+e+\",0,1,\"+y+\",\"+(i-f)+\",\"+(a-c)+\"A\"+e+\",\"+e+\",0,1,\"+y+\",\"+((this||t)._x1=l)+\",\"+((this||t)._y1=x):M>_&&((this||t)._+=\"A\"+e+\",\"+e+\",0,\"+ +(M>=h)+\",\"+y+\",\"+((this||t)._x1=i+e*Math.cos(r))+\",\"+((this||t)._y1=a+e*Math.sin(r)))}},rect:function(i,h,s,_){(this||t)._+=\"M\"+((this||t)._x0=(this||t)._x1=+i)+\",\"+((this||t)._y0=(this||t)._y1=+h)+\"h\"+ +s+\"v\"+ +_+\"h\"+-s+\"Z\"},toString:function(){return(this||t)._}};i.path=path;Object.defineProperty(i,\"__esModule\",{value:true})}));const h=i.path,s=i.__esModule;export default i;export{s as __esModule,h as path};\n\n"
  },
  {
    "path": "vendor/javascript/d3-polygon.js",
    "content": "// d3-polygon@3.0.1 downloaded from https://ga.jspm.io/npm:d3-polygon@3.0.1/src/index.js\n\nfunction area(n){var r,e=-1,t=n.length,o=n[t-1],l=0;while(++e<t){r=o;o=n[e];l+=r[1]*o[0]-r[0]*o[1]}return l/2}function centroid(n){var r,e,t=-1,o=n.length,l=0,u=0,a=n[o-1],h=0;while(++t<o){r=a;a=n[t];h+=e=r[0]*a[1]-a[0]*r[1];l+=(r[0]+a[0])*e;u+=(r[1]+a[1])*e}return h*=3,[l/h,u/h]}function cross(n,r,e){return(r[0]-n[0])*(e[1]-n[1])-(r[1]-n[1])*(e[0]-n[0])}function lexicographicOrder(n,r){return n[0]-r[0]||n[1]-r[1]}function computeUpperHullIndexes(n){const r=n.length,e=[0,1];let t,o=2;for(t=2;t<r;++t){while(o>1&&cross(n[e[o-2]],n[e[o-1]],n[t])<=0)--o;e[o++]=t}return e.slice(0,o)}function hull(n){if((e=n.length)<3)return null;var r,e,t=new Array(e),o=new Array(e);for(r=0;r<e;++r)t[r]=[+n[r][0],+n[r][1],r];t.sort(lexicographicOrder);for(r=0;r<e;++r)o[r]=[t[r][0],-t[r][1]];var l=computeUpperHullIndexes(t),u=computeUpperHullIndexes(o);var a=u[0]===l[0],h=u[u.length-1]===l[l.length-1],i=[];for(r=l.length-1;r>=0;--r)i.push(n[t[l[r]][2]]);for(r=+a;r<u.length-h;++r)i.push(n[t[u[r]][2]]);return i}function contains(n,r){var e,t,o=n.length,l=n[o-1],u=r[0],a=r[1],h=l[0],i=l[1],c=false;for(var s=0;s<o;++s){l=n[s],e=l[0],t=l[1];t>a!==i>a&&u<(h-e)*(a-t)/(i-t)+e&&(c=!c);h=e,i=t}return c}function length(n){var r,e,t=-1,o=n.length,l=n[o-1],u=l[0],a=l[1],h=0;while(++t<o){r=u;e=a;l=n[t];u=l[0];a=l[1];r-=u;e-=a;h+=Math.hypot(r,e)}return h}export{area as polygonArea,centroid as polygonCentroid,contains as polygonContains,hull as polygonHull,length as polygonLength};\n\n"
  },
  {
    "path": "vendor/javascript/d3-quadtree.js",
    "content": "// d3-quadtree@3.0.1 downloaded from https://ga.jspm.io/npm:d3-quadtree@3.0.1/src/index.js\n\nfunction tree_add(t){const e=+this._x.call(null,t),i=+this._y.call(null,t);return add(this.cover(e,i),e,i,t)}function add(t,e,i,r){if(isNaN(e)||isNaN(i))return t;var n,h,s,a,o,u,l,_,d,f=t._root,y={data:r},x=t._x0,c=t._y0,v=t._x1,w=t._y1;if(!f)return t._root=y,t;while(f.length){(u=e>=(h=(x+v)/2))?x=h:v=h;(l=i>=(s=(c+w)/2))?c=s:w=s;if(n=f,!(f=f[_=l<<1|u]))return n[_]=y,t}a=+t._x.call(null,f.data);o=+t._y.call(null,f.data);if(e===a&&i===o)return y.next=f,n?n[_]=y:t._root=y,t;do{n=n?n[_]=new Array(4):t._root=new Array(4);(u=e>=(h=(x+v)/2))?x=h:v=h;(l=i>=(s=(c+w)/2))?c=s:w=s}while((_=l<<1|u)===(d=(o>=s)<<1|a>=h));return n[d]=f,n[_]=y,t}function addAll(t){var e,i,r,n,h=t.length,s=new Array(h),a=new Array(h),o=Infinity,u=Infinity,l=-Infinity,_=-Infinity;for(i=0;i<h;++i)if(!isNaN(r=+this._x.call(null,e=t[i]))&&!isNaN(n=+this._y.call(null,e))){s[i]=r;a[i]=n;r<o&&(o=r);r>l&&(l=r);n<u&&(u=n);n>_&&(_=n)}if(o>l||u>_)return this;this.cover(o,u).cover(l,_);for(i=0;i<h;++i)add(this,s[i],a[i],t[i]);return this}function tree_cover(t,e){if(isNaN(t=+t)||isNaN(e=+e))return this;var i=this._x0,r=this._y0,n=this._x1,h=this._y1;if(isNaN(i)){n=(i=Math.floor(t))+1;h=(r=Math.floor(e))+1}else{var s,a,o=n-i||1,u=this._root;while(i>t||t>=n||r>e||e>=h){a=(e<r)<<1|t<i;s=new Array(4),s[a]=u,u=s,o*=2;switch(a){case 0:n=i+o,h=r+o;break;case 1:i=n-o,h=r+o;break;case 2:n=i+o,r=h-o;break;case 3:i=n-o,r=h-o;break}}this._root&&this._root.length&&(this._root=u)}this._x0=i;this._y0=r;this._x1=n;this._y1=h;return this}function tree_data(){var t=[];this.visit((function(e){if(!e.length)do{t.push(e.data)}while(e=e.next)}));return t}function tree_extent(t){return arguments.length?this.cover(+t[0][0],+t[0][1]).cover(+t[1][0],+t[1][1]):isNaN(this._x0)?void 0:[[this._x0,this._y0],[this._x1,this._y1]]}function Quad(t,e,i,r,n){this.node=t;this.x0=e;this.y0=i;this.x1=r;this.y1=n}function tree_find(t,e,i){var r,n,h,s,a,o,u,l=this._x0,_=this._y0,d=this._x1,f=this._y1,y=[],x=this._root;x&&y.push(new Quad(x,l,_,d,f));if(null==i)i=Infinity;else{l=t-i,_=e-i;d=t+i,f=e+i;i*=i}while(o=y.pop())if(!(!(x=o.node)||(n=o.x0)>d||(h=o.y0)>f||(s=o.x1)<l||(a=o.y1)<_))if(x.length){var c=(n+s)/2,v=(h+a)/2;y.push(new Quad(x[3],c,v,s,a),new Quad(x[2],n,v,c,a),new Quad(x[1],c,h,s,v),new Quad(x[0],n,h,c,v));if(u=(e>=v)<<1|t>=c){o=y[y.length-1];y[y.length-1]=y[y.length-1-u];y[y.length-1-u]=o}}else{var w=t-+this._x.call(null,x.data),p=e-+this._y.call(null,x.data),N=w*w+p*p;if(N<i){var g=Math.sqrt(i=N);l=t-g,_=e-g;d=t+g,f=e+g;r=x.data}}return r}function tree_remove(t){if(isNaN(h=+this._x.call(null,t))||isNaN(s=+this._y.call(null,t)))return this;var e,i,r,n,h,s,a,o,u,l,_,d,f=this._root,y=this._x0,x=this._y0,c=this._x1,v=this._y1;if(!f)return this;if(f.length)while(true){(u=h>=(a=(y+c)/2))?y=a:c=a;(l=s>=(o=(x+v)/2))?x=o:v=o;if(!(e=f,f=f[_=l<<1|u]))return this;if(!f.length)break;(e[_+1&3]||e[_+2&3]||e[_+3&3])&&(i=e,d=_)}while(f.data!==t)if(!(r=f,f=f.next))return this;(n=f.next)&&delete f.next;if(r)return n?r.next=n:delete r.next,this;if(!e)return this._root=n,this;n?e[_]=n:delete e[_];(f=e[0]||e[1]||e[2]||e[3])&&f===(e[3]||e[2]||e[1]||e[0])&&!f.length&&(i?i[d]=f:this._root=f);return this}function removeAll(t){for(var e=0,i=t.length;e<i;++e)this.remove(t[e]);return this}function tree_root(){return this._root}function tree_size(){var t=0;this.visit((function(e){if(!e.length)do{++t}while(e=e.next)}));return t}function tree_visit(t){var e,i,r,n,h,s,a=[],o=this._root;o&&a.push(new Quad(o,this._x0,this._y0,this._x1,this._y1));while(e=a.pop())if(!t(o=e.node,r=e.x0,n=e.y0,h=e.x1,s=e.y1)&&o.length){var u=(r+h)/2,l=(n+s)/2;(i=o[3])&&a.push(new Quad(i,u,l,h,s));(i=o[2])&&a.push(new Quad(i,r,l,u,s));(i=o[1])&&a.push(new Quad(i,u,n,h,l));(i=o[0])&&a.push(new Quad(i,r,n,u,l))}return this}function tree_visitAfter(t){var e,i=[],r=[];this._root&&i.push(new Quad(this._root,this._x0,this._y0,this._x1,this._y1));while(e=i.pop()){var n=e.node;if(n.length){var h,s=e.x0,a=e.y0,o=e.x1,u=e.y1,l=(s+o)/2,_=(a+u)/2;(h=n[0])&&i.push(new Quad(h,s,a,l,_));(h=n[1])&&i.push(new Quad(h,l,a,o,_));(h=n[2])&&i.push(new Quad(h,s,_,l,u));(h=n[3])&&i.push(new Quad(h,l,_,o,u))}r.push(e)}while(e=r.pop())t(e.node,e.x0,e.y0,e.x1,e.y1);return this}function defaultX(t){return t[0]}function tree_x(t){return arguments.length?(this._x=t,this):this._x}function defaultY(t){return t[1]}function tree_y(t){return arguments.length?(this._y=t,this):this._y}function quadtree(t,e,i){var r=new Quadtree(null==e?defaultX:e,null==i?defaultY:i,NaN,NaN,NaN,NaN);return null==t?r:r.addAll(t)}function Quadtree(t,e,i,r,n,h){this._x=t;this._y=e;this._x0=i;this._y0=r;this._x1=n;this._y1=h;this._root=void 0}function leaf_copy(t){var e={data:t.data},i=e;while(t=t.next)i=i.next={data:t.data};return e}var t=quadtree.prototype=Quadtree.prototype;t.copy=function(){var t,e,i=new Quadtree(this._x,this._y,this._x0,this._y0,this._x1,this._y1),r=this._root;if(!r)return i;if(!r.length)return i._root=leaf_copy(r),i;t=[{source:r,target:i._root=new Array(4)}];while(r=t.pop())for(var n=0;n<4;++n)(e=r.source[n])&&(e.length?t.push({source:e,target:r.target[n]=new Array(4)}):r.target[n]=leaf_copy(e));return i};t.add=tree_add;t.addAll=addAll;t.cover=tree_cover;t.data=tree_data;t.extent=tree_extent;t.find=tree_find;t.remove=tree_remove;t.removeAll=removeAll;t.root=tree_root;t.size=tree_size;t.visit=tree_visit;t.visitAfter=tree_visitAfter;t.x=tree_x;t.y=tree_y;export{quadtree};\n\n"
  },
  {
    "path": "vendor/javascript/d3-random.js",
    "content": "// d3-random@3.0.1 downloaded from https://ga.jspm.io/npm:d3-random@3.0.1/src/index.js\n\nvar r=Math.random;var n=function sourceRandomUniform(r){function randomUniform(n,o){n=null==n?0:+n;o=null==o?1:+o;1===arguments.length?(o=n,n=0):o-=n;return function(){return r()*o+n}}randomUniform.source=sourceRandomUniform;return randomUniform}(r);var o=function sourceRandomInt(r){function randomInt(n,o){arguments.length<2&&(o=n,n=0);n=Math.floor(n);o=Math.floor(o)-n;return function(){return Math.floor(r()*o+n)}}randomInt.source=sourceRandomInt;return randomInt}(r);var a=function sourceRandomNormal(r){function randomNormal(n,o){var a,u;n=null==n?0:+n;o=null==o?1:+o;return function(){var t;if(null!=a)t=a,a=null;else do{a=2*r()-1;t=2*r()-1;u=a*a+t*t}while(!u||u>1);return n+o*t*Math.sqrt(-2*Math.log(u)/u)}}randomNormal.source=sourceRandomNormal;return randomNormal}(r);var u=function sourceRandomLogNormal(r){var n=a.source(r);function randomLogNormal(){var r=n.apply(this,arguments);return function(){return Math.exp(r())}}randomLogNormal.source=sourceRandomLogNormal;return randomLogNormal}(r);var t=function sourceRandomIrwinHall(r){function randomIrwinHall(n){return(n=+n)<=0?()=>0:function(){for(var o=0,a=n;a>1;--a)o+=r();return o+a*r()}}randomIrwinHall.source=sourceRandomIrwinHall;return randomIrwinHall}(r);var e=function sourceRandomBates(r){var n=t.source(r);function randomBates(o){if(0===(o=+o))return r;var a=n(o);return function(){return a()/o}}randomBates.source=sourceRandomBates;return randomBates}(r);var i=function sourceRandomExponential(r){function randomExponential(n){return function(){return-Math.log1p(-r())/n}}randomExponential.source=sourceRandomExponential;return randomExponential}(r);var m=function sourceRandomPareto(r){function randomPareto(n){if((n=+n)<0)throw new RangeError(\"invalid alpha\");n=1/-n;return function(){return Math.pow(1-r(),n)}}randomPareto.source=sourceRandomPareto;return randomPareto}(r);var c=function sourceRandomBernoulli(r){function randomBernoulli(n){if((n=+n)<0||n>1)throw new RangeError(\"invalid p\");return function(){return Math.floor(r()+n)}}randomBernoulli.source=sourceRandomBernoulli;return randomBernoulli}(r);var l=function sourceRandomGeometric(r){function randomGeometric(n){if((n=+n)<0||n>1)throw new RangeError(\"invalid p\");if(0===n)return()=>Infinity;if(1===n)return()=>1;n=Math.log1p(-n);return function(){return 1+Math.floor(Math.log1p(-r())/n)}}randomGeometric.source=sourceRandomGeometric;return randomGeometric}(r);var d=function sourceRandomGamma(r){var n=a.source(r)();function randomGamma(o,a){if((o=+o)<0)throw new RangeError(\"invalid k\");if(0===o)return()=>0;a=null==a?1:+a;if(1===o)return()=>-Math.log1p(-r())*a;var u=(o<1?o+1:o)-1/3,t=1/(3*Math.sqrt(u)),e=o<1?()=>Math.pow(r(),1/o):()=>1;return function(){do{do{var o=n(),i=1+t*o}while(i<=0);i*=i*i;var m=1-r()}while(m>=1-.0331*o*o*o*o&&Math.log(m)>=.5*o*o+u*(1-i+Math.log(i)));return u*i*e()*a}}randomGamma.source=sourceRandomGamma;return randomGamma}(r);var s=function sourceRandomBeta(r){var n=d.source(r);function randomBeta(r,o){var a=n(r),u=n(o);return function(){var r=a();return 0===r?0:r/(r+u())}}randomBeta.source=sourceRandomBeta;return randomBeta}(r);var f=function sourceRandomBinomial(r){var n=l.source(r),o=s.source(r);function randomBinomial(r,a){r=+r;return(a=+a)>=1?()=>r:a<=0?()=>0:function(){var u=0,t=r,e=a;while(t*e>16&&t*(1-e)>16){var i=Math.floor((t+1)*e),m=o(i,t-i+1)();if(m<=e){u+=i;t-=i;e=(e-m)/(1-m)}else{t=i-1;e/=m}}var c=e<.5,l=c?e:1-e,d=n(l);for(var s=d(),f=0;s<=t;++f)s+=d();return u+(c?f:t-f)}}randomBinomial.source=sourceRandomBinomial;return randomBinomial}(r);var h=function sourceRandomWeibull(r){function randomWeibull(n,o,a){var u;if(0===(n=+n))u=r=>-Math.log(r);else{n=1/n;u=r=>Math.pow(r,n)}o=null==o?0:+o;a=null==a?1:+a;return function(){return o+a*u(-Math.log1p(-r()))}}randomWeibull.source=sourceRandomWeibull;return randomWeibull}(r);var v=function sourceRandomCauchy(r){function randomCauchy(n,o){n=null==n?0:+n;o=null==o?1:+o;return function(){return n+o*Math.tan(Math.PI*r())}}randomCauchy.source=sourceRandomCauchy;return randomCauchy}(r);var R=function sourceRandomLogistic(r){function randomLogistic(n,o){n=null==n?0:+n;o=null==o?1:+o;return function(){var a=r();return n+o*Math.log(a/(1-a))}}randomLogistic.source=sourceRandomLogistic;return randomLogistic}(r);var g=function sourceRandomPoisson(r){var n=d.source(r),o=f.source(r);function randomPoisson(a){return function(){var u=0,t=a;while(t>16){var e=Math.floor(.875*t),i=n(e)();if(i>t)return u+o(e-1,t/i)();u+=e;t-=i}for(var m=-Math.log1p(-r()),c=0;m<=t;++c)m-=Math.log1p(-r());return u+c}}randomPoisson.source=sourceRandomPoisson;return randomPoisson}(r);const M=1664525;const B=1013904223;const p=1/4294967296;function lcg(r=Math.random()){let n=0|(0<=r&&r<1?r/p:Math.abs(r));return()=>(n=M*n+B|0,p*(n>>>0))}export{e as randomBates,c as randomBernoulli,s as randomBeta,f as randomBinomial,v as randomCauchy,i as randomExponential,d as randomGamma,l as randomGeometric,o as randomInt,t as randomIrwinHall,lcg as randomLcg,u as randomLogNormal,R as randomLogistic,a as randomNormal,m as randomPareto,g as randomPoisson,n as randomUniform,h as randomWeibull};\n\n"
  },
  {
    "path": "vendor/javascript/d3-sankey.js",
    "content": "// d3-sankey@0.12.3 downloaded from https://ga.jspm.io/npm:d3-sankey@0.12.3/dist/d3-sankey.js\n\nimport e from\"d3-array\";import t from\"d3-shape\";var n={};(function(o,r){r(n,e,t)})(n,(function(e,t,n){function targetDepth(e){return e.target.depth}function left(e){return e.depth}function right(e,t){return t-1-e.height}function justify(e,t){return e.sourceLinks.length?e.depth:t-1}function center(e){return e.targetLinks.length?e.depth:e.sourceLinks.length?t.min(e.sourceLinks,targetDepth)-1:0}function constant(e){return function(){return e}}function ascendingSourceBreadth(e,t){return ascendingBreadth(e.source,t.source)||e.index-t.index}function ascendingTargetBreadth(e,t){return ascendingBreadth(e.target,t.target)||e.index-t.index}function ascendingBreadth(e,t){return e.y0-t.y0}function value(e){return e.value}function defaultId(e){return e.index}function defaultNodes(e){return e.nodes}function defaultLinks(e){return e.links}function find(e,t){const n=e.get(t);if(!n)throw new Error(\"missing: \"+t);return n}function computeLinkBreadths({nodes:e}){for(const t of e){let e=t.y0;let n=e;for(const n of t.sourceLinks){n.y0=e+n.width/2;e+=n.width}for(const e of t.targetLinks){e.y1=n+e.width/2;n+=e.width}}}function Sankey(){let e=0,n=0,o=1,r=1;let s=24;let i=8,a;let u=defaultId;let c=justify;let f;let l;let d=defaultNodes;let k=defaultLinks;let h=6;function sankey(){const e={nodes:d.apply(null,arguments),links:k.apply(null,arguments)};computeNodeLinks(e);computeNodeValues(e);computeNodeDepths(e);computeNodeHeights(e);computeNodeBreadths(e);computeLinkBreadths(e);return e}sankey.update=function(e){computeLinkBreadths(e);return e};sankey.nodeId=function(e){return arguments.length?(u=\"function\"===typeof e?e:constant(e),sankey):u};sankey.nodeAlign=function(e){return arguments.length?(c=\"function\"===typeof e?e:constant(e),sankey):c};sankey.nodeSort=function(e){return arguments.length?(f=e,sankey):f};sankey.nodeWidth=function(e){return arguments.length?(s=+e,sankey):s};sankey.nodePadding=function(e){return arguments.length?(i=a=+e,sankey):i};sankey.nodes=function(e){return arguments.length?(d=\"function\"===typeof e?e:constant(e),sankey):d};sankey.links=function(e){return arguments.length?(k=\"function\"===typeof e?e:constant(e),sankey):k};sankey.linkSort=function(e){return arguments.length?(l=e,sankey):l};sankey.size=function(t){return arguments.length?(e=n=0,o=+t[0],r=+t[1],sankey):[o-e,r-n]};sankey.extent=function(t){return arguments.length?(e=+t[0][0],o=+t[1][0],n=+t[0][1],r=+t[1][1],sankey):[[e,n],[o,r]]};sankey.iterations=function(e){return arguments.length?(h=+e,sankey):h};function computeNodeLinks({nodes:e,links:t}){for(const[t,n]of e.entries()){n.index=t;n.sourceLinks=[];n.targetLinks=[]}const n=new Map(e.map((t,n)=>[u(t,n,e),t]));for(const[e,o]of t.entries()){o.index=e;let{source:t,target:r}=o;\"object\"!==typeof t&&(t=o.source=find(n,t));\"object\"!==typeof r&&(r=o.target=find(n,r));t.sourceLinks.push(o);r.targetLinks.push(o)}if(null!=l)for(const{sourceLinks:t,targetLinks:n}of e){t.sort(l);n.sort(l)}}function computeNodeValues({nodes:e}){for(const n of e)n.value=void 0===n.fixedValue?Math.max(t.sum(n.sourceLinks,value),t.sum(n.targetLinks,value)):n.fixedValue}function computeNodeDepths({nodes:e}){const t=e.length;let n=new Set(e);let o=new Set;let r=0;while(n.size){for(const e of n){e.depth=r;for(const{target:t}of e.sourceLinks)o.add(t)}if(++r>t)throw new Error(\"circular link\");n=o;o=new Set}}function computeNodeHeights({nodes:e}){const t=e.length;let n=new Set(e);let o=new Set;let r=0;while(n.size){for(const e of n){e.height=r;for(const{source:t}of e.targetLinks)o.add(t)}if(++r>t)throw new Error(\"circular link\");n=o;o=new Set}}function computeNodeLayers({nodes:n}){const r=t.max(n,e=>e.depth)+1;const i=(o-e-s)/(r-1);const a=new Array(r);for(const t of n){const n=Math.max(0,Math.min(r-1,Math.floor(c.call(null,t,r))));t.layer=n;t.x0=e+n*i;t.x1=t.x0+s;a[n]?a[n].push(t):a[n]=[t]}if(f)for(const e of a)e.sort(f);return a}function initializeNodeBreadths(e){const o=t.min(e,e=>(r-n-(e.length-1)*a)/t.sum(e,value));for(const t of e){let e=n;for(const n of t){n.y0=e;n.y1=e+n.value*o;e=n.y1+a;for(const e of n.sourceLinks)e.width=e.value*o}e=(r-e+a)/(t.length+1);for(let n=0;n<t.length;++n){const o=t[n];o.y0+=e*(n+1);o.y1+=e*(n+1)}reorderLinks(t)}}function computeNodeBreadths(e){const o=computeNodeLayers(e);a=Math.min(i,(r-n)/(t.max(o,e=>e.length)-1));initializeNodeBreadths(o);for(let e=0;e<h;++e){const t=Math.pow(.99,e);const n=Math.max(1-t,(e+1)/h);relaxRightToLeft(o,t,n);relaxLeftToRight(o,t,n)}}function relaxLeftToRight(e,t,n){for(let o=1,r=e.length;o<r;++o){const r=e[o];for(const e of r){let n=0;let o=0;for(const{source:t,value:r}of e.targetLinks){let s=r*(e.layer-t.layer);n+=targetTop(t,e)*s;o+=s}if(!(o>0))continue;let r=(n/o-e.y0)*t;e.y0+=r;e.y1+=r;reorderNodeLinks(e)}void 0===f&&r.sort(ascendingBreadth);resolveCollisions(r,n)}}function relaxRightToLeft(e,t,n){for(let o=e.length,r=o-2;r>=0;--r){const o=e[r];for(const e of o){let n=0;let o=0;for(const{target:t,value:r}of e.sourceLinks){let s=r*(t.layer-e.layer);n+=sourceTop(e,t)*s;o+=s}if(!(o>0))continue;let r=(n/o-e.y0)*t;e.y0+=r;e.y1+=r;reorderNodeLinks(e)}void 0===f&&o.sort(ascendingBreadth);resolveCollisions(o,n)}}function resolveCollisions(e,t){const o=e.length>>1;const s=e[o];resolveCollisionsBottomToTop(e,s.y0-a,o-1,t);resolveCollisionsTopToBottom(e,s.y1+a,o+1,t);resolveCollisionsBottomToTop(e,r,e.length-1,t);resolveCollisionsTopToBottom(e,n,0,t)}function resolveCollisionsTopToBottom(e,t,n,o){for(;n<e.length;++n){const r=e[n];const s=(t-r.y0)*o;s>1e-6&&(r.y0+=s,r.y1+=s);t=r.y1+a}}function resolveCollisionsBottomToTop(e,t,n,o){for(;n>=0;--n){const r=e[n];const s=(r.y1-t)*o;s>1e-6&&(r.y0-=s,r.y1-=s);t=r.y0-a}}function reorderNodeLinks({sourceLinks:e,targetLinks:t}){if(void 0===l){for(const{source:{sourceLinks:e}}of t)e.sort(ascendingTargetBreadth);for(const{target:{targetLinks:t}}of e)t.sort(ascendingSourceBreadth)}}function reorderLinks(e){if(void 0===l)for(const{sourceLinks:t,targetLinks:n}of e){t.sort(ascendingTargetBreadth);n.sort(ascendingSourceBreadth)}}function targetTop(e,t){let n=e.y0-(e.sourceLinks.length-1)*a/2;for(const{target:o,width:r}of e.sourceLinks){if(o===t)break;n+=r+a}for(const{source:o,width:r}of t.targetLinks){if(o===e)break;n-=r}return n}function sourceTop(e,t){let n=t.y0-(t.targetLinks.length-1)*a/2;for(const{source:o,width:r}of t.targetLinks){if(o===e)break;n+=r+a}for(const{target:o,width:r}of e.sourceLinks){if(o===t)break;n-=r}return n}return sankey}function horizontalSource(e){return[e.source.x1,e.y0]}function horizontalTarget(e){return[e.target.x0,e.y1]}function sankeyLinkHorizontal(){return n.linkHorizontal().source(horizontalSource).target(horizontalTarget)}e.sankey=Sankey;e.sankeyCenter=center;e.sankeyJustify=justify;e.sankeyLeft=left;e.sankeyLinkHorizontal=sankeyLinkHorizontal;e.sankeyRight=right;Object.defineProperty(e,\"__esModule\",{value:true})}));const o=n.sankey,r=n.sankeyCenter,s=n.sankeyJustify,i=n.sankeyLeft,a=n.sankeyLinkHorizontal,u=n.sankeyRight,c=n.__esModule;export default n;export{c as __esModule,o as sankey,r as sankeyCenter,s as sankeyJustify,i as sankeyLeft,a as sankeyLinkHorizontal,u as sankeyRight};\n\n"
  },
  {
    "path": "vendor/javascript/d3-scale-chromatic.js",
    "content": "// d3-scale-chromatic@3.1.0 downloaded from https://ga.jspm.io/npm:d3-scale-chromatic@3.1.0/src/index.js\n\nimport{interpolateRgbBasis as f,interpolateCubehelixLong as e}from\"d3-interpolate\";import{cubehelix as a,rgb as d}from\"d3-color\";function colors(f){var e=f.length/6|0,a=new Array(e),d=0;while(d<e)a[d]=\"#\"+f.slice(d*6,6*++d);return a}var c=colors(\"1f77b4ff7f0e2ca02cd627289467bd8c564be377c27f7f7fbcbd2217becf\");var b=colors(\"7fc97fbeaed4fdc086ffff99386cb0f0027fbf5b17666666\");var r=colors(\"1b9e77d95f027570b3e7298a66a61ee6ab02a6761d666666\");var o=colors(\"4269d0efb118ff725c6cc5b03ca951ff8ab7a463f297bbf59c6b4e9498a0\");var s=colors(\"a6cee31f78b4b2df8a33a02cfb9a99e31a1cfdbf6fff7f00cab2d66a3d9affff99b15928\");var t=colors(\"fbb4aeb3cde3ccebc5decbe4fed9a6ffffcce5d8bdfddaecf2f2f2\");var n=colors(\"b3e2cdfdcdaccbd5e8f4cae4e6f5c9fff2aef1e2cccccccc\");var l=colors(\"e41a1c377eb84daf4a984ea3ff7f00ffff33a65628f781bf999999\");var m=colors(\"66c2a5fc8d628da0cbe78ac3a6d854ffd92fe5c494b3b3b3\");var i=colors(\"8dd3c7ffffb3bebadafb807280b1d3fdb462b3de69fccde5d9d9d9bc80bdccebc5ffed6f\");var v=colors(\"4e79a7f28e2ce1575976b7b259a14fedc949af7aa1ff9da79c755fbab0ab\");var ramp$1=e=>f(e[e.length-1]);var h=new Array(3).concat(\"d8b365f5f5f55ab4ac\",\"a6611adfc27d80cdc1018571\",\"a6611adfc27df5f5f580cdc1018571\",\"8c510ad8b365f6e8c3c7eae55ab4ac01665e\",\"8c510ad8b365f6e8c3f5f5f5c7eae55ab4ac01665e\",\"8c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e\",\"8c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e\",\"5430058c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e003c30\",\"5430058c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e003c30\").map(colors);var p=ramp$1(h);var u=new Array(3).concat(\"af8dc3f7f7f77fbf7b\",\"7b3294c2a5cfa6dba0008837\",\"7b3294c2a5cff7f7f7a6dba0008837\",\"762a83af8dc3e7d4e8d9f0d37fbf7b1b7837\",\"762a83af8dc3e7d4e8f7f7f7d9f0d37fbf7b1b7837\",\"762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b7837\",\"762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b7837\",\"40004b762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b783700441b\",\"40004b762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b783700441b\").map(colors);var w=ramp$1(u);var M=new Array(3).concat(\"e9a3c9f7f7f7a1d76a\",\"d01c8bf1b6dab8e1864dac26\",\"d01c8bf1b6daf7f7f7b8e1864dac26\",\"c51b7de9a3c9fde0efe6f5d0a1d76a4d9221\",\"c51b7de9a3c9fde0eff7f7f7e6f5d0a1d76a4d9221\",\"c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221\",\"c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221\",\"8e0152c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221276419\",\"8e0152c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221276419\").map(colors);var y=ramp$1(M);var A=new Array(3).concat(\"998ec3f7f7f7f1a340\",\"5e3c99b2abd2fdb863e66101\",\"5e3c99b2abd2f7f7f7fdb863e66101\",\"542788998ec3d8daebfee0b6f1a340b35806\",\"542788998ec3d8daebf7f7f7fee0b6f1a340b35806\",\"5427888073acb2abd2d8daebfee0b6fdb863e08214b35806\",\"5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b35806\",\"2d004b5427888073acb2abd2d8daebfee0b6fdb863e08214b358067f3b08\",\"2d004b5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b358067f3b08\").map(colors);var P=ramp$1(A);var B=new Array(3).concat(\"ef8a62f7f7f767a9cf\",\"ca0020f4a58292c5de0571b0\",\"ca0020f4a582f7f7f792c5de0571b0\",\"b2182bef8a62fddbc7d1e5f067a9cf2166ac\",\"b2182bef8a62fddbc7f7f7f7d1e5f067a9cf2166ac\",\"b2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac\",\"b2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac\",\"67001fb2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac053061\",\"67001fb2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac053061\").map(colors);var G=ramp$1(B);var R=new Array(3).concat(\"ef8a62ffffff999999\",\"ca0020f4a582bababa404040\",\"ca0020f4a582ffffffbababa404040\",\"b2182bef8a62fddbc7e0e0e09999994d4d4d\",\"b2182bef8a62fddbc7ffffffe0e0e09999994d4d4d\",\"b2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d\",\"b2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d\",\"67001fb2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d1a1a1a\",\"67001fb2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d1a1a1a\").map(colors);var Y=ramp$1(R);var x=new Array(3).concat(\"fc8d59ffffbf91bfdb\",\"d7191cfdae61abd9e92c7bb6\",\"d7191cfdae61ffffbfabd9e92c7bb6\",\"d73027fc8d59fee090e0f3f891bfdb4575b4\",\"d73027fc8d59fee090ffffbfe0f3f891bfdb4575b4\",\"d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4\",\"d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4\",\"a50026d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4313695\",\"a50026d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4313695\").map(colors);var O=ramp$1(x);var g=new Array(3).concat(\"fc8d59ffffbf91cf60\",\"d7191cfdae61a6d96a1a9641\",\"d7191cfdae61ffffbfa6d96a1a9641\",\"d73027fc8d59fee08bd9ef8b91cf601a9850\",\"d73027fc8d59fee08bffffbfd9ef8b91cf601a9850\",\"d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850\",\"d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850\",\"a50026d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850006837\",\"a50026d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850006837\").map(colors);var S=ramp$1(g);var C=new Array(3).concat(\"fc8d59ffffbf99d594\",\"d7191cfdae61abdda42b83ba\",\"d7191cfdae61ffffbfabdda42b83ba\",\"d53e4ffc8d59fee08be6f59899d5943288bd\",\"d53e4ffc8d59fee08bffffbfe6f59899d5943288bd\",\"d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd\",\"d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd\",\"9e0142d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd5e4fa2\",\"9e0142d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd5e4fa2\").map(colors);var I=ramp$1(C);var D=new Array(3).concat(\"e5f5f999d8c92ca25f\",\"edf8fbb2e2e266c2a4238b45\",\"edf8fbb2e2e266c2a42ca25f006d2c\",\"edf8fbccece699d8c966c2a42ca25f006d2c\",\"edf8fbccece699d8c966c2a441ae76238b45005824\",\"f7fcfde5f5f9ccece699d8c966c2a441ae76238b45005824\",\"f7fcfde5f5f9ccece699d8c966c2a441ae76238b45006d2c00441b\").map(colors);var T=ramp$1(D);var k=new Array(3).concat(\"e0ecf49ebcda8856a7\",\"edf8fbb3cde38c96c688419d\",\"edf8fbb3cde38c96c68856a7810f7c\",\"edf8fbbfd3e69ebcda8c96c68856a7810f7c\",\"edf8fbbfd3e69ebcda8c96c68c6bb188419d6e016b\",\"f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d6e016b\",\"f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d810f7c4d004b\").map(colors);var V=ramp$1(k);var W=new Array(3).concat(\"e0f3dba8ddb543a2ca\",\"f0f9e8bae4bc7bccc42b8cbe\",\"f0f9e8bae4bc7bccc443a2ca0868ac\",\"f0f9e8ccebc5a8ddb57bccc443a2ca0868ac\",\"f0f9e8ccebc5a8ddb57bccc44eb3d32b8cbe08589e\",\"f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe08589e\",\"f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe0868ac084081\").map(colors);var j=ramp$1(W);var q=new Array(3).concat(\"fee8c8fdbb84e34a33\",\"fef0d9fdcc8afc8d59d7301f\",\"fef0d9fdcc8afc8d59e34a33b30000\",\"fef0d9fdd49efdbb84fc8d59e34a33b30000\",\"fef0d9fdd49efdbb84fc8d59ef6548d7301f990000\",\"fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301f990000\",\"fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301fb300007f0000\").map(colors);var z=ramp$1(q);var E=new Array(3).concat(\"ece2f0a6bddb1c9099\",\"f6eff7bdc9e167a9cf02818a\",\"f6eff7bdc9e167a9cf1c9099016c59\",\"f6eff7d0d1e6a6bddb67a9cf1c9099016c59\",\"f6eff7d0d1e6a6bddb67a9cf3690c002818a016450\",\"fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016450\",\"fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016c59014636\").map(colors);var F=ramp$1(E);var H=new Array(3).concat(\"ece7f2a6bddb2b8cbe\",\"f1eef6bdc9e174a9cf0570b0\",\"f1eef6bdc9e174a9cf2b8cbe045a8d\",\"f1eef6d0d1e6a6bddb74a9cf2b8cbe045a8d\",\"f1eef6d0d1e6a6bddb74a9cf3690c00570b0034e7b\",\"fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0034e7b\",\"fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0045a8d023858\").map(colors);var J=ramp$1(H);var K=new Array(3).concat(\"e7e1efc994c7dd1c77\",\"f1eef6d7b5d8df65b0ce1256\",\"f1eef6d7b5d8df65b0dd1c77980043\",\"f1eef6d4b9dac994c7df65b0dd1c77980043\",\"f1eef6d4b9dac994c7df65b0e7298ace125691003f\",\"f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125691003f\",\"f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125698004367001f\").map(colors);var L=ramp$1(K);var N=new Array(3).concat(\"fde0ddfa9fb5c51b8a\",\"feebe2fbb4b9f768a1ae017e\",\"feebe2fbb4b9f768a1c51b8a7a0177\",\"feebe2fcc5c0fa9fb5f768a1c51b8a7a0177\",\"feebe2fcc5c0fa9fb5f768a1dd3497ae017e7a0177\",\"fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a0177\",\"fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a017749006a\").map(colors);var Q=ramp$1(N);var U=new Array(3).concat(\"edf8b17fcdbb2c7fb8\",\"ffffcca1dab441b6c4225ea8\",\"ffffcca1dab441b6c42c7fb8253494\",\"ffffccc7e9b47fcdbb41b6c42c7fb8253494\",\"ffffccc7e9b47fcdbb41b6c41d91c0225ea80c2c84\",\"ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea80c2c84\",\"ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea8253494081d58\").map(colors);var X=ramp$1(U);var Z=new Array(3).concat(\"f7fcb9addd8e31a354\",\"ffffccc2e69978c679238443\",\"ffffccc2e69978c67931a354006837\",\"ffffccd9f0a3addd8e78c67931a354006837\",\"ffffccd9f0a3addd8e78c67941ab5d238443005a32\",\"ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443005a32\",\"ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443006837004529\").map(colors);var $=ramp$1(Z);var _=new Array(3).concat(\"fff7bcfec44fd95f0e\",\"ffffd4fed98efe9929cc4c02\",\"ffffd4fed98efe9929d95f0e993404\",\"ffffd4fee391fec44ffe9929d95f0e993404\",\"ffffd4fee391fec44ffe9929ec7014cc4c028c2d04\",\"ffffe5fff7bcfee391fec44ffe9929ec7014cc4c028c2d04\",\"ffffe5fff7bcfee391fec44ffe9929ec7014cc4c02993404662506\").map(colors);var ff=ramp$1(_);var ef=new Array(3).concat(\"ffeda0feb24cf03b20\",\"ffffb2fecc5cfd8d3ce31a1c\",\"ffffb2fecc5cfd8d3cf03b20bd0026\",\"ffffb2fed976feb24cfd8d3cf03b20bd0026\",\"ffffb2fed976feb24cfd8d3cfc4e2ae31a1cb10026\",\"ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cb10026\",\"ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cbd0026800026\").map(colors);var af=ramp$1(ef);var df=new Array(3).concat(\"deebf79ecae13182bd\",\"eff3ffbdd7e76baed62171b5\",\"eff3ffbdd7e76baed63182bd08519c\",\"eff3ffc6dbef9ecae16baed63182bd08519c\",\"eff3ffc6dbef9ecae16baed64292c62171b5084594\",\"f7fbffdeebf7c6dbef9ecae16baed64292c62171b5084594\",\"f7fbffdeebf7c6dbef9ecae16baed64292c62171b508519c08306b\").map(colors);var cf=ramp$1(df);var bf=new Array(3).concat(\"e5f5e0a1d99b31a354\",\"edf8e9bae4b374c476238b45\",\"edf8e9bae4b374c47631a354006d2c\",\"edf8e9c7e9c0a1d99b74c47631a354006d2c\",\"edf8e9c7e9c0a1d99b74c47641ab5d238b45005a32\",\"f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45005a32\",\"f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45006d2c00441b\").map(colors);var rf=ramp$1(bf);var of=new Array(3).concat(\"f0f0f0bdbdbd636363\",\"f7f7f7cccccc969696525252\",\"f7f7f7cccccc969696636363252525\",\"f7f7f7d9d9d9bdbdbd969696636363252525\",\"f7f7f7d9d9d9bdbdbd969696737373525252252525\",\"fffffff0f0f0d9d9d9bdbdbd969696737373525252252525\",\"fffffff0f0f0d9d9d9bdbdbd969696737373525252252525000000\").map(colors);var sf=ramp$1(of);var tf=new Array(3).concat(\"efedf5bcbddc756bb1\",\"f2f0f7cbc9e29e9ac86a51a3\",\"f2f0f7cbc9e29e9ac8756bb154278f\",\"f2f0f7dadaebbcbddc9e9ac8756bb154278f\",\"f2f0f7dadaebbcbddc9e9ac8807dba6a51a34a1486\",\"fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a34a1486\",\"fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a354278f3f007d\").map(colors);var nf=ramp$1(tf);var lf=new Array(3).concat(\"fee0d2fc9272de2d26\",\"fee5d9fcae91fb6a4acb181d\",\"fee5d9fcae91fb6a4ade2d26a50f15\",\"fee5d9fcbba1fc9272fb6a4ade2d26a50f15\",\"fee5d9fcbba1fc9272fb6a4aef3b2ccb181d99000d\",\"fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181d99000d\",\"fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181da50f1567000d\").map(colors);var mf=ramp$1(lf);var vf=new Array(3).concat(\"fee6cefdae6be6550d\",\"feeddefdbe85fd8d3cd94701\",\"feeddefdbe85fd8d3ce6550da63603\",\"feeddefdd0a2fdae6bfd8d3ce6550da63603\",\"feeddefdd0a2fdae6bfd8d3cf16913d948018c2d04\",\"fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d948018c2d04\",\"fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d94801a636037f2704\").map(colors);var hf=ramp$1(vf);function cividis(f){f=Math.max(0,Math.min(1,f));return\"rgb(\"+Math.max(0,Math.min(255,Math.round(-4.54-f*(35.34-f*(2381.73-f*(6402.7-f*(7024.72-f*2710.57)))))))+\", \"+Math.max(0,Math.min(255,Math.round(32.49+f*(170.73+f*(52.82-f*(131.46-f*(176.58-f*67.37)))))))+\", \"+Math.max(0,Math.min(255,Math.round(81.24+f*(442.36-f*(2482.43-f*(6167.24-f*(6614.94-f*2475.67)))))))+\")\"}var pf=e(a(300,.5,0),a(-240,.5,1));var uf=e(a(-100,.75,.35),a(80,1.5,.8));var wf=e(a(260,.75,.35),a(80,1.5,.8));var Mf=a();function rainbow(f){(f<0||f>1)&&(f-=Math.floor(f));var e=Math.abs(f-.5);Mf.h=360*f-100;Mf.s=1.5-1.5*e;Mf.l=.8-.9*e;return Mf+\"\"}var yf=d(),Af=Math.PI/3,Pf=Math.PI*2/3;function sinebow(f){var e;f=(.5-f)*Math.PI;yf.r=255*(e=Math.sin(f))*e;yf.g=255*(e=Math.sin(f+Af))*e;yf.b=255*(e=Math.sin(f+Pf))*e;return yf+\"\"}function turbo(f){f=Math.max(0,Math.min(1,f));return\"rgb(\"+Math.max(0,Math.min(255,Math.round(34.61+f*(1172.33-f*(10793.56-f*(33300.12-f*(38394.49-f*14825.05)))))))+\", \"+Math.max(0,Math.min(255,Math.round(23.31+f*(557.33+f*(1225.33-f*(3574.96-f*(1073.77+f*707.56)))))))+\", \"+Math.max(0,Math.min(255,Math.round(27.2+f*(3211.1-f*(15327.97-f*(27814-f*(22569.18-f*6838.66)))))))+\")\"}function ramp(f){var e=f.length;return function(a){return f[Math.max(0,Math.min(e-1,Math.floor(a*e)))]}}var Bf=ramp(colors(\"44015444025645045745055946075a46085c460a5d460b5e470d60470e6147106347116447136548146748166848176948186a481a6c481b6d481c6e481d6f481f70482071482173482374482475482576482677482878482979472a7a472c7a472d7b472e7c472f7d46307e46327e46337f463480453581453781453882443983443a83443b84433d84433e85423f854240864241864142874144874045884046883f47883f48893e49893e4a893e4c8a3d4d8a3d4e8a3c4f8a3c508b3b518b3b528b3a538b3a548c39558c39568c38588c38598c375a8c375b8d365c8d365d8d355e8d355f8d34608d34618d33628d33638d32648e32658e31668e31678e31688e30698e306a8e2f6b8e2f6c8e2e6d8e2e6e8e2e6f8e2d708e2d718e2c718e2c728e2c738e2b748e2b758e2a768e2a778e2a788e29798e297a8e297b8e287c8e287d8e277e8e277f8e27808e26818e26828e26828e25838e25848e25858e24868e24878e23888e23898e238a8d228b8d228c8d228d8d218e8d218f8d21908d21918c20928c20928c20938c1f948c1f958b1f968b1f978b1f988b1f998a1f9a8a1e9b8a1e9c891e9d891f9e891f9f881fa0881fa1881fa1871fa28720a38620a48621a58521a68522a78522a88423a98324aa8325ab8225ac8226ad8127ad8128ae8029af7f2ab07f2cb17e2db27d2eb37c2fb47c31b57b32b67a34b67935b77937b87838b9773aba763bbb753dbc743fbc7340bd7242be7144bf7046c06f48c16e4ac16d4cc26c4ec36b50c46a52c56954c56856c66758c7655ac8645cc8635ec96260ca6063cb5f65cb5e67cc5c69cd5b6ccd5a6ece5870cf5773d05675d05477d1537ad1517cd2507fd34e81d34d84d44b86d54989d5488bd6468ed64590d74393d74195d84098d83e9bd93c9dd93ba0da39a2da37a5db36a8db34aadc32addc30b0dd2fb2dd2db5de2bb8de29bade28bddf26c0df25c2df23c5e021c8e020cae11fcde11dd0e11cd2e21bd5e21ad8e219dae319dde318dfe318e2e418e5e419e7e419eae51aece51befe51cf1e51df4e61ef6e620f8e621fbe723fde725\"));var Gf=ramp(colors(\"00000401000501010601010802010902020b02020d03030f03031204041405041606051806051a07061c08071e0907200a08220b09240c09260d0a290e0b2b100b2d110c2f120d31130d34140e36150e38160f3b180f3d19103f1a10421c10441d11471e114920114b21114e22115024125325125527125829115a2a115c2c115f2d11612f116331116533106734106936106b38106c390f6e3b0f703d0f713f0f72400f74420f75440f764510774710784910784a10794c117a4e117b4f127b51127c52137c54137d56147d57157e59157e5a167e5c167f5d177f5f187f601880621980641a80651a80671b80681c816a1c816b1d816d1d816e1e81701f81721f817320817521817621817822817922827b23827c23827e24828025828125818326818426818627818827818928818b29818c29818e2a81902a81912b81932b80942c80962c80982d80992d809b2e7f9c2e7f9e2f7fa02f7fa1307ea3307ea5317ea6317da8327daa337dab337cad347cae347bb0357bb2357bb3367ab5367ab73779b83779ba3878bc3978bd3977bf3a77c03a76c23b75c43c75c53c74c73d73c83e73ca3e72cc3f71cd4071cf4070d0416fd2426fd3436ed5446dd6456cd8456cd9466bdb476adc4869de4968df4a68e04c67e24d66e34e65e44f64e55064e75263e85362e95462ea5661eb5760ec5860ed5a5fee5b5eef5d5ef05f5ef1605df2625df2645cf3655cf4675cf4695cf56b5cf66c5cf66e5cf7705cf7725cf8745cf8765cf9785df9795df97b5dfa7d5efa7f5efa815ffb835ffb8560fb8761fc8961fc8a62fc8c63fc8e64fc9065fd9266fd9467fd9668fd9869fd9a6afd9b6bfe9d6cfe9f6dfea16efea36ffea571fea772fea973feaa74feac76feae77feb078feb27afeb47bfeb67cfeb77efeb97ffebb81febd82febf84fec185fec287fec488fec68afec88cfeca8dfecc8ffecd90fecf92fed194fed395fed597fed799fed89afdda9cfddc9efddea0fde0a1fde2a3fde3a5fde5a7fde7a9fde9aafdebacfcecaefceeb0fcf0b2fcf2b4fcf4b6fcf6b8fcf7b9fcf9bbfcfbbdfcfdbf\"));var Rf=ramp(colors(\"00000401000501010601010802010a02020c02020e03021004031204031405041706041907051b08051d09061f0a07220b07240c08260d08290e092b10092d110a30120a32140b34150b37160b39180c3c190c3e1b0c411c0c431e0c451f0c48210c4a230c4c240c4f260c51280b53290b552b0b572d0b592f0a5b310a5c320a5e340a5f3609613809623909633b09643d09653e0966400a67420a68440a68450a69470b6a490b6a4a0c6b4c0c6b4d0d6c4f0d6c510e6c520e6d540f6d550f6d57106e59106e5a116e5c126e5d126e5f136e61136e62146e64156e65156e67166e69166e6a176e6c186e6d186e6f196e71196e721a6e741a6e751b6e771c6d781c6d7a1d6d7c1d6d7d1e6d7f1e6c801f6c82206c84206b85216b87216b88226a8a226a8c23698d23698f24699025689225689326679526679727669827669a28659b29649d29649f2a63a02a63a22b62a32c61a52c60a62d60a82e5fa92e5eab2f5ead305dae305cb0315bb1325ab3325ab43359b63458b73557b93556ba3655bc3754bd3853bf3952c03a51c13a50c33b4fc43c4ec63d4dc73e4cc83f4bca404acb4149cc4248ce4347cf4446d04545d24644d34743d44842d54a41d74b3fd84c3ed94d3dda4e3cdb503bdd513ade5238df5337e05536e15635e25734e35933e45a31e55c30e65d2fe75e2ee8602de9612bea632aeb6429eb6628ec6726ed6925ee6a24ef6c23ef6e21f06f20f1711ff1731df2741cf3761bf37819f47918f57b17f57d15f67e14f68013f78212f78410f8850ff8870ef8890cf98b0bf98c0af98e09fa9008fa9207fa9407fb9606fb9706fb9906fb9b06fb9d07fc9f07fca108fca309fca50afca60cfca80dfcaa0ffcac11fcae12fcb014fcb216fcb418fbb61afbb81dfbba1ffbbc21fbbe23fac026fac228fac42afac62df9c72ff9c932f9cb35f8cd37f8cf3af7d13df7d340f6d543f6d746f5d949f5db4cf4dd4ff4df53f4e156f3e35af3e55df2e661f2e865f2ea69f1ec6df1ed71f1ef75f1f179f2f27df2f482f3f586f3f68af4f88ef5f992f6fa96f8fb9af9fc9dfafda1fcffa4\"));var Yf=ramp(colors(\"0d088710078813078916078a19068c1b068d1d068e20068f2206902406912605912805922a05932c05942e05952f059631059733059735049837049938049a3a049a3c049b3e049c3f049c41049d43039e44039e46039f48039f4903a04b03a14c02a14e02a25002a25102a35302a35502a45601a45801a45901a55b01a55c01a65e01a66001a66100a76300a76400a76600a76700a86900a86a00a86c00a86e00a86f00a87100a87201a87401a87501a87701a87801a87a02a87b02a87d03a87e03a88004a88104a78305a78405a78606a68707a68808a68a09a58b0aa58d0ba58e0ca48f0da4910ea3920fa39410a29511a19613a19814a099159f9a169f9c179e9d189d9e199da01a9ca11b9ba21d9aa31e9aa51f99a62098a72197a82296aa2395ab2494ac2694ad2793ae2892b02991b12a90b22b8fb32c8eb42e8db52f8cb6308bb7318ab83289ba3388bb3488bc3587bd3786be3885bf3984c03a83c13b82c23c81c33d80c43e7fc5407ec6417dc7427cc8437bc9447aca457acb4679cc4778cc4977cd4a76ce4b75cf4c74d04d73d14e72d24f71d35171d45270d5536fd5546ed6556dd7566cd8576bd9586ada5a6ada5b69db5c68dc5d67dd5e66de5f65de6164df6263e06363e16462e26561e26660e3685fe4695ee56a5de56b5de66c5ce76e5be76f5ae87059e97158e97257ea7457eb7556eb7655ec7754ed7953ed7a52ee7b51ef7c51ef7e50f07f4ff0804ef1814df1834cf2844bf3854bf3874af48849f48948f58b47f58c46f68d45f68f44f79044f79143f79342f89441f89540f9973ff9983ef99a3efa9b3dfa9c3cfa9e3bfb9f3afba139fba238fca338fca537fca636fca835fca934fdab33fdac33fdae32fdaf31fdb130fdb22ffdb42ffdb52efeb72dfeb82cfeba2cfebb2bfebd2afebe2afec029fdc229fdc328fdc527fdc627fdc827fdca26fdcb26fccd25fcce25fcd025fcd225fbd324fbd524fbd724fad824fada24f9dc24f9dd25f8df25f8e125f7e225f7e425f6e626f6e826f5e926f5eb27f4ed27f3ee27f3f027f2f227f1f426f1f525f0f724f0f921\"));export{cf as interpolateBlues,p as interpolateBrBG,T as interpolateBuGn,V as interpolateBuPu,cividis as interpolateCividis,wf as interpolateCool,pf as interpolateCubehelixDefault,j as interpolateGnBu,rf as interpolateGreens,sf as interpolateGreys,Rf as interpolateInferno,Gf as interpolateMagma,z as interpolateOrRd,hf as interpolateOranges,w as interpolatePRGn,y as interpolatePiYG,Yf as interpolatePlasma,J as interpolatePuBu,F as interpolatePuBuGn,P as interpolatePuOr,L as interpolatePuRd,nf as interpolatePurples,rainbow as interpolateRainbow,G as interpolateRdBu,Y as interpolateRdGy,Q as interpolateRdPu,O as interpolateRdYlBu,S as interpolateRdYlGn,mf as interpolateReds,sinebow as interpolateSinebow,I as interpolateSpectral,turbo as interpolateTurbo,Bf as interpolateViridis,uf as interpolateWarm,$ as interpolateYlGn,X as interpolateYlGnBu,ff as interpolateYlOrBr,af as interpolateYlOrRd,b as schemeAccent,df as schemeBlues,h as schemeBrBG,D as schemeBuGn,k as schemeBuPu,c as schemeCategory10,r as schemeDark2,W as schemeGnBu,bf as schemeGreens,of as schemeGreys,o as schemeObservable10,q as schemeOrRd,vf as schemeOranges,u as schemePRGn,s as schemePaired,t as schemePastel1,n as schemePastel2,M as schemePiYG,H as schemePuBu,E as schemePuBuGn,A as schemePuOr,K as schemePuRd,tf as schemePurples,B as schemeRdBu,R as schemeRdGy,N as schemeRdPu,x as schemeRdYlBu,g as schemeRdYlGn,lf as schemeReds,l as schemeSet1,m as schemeSet2,i as schemeSet3,C as schemeSpectral,v as schemeTableau10,Z as schemeYlGn,U as schemeYlGnBu,_ as schemeYlOrBr,ef as schemeYlOrRd};\n\n"
  },
  {
    "path": "vendor/javascript/d3-scale.js",
    "content": "// d3-scale@4.0.2 downloaded from https://ga.jspm.io/npm:d3-scale@4.0.2/src/index.js\n\nimport{InternMap as n,range as e,bisect as t,tickStep as r,ticks as a,tickIncrement as i,quantileSorted as o,ascending as l,quantile as u}from\"d3-array\";import{interpolate as c,interpolateNumber as s,interpolateRound as f,piecewise as g}from\"d3-interpolate\";import{formatSpecifier as p,precisionFixed as h,precisionRound as m,precisionPrefix as d,formatPrefix as y,format as v}from\"d3-format\";import{timeTicks as w,timeTickInterval as M,timeYear as q,timeMonth as k,timeWeek as b,timeDay as x,timeHour as $,timeMinute as N,timeSecond as S,utcTicks as I,utcTickInterval as R,utcYear as A,utcMonth as L,utcWeek as P,utcDay as D,utcHour as E,utcMinute as F,utcSecond as z}from\"d3-time\";import{timeFormat as O,utcFormat as Q}from\"d3-time-format\";function initRange(n,e){switch(arguments.length){case 0:break;case 1:this.range(n);break;default:this.range(e).domain(n);break}return this}function initInterpolator(n,e){switch(arguments.length){case 0:break;case 1:\"function\"===typeof n?this.interpolator(n):this.range(n);break;default:this.domain(n);\"function\"===typeof e?this.interpolator(e):this.range(e);break}return this}const T=Symbol(\"implicit\");function ordinal(){var e=new n,t=[],r=[],a=T;function scale(n){let i=e.get(n);if(void 0===i){if(a!==T)return a;e.set(n,i=t.push(n)-1)}return r[i%r.length]}scale.domain=function(r){if(!arguments.length)return t.slice();t=[],e=new n;for(const n of r)e.has(n)||e.set(n,t.push(n)-1);return scale};scale.range=function(n){return arguments.length?(r=Array.from(n),scale):r.slice()};scale.unknown=function(n){return arguments.length?(a=n,scale):a};scale.copy=function(){return ordinal(t,r).unknown(a)};initRange.apply(scale,arguments);return scale}function band(){var n,t,r=ordinal().unknown(void 0),a=r.domain,i=r.range,o=0,l=1,u=false,c=0,s=0,f=.5;delete r.unknown;function rescale(){var r=a().length,g=l<o,p=g?l:o,h=g?o:l;n=(h-p)/Math.max(1,r-c+2*s);u&&(n=Math.floor(n));p+=(h-p-n*(r-c))*f;t=n*(1-c);u&&(p=Math.round(p),t=Math.round(t));var m=e(r).map((function(e){return p+n*e}));return i(g?m.reverse():m)}r.domain=function(n){return arguments.length?(a(n),rescale()):a()};r.range=function(n){return arguments.length?([o,l]=n,o=+o,l=+l,rescale()):[o,l]};r.rangeRound=function(n){return[o,l]=n,o=+o,l=+l,u=true,rescale()};r.bandwidth=function(){return t};r.step=function(){return n};r.round=function(n){return arguments.length?(u=!!n,rescale()):u};r.padding=function(n){return arguments.length?(c=Math.min(1,s=+n),rescale()):c};r.paddingInner=function(n){return arguments.length?(c=Math.min(1,n),rescale()):c};r.paddingOuter=function(n){return arguments.length?(s=+n,rescale()):s};r.align=function(n){return arguments.length?(f=Math.max(0,Math.min(1,n)),rescale()):f};r.copy=function(){return band(a(),[o,l]).round(u).paddingInner(c).paddingOuter(s).align(f)};return initRange.apply(rescale(),arguments)}function pointish(n){var e=n.copy;n.padding=n.paddingOuter;delete n.paddingInner;delete n.paddingOuter;n.copy=function(){return pointish(e())};return n}function point(){return pointish(band.apply(null,arguments).paddingInner(1))}function constants(n){return function(){return n}}function number$1(n){return+n}var U=[0,1];function identity$1(n){return n}function normalize(n,e){return(e-=n=+n)?function(t){return(t-n)/e}:constants(isNaN(e)?NaN:.5)}function clamper(n,e){var t;n>e&&(t=n,n=e,e=t);return function(t){return Math.max(n,Math.min(e,t))}}function bimap(n,e,t){var r=n[0],a=n[1],i=e[0],o=e[1];a<r?(r=normalize(a,r),i=t(o,i)):(r=normalize(r,a),i=t(i,o));return function(n){return i(r(n))}}function polymap(n,e,r){var a=Math.min(n.length,e.length)-1,i=new Array(a),o=new Array(a),l=-1;if(n[a]<n[0]){n=n.slice().reverse();e=e.slice().reverse()}while(++l<a){i[l]=normalize(n[l],n[l+1]);o[l]=r(e[l],e[l+1])}return function(e){var r=t(n,e,1,a)-1;return o[r](i[r](e))}}function copy$1(n,e){return e.domain(n.domain()).range(n.range()).interpolate(n.interpolate()).clamp(n.clamp()).unknown(n.unknown())}function transformer$2(){var n,e,t,r,a,i,o=U,l=U,u=c,g=identity$1;function rescale(){var n=Math.min(o.length,l.length);g!==identity$1&&(g=clamper(o[0],o[n-1]));r=n>2?polymap:bimap;a=i=null;return scale}function scale(e){return null==e||isNaN(e=+e)?t:(a||(a=r(o.map(n),l,u)))(n(g(e)))}scale.invert=function(t){return g(e((i||(i=r(l,o.map(n),s)))(t)))};scale.domain=function(n){return arguments.length?(o=Array.from(n,number$1),rescale()):o.slice()};scale.range=function(n){return arguments.length?(l=Array.from(n),rescale()):l.slice()};scale.rangeRound=function(n){return l=Array.from(n),u=f,rescale()};scale.clamp=function(n){return arguments.length?(g=!!n||identity$1,rescale()):g!==identity$1};scale.interpolate=function(n){return arguments.length?(u=n,rescale()):u};scale.unknown=function(n){return arguments.length?(t=n,scale):t};return function(t,r){n=t,e=r;return rescale()}}function continuous(){return transformer$2()(identity$1,identity$1)}function tickFormat(n,e,t,a){var i,o=r(n,e,t);a=p(null==a?\",f\":a);switch(a.type){case\"s\":var l=Math.max(Math.abs(n),Math.abs(e));null!=a.precision||isNaN(i=d(o,l))||(a.precision=i);return y(a,l);case\"\":case\"e\":case\"g\":case\"p\":case\"r\":null!=a.precision||isNaN(i=m(o,Math.max(Math.abs(n),Math.abs(e))))||(a.precision=i-(\"e\"===a.type));break;case\"f\":case\"%\":null!=a.precision||isNaN(i=h(o))||(a.precision=i-2*(\"%\"===a.type));break}return v(a)}function linearish(n){var e=n.domain;n.ticks=function(n){var t=e();return a(t[0],t[t.length-1],null==n?10:n)};n.tickFormat=function(n,t){var r=e();return tickFormat(r[0],r[r.length-1],null==n?10:n,t)};n.nice=function(t){null==t&&(t=10);var r=e();var a=0;var o=r.length-1;var l=r[a];var u=r[o];var c;var s;var f=10;if(u<l){s=l,l=u,u=s;s=a,a=o,o=s}while(f-- >0){s=i(l,u,t);if(s===c){r[a]=l;r[o]=u;return e(r)}if(s>0){l=Math.floor(l/s)*s;u=Math.ceil(u/s)*s}else{if(!(s<0))break;l=Math.ceil(l*s)/s;u=Math.floor(u*s)/s}c=s}return n};return n}function linear(){var n=continuous();n.copy=function(){return copy$1(n,linear())};initRange.apply(n,arguments);return linearish(n)}function identity(n){var e;function scale(n){return null==n||isNaN(n=+n)?e:n}scale.invert=scale;scale.domain=scale.range=function(e){return arguments.length?(n=Array.from(e,number$1),scale):n.slice()};scale.unknown=function(n){return arguments.length?(e=n,scale):e};scale.copy=function(){return identity(n).unknown(e)};n=arguments.length?Array.from(n,number$1):[0,1];return linearish(scale)}function nice(n,e){n=n.slice();var t,r=0,a=n.length-1,i=n[r],o=n[a];if(o<i){t=r,r=a,a=t;t=i,i=o,o=t}n[r]=e.floor(i);n[a]=e.ceil(o);return n}function transformLog(n){return Math.log(n)}function transformExp(n){return Math.exp(n)}function transformLogn(n){return-Math.log(-n)}function transformExpn(n){return-Math.exp(-n)}function pow10(n){return isFinite(n)?+(\"1e\"+n):n<0?0:n}function powp(n){return 10===n?pow10:n===Math.E?Math.exp:e=>Math.pow(n,e)}function logp(n){return n===Math.E?Math.log:10===n&&Math.log10||2===n&&Math.log2||(n=Math.log(n),e=>Math.log(e)/n)}function reflect(n){return(e,t)=>-n(-e,t)}function loggish(n){const e=n(transformLog,transformExp);const t=e.domain;let r=10;let i;let o;function rescale(){i=logp(r),o=powp(r);if(t()[0]<0){i=reflect(i),o=reflect(o);n(transformLogn,transformExpn)}else n(transformLog,transformExp);return e}e.base=function(n){return arguments.length?(r=+n,rescale()):r};e.domain=function(n){return arguments.length?(t(n),rescale()):t()};e.ticks=n=>{const e=t();let l=e[0];let u=e[e.length-1];const c=u<l;c&&([l,u]=[u,l]);let s=i(l);let f=i(u);let g;let p;const h=null==n?10:+n;let m=[];if(!(r%1)&&f-s<h){s=Math.floor(s),f=Math.ceil(f);if(l>0)for(;s<=f;++s)for(g=1;g<r;++g){p=s<0?g/o(-s):g*o(s);if(!(p<l)){if(p>u)break;m.push(p)}}else for(;s<=f;++s)for(g=r-1;g>=1;--g){p=s>0?g/o(-s):g*o(s);if(!(p<l)){if(p>u)break;m.push(p)}}2*m.length<h&&(m=a(l,u,h))}else m=a(s,f,Math.min(f-s,h)).map(o);return c?m.reverse():m};e.tickFormat=(n,t)=>{null==n&&(n=10);null==t&&(t=10===r?\"s\":\",\");if(\"function\"!==typeof t){r%1||null!=(t=p(t)).precision||(t.trim=true);t=v(t)}if(Infinity===n)return t;const a=Math.max(1,r*n/e.ticks().length);return n=>{let e=n/o(Math.round(i(n)));e*r<r-.5&&(e*=r);return e<=a?t(n):\"\"}};e.nice=()=>t(nice(t(),{floor:n=>o(Math.floor(i(n))),ceil:n=>o(Math.ceil(i(n)))}));return e}function log(){const n=loggish(transformer$2()).domain([1,10]);n.copy=()=>copy$1(n,log()).base(n.base());initRange.apply(n,arguments);return n}function transformSymlog(n){return function(e){return Math.sign(e)*Math.log1p(Math.abs(e/n))}}function transformSymexp(n){return function(e){return Math.sign(e)*Math.expm1(Math.abs(e))*n}}function symlogish(n){var e=1,t=n(transformSymlog(e),transformSymexp(e));t.constant=function(t){return arguments.length?n(transformSymlog(e=+t),transformSymexp(e)):e};return linearish(t)}function symlog(){var n=symlogish(transformer$2());n.copy=function(){return copy$1(n,symlog()).constant(n.constant())};return initRange.apply(n,arguments)}function transformPow(n){return function(e){return e<0?-Math.pow(-e,n):Math.pow(e,n)}}function transformSqrt(n){return n<0?-Math.sqrt(-n):Math.sqrt(n)}function transformSquare(n){return n<0?-n*n:n*n}function powish(n){var e=n(identity$1,identity$1),t=1;function rescale(){return 1===t?n(identity$1,identity$1):.5===t?n(transformSqrt,transformSquare):n(transformPow(t),transformPow(1/t))}e.exponent=function(n){return arguments.length?(t=+n,rescale()):t};return linearish(e)}function pow(){var n=powish(transformer$2());n.copy=function(){return copy$1(n,pow()).exponent(n.exponent())};initRange.apply(n,arguments);return n}function sqrt(){return pow.apply(null,arguments).exponent(.5)}function square(n){return Math.sign(n)*n*n}function unsquare(n){return Math.sign(n)*Math.sqrt(Math.abs(n))}function radial(){var n,e=continuous(),t=[0,1],r=false;function scale(t){var a=unsquare(e(t));return isNaN(a)?n:r?Math.round(a):a}scale.invert=function(n){return e.invert(square(n))};scale.domain=function(n){return arguments.length?(e.domain(n),scale):e.domain()};scale.range=function(n){return arguments.length?(e.range((t=Array.from(n,number$1)).map(square)),scale):t.slice()};scale.rangeRound=function(n){return scale.range(n).round(true)};scale.round=function(n){return arguments.length?(r=!!n,scale):r};scale.clamp=function(n){return arguments.length?(e.clamp(n),scale):e.clamp()};scale.unknown=function(e){return arguments.length?(n=e,scale):n};scale.copy=function(){return radial(e.domain(),t).round(r).clamp(e.clamp()).unknown(n)};initRange.apply(scale,arguments);return linearish(scale)}function quantile(){var n,e=[],r=[],a=[];function rescale(){var n=0,t=Math.max(1,r.length);a=new Array(t-1);while(++n<t)a[n-1]=o(e,n/t);return scale}function scale(e){return null==e||isNaN(e=+e)?n:r[t(a,e)]}scale.invertExtent=function(n){var t=r.indexOf(n);return t<0?[NaN,NaN]:[t>0?a[t-1]:e[0],t<a.length?a[t]:e[e.length-1]]};scale.domain=function(n){if(!arguments.length)return e.slice();e=[];for(let t of n)null==t||isNaN(t=+t)||e.push(t);e.sort(l);return rescale()};scale.range=function(n){return arguments.length?(r=Array.from(n),rescale()):r.slice()};scale.unknown=function(e){return arguments.length?(n=e,scale):n};scale.quantiles=function(){return a.slice()};scale.copy=function(){return quantile().domain(e).range(r).unknown(n)};return initRange.apply(scale,arguments)}function quantize(){var n,e=0,r=1,a=1,i=[.5],o=[0,1];function scale(e){return null!=e&&e<=e?o[t(i,e,0,a)]:n}function rescale(){var n=-1;i=new Array(a);while(++n<a)i[n]=((n+1)*r-(n-a)*e)/(a+1);return scale}scale.domain=function(n){return arguments.length?([e,r]=n,e=+e,r=+r,rescale()):[e,r]};scale.range=function(n){return arguments.length?(a=(o=Array.from(n)).length-1,rescale()):o.slice()};scale.invertExtent=function(n){var t=o.indexOf(n);return t<0?[NaN,NaN]:t<1?[e,i[0]]:t>=a?[i[a-1],r]:[i[t-1],i[t]]};scale.unknown=function(e){return arguments.length?(n=e,scale):scale};scale.thresholds=function(){return i.slice()};scale.copy=function(){return quantize().domain([e,r]).range(o).unknown(n)};return initRange.apply(linearish(scale),arguments)}function threshold(){var n,e=[.5],r=[0,1],a=1;function scale(i){return null!=i&&i<=i?r[t(e,i,0,a)]:n}scale.domain=function(n){return arguments.length?(e=Array.from(n),a=Math.min(e.length,r.length-1),scale):e.slice()};scale.range=function(n){return arguments.length?(r=Array.from(n),a=Math.min(e.length,r.length-1),scale):r.slice()};scale.invertExtent=function(n){var t=r.indexOf(n);return[e[t-1],e[t]]};scale.unknown=function(e){return arguments.length?(n=e,scale):n};scale.copy=function(){return threshold().domain(e).range(r).unknown(n)};return initRange.apply(scale,arguments)}function date(n){return new Date(n)}function number(n){return n instanceof Date?+n:+new Date(+n)}function calendar(n,e,t,r,a,i,o,l,u,c){var s=continuous(),f=s.invert,g=s.domain;var p=c(\".%L\"),h=c(\":%S\"),m=c(\"%I:%M\"),d=c(\"%I %p\"),y=c(\"%a %d\"),v=c(\"%b %d\"),w=c(\"%B\"),M=c(\"%Y\");function tickFormat(n){return(u(n)<n?p:l(n)<n?h:o(n)<n?m:i(n)<n?d:r(n)<n?a(n)<n?y:v:t(n)<n?w:M)(n)}s.invert=function(n){return new Date(f(n))};s.domain=function(n){return arguments.length?g(Array.from(n,number)):g().map(date)};s.ticks=function(e){var t=g();return n(t[0],t[t.length-1],null==e?10:e)};s.tickFormat=function(n,e){return null==e?tickFormat:c(e)};s.nice=function(n){var t=g();n&&\"function\"===typeof n.range||(n=e(t[0],t[t.length-1],null==n?10:n));return n?g(nice(t,n)):s};s.copy=function(){return copy$1(s,calendar(n,e,t,r,a,i,o,l,u,c))};return s}function time(){return initRange.apply(calendar(w,M,q,k,b,x,$,N,S,O).domain([new Date(2e3,0,1),new Date(2e3,0,2)]),arguments)}function utcTime(){return initRange.apply(calendar(I,R,A,L,P,D,E,F,z,Q).domain([Date.UTC(2e3,0,1),Date.UTC(2e3,0,2)]),arguments)}function transformer$1(){var n,e,t,r,a,i=0,o=1,l=identity$1,u=false;function scale(e){return null==e||isNaN(e=+e)?a:l(0===t?.5:(e=(r(e)-n)*t,u?Math.max(0,Math.min(1,e)):e))}scale.domain=function(a){return arguments.length?([i,o]=a,n=r(i=+i),e=r(o=+o),t=n===e?0:1/(e-n),scale):[i,o]};scale.clamp=function(n){return arguments.length?(u=!!n,scale):u};scale.interpolator=function(n){return arguments.length?(l=n,scale):l};function range(n){return function(e){var t,r;return arguments.length?([t,r]=e,l=n(t,r),scale):[l(0),l(1)]}}scale.range=range(c);scale.rangeRound=range(f);scale.unknown=function(n){return arguments.length?(a=n,scale):a};return function(a){r=a,n=a(i),e=a(o),t=n===e?0:1/(e-n);return scale}}function copy(n,e){return e.domain(n.domain()).interpolator(n.interpolator()).clamp(n.clamp()).unknown(n.unknown())}function sequential(){var n=linearish(transformer$1()(identity$1));n.copy=function(){return copy(n,sequential())};return initInterpolator.apply(n,arguments)}function sequentialLog(){var n=loggish(transformer$1()).domain([1,10]);n.copy=function(){return copy(n,sequentialLog()).base(n.base())};return initInterpolator.apply(n,arguments)}function sequentialSymlog(){var n=symlogish(transformer$1());n.copy=function(){return copy(n,sequentialSymlog()).constant(n.constant())};return initInterpolator.apply(n,arguments)}function sequentialPow(){var n=powish(transformer$1());n.copy=function(){return copy(n,sequentialPow()).exponent(n.exponent())};return initInterpolator.apply(n,arguments)}function sequentialSqrt(){return sequentialPow.apply(null,arguments).exponent(.5)}function sequentialQuantile(){var n=[],e=identity$1;function scale(r){if(null!=r&&!isNaN(r=+r))return e((t(n,r,1)-1)/(n.length-1))}scale.domain=function(e){if(!arguments.length)return n.slice();n=[];for(let t of e)null==t||isNaN(t=+t)||n.push(t);n.sort(l);return scale};scale.interpolator=function(n){return arguments.length?(e=n,scale):e};scale.range=function(){return n.map(((t,r)=>e(r/(n.length-1))))};scale.quantiles=function(e){return Array.from({length:e+1},((t,r)=>u(n,r/e)))};scale.copy=function(){return sequentialQuantile(e).domain(n)};return initInterpolator.apply(scale,arguments)}function transformer(){var n,e,t,r,a,i,o,l=0,u=.5,s=1,p=1,h=identity$1,m=false;function scale(n){return isNaN(n=+n)?o:(n=.5+((n=+i(n))-e)*(p*n<p*e?r:a),h(m?Math.max(0,Math.min(1,n)):n))}scale.domain=function(o){return arguments.length?([l,u,s]=o,n=i(l=+l),e=i(u=+u),t=i(s=+s),r=n===e?0:.5/(e-n),a=e===t?0:.5/(t-e),p=e<n?-1:1,scale):[l,u,s]};scale.clamp=function(n){return arguments.length?(m=!!n,scale):m};scale.interpolator=function(n){return arguments.length?(h=n,scale):h};function range(n){return function(e){var t,r,a;return arguments.length?([t,r,a]=e,h=g(n,[t,r,a]),scale):[h(0),h(.5),h(1)]}}scale.range=range(c);scale.rangeRound=range(f);scale.unknown=function(n){return arguments.length?(o=n,scale):o};return function(o){i=o,n=o(l),e=o(u),t=o(s),r=n===e?0:.5/(e-n),a=e===t?0:.5/(t-e),p=e<n?-1:1;return scale}}function diverging(){var n=linearish(transformer()(identity$1));n.copy=function(){return copy(n,diverging())};return initInterpolator.apply(n,arguments)}function divergingLog(){var n=loggish(transformer()).domain([.1,1,10]);n.copy=function(){return copy(n,divergingLog()).base(n.base())};return initInterpolator.apply(n,arguments)}function divergingSymlog(){var n=symlogish(transformer());n.copy=function(){return copy(n,divergingSymlog()).constant(n.constant())};return initInterpolator.apply(n,arguments)}function divergingPow(){var n=powish(transformer());n.copy=function(){return copy(n,divergingPow()).exponent(n.exponent())};return initInterpolator.apply(n,arguments)}function divergingSqrt(){return divergingPow.apply(null,arguments).exponent(.5)}export{band as scaleBand,diverging as scaleDiverging,divergingLog as scaleDivergingLog,divergingPow as scaleDivergingPow,divergingSqrt as scaleDivergingSqrt,divergingSymlog as scaleDivergingSymlog,identity as scaleIdentity,T as scaleImplicit,linear as scaleLinear,log as scaleLog,ordinal as scaleOrdinal,point as scalePoint,pow as scalePow,quantile as scaleQuantile,quantize as scaleQuantize,radial as scaleRadial,sequential as scaleSequential,sequentialLog as scaleSequentialLog,sequentialPow as scaleSequentialPow,sequentialQuantile as scaleSequentialQuantile,sequentialSqrt as scaleSequentialSqrt,sequentialSymlog as scaleSequentialSymlog,sqrt as scaleSqrt,symlog as scaleSymlog,threshold as scaleThreshold,time as scaleTime,utcTime as scaleUtc,tickFormat};\n\n"
  },
  {
    "path": "vendor/javascript/d3-selection.js",
    "content": "// d3-selection@3.0.0 downloaded from https://ga.jspm.io/npm:d3-selection@3.0.0/src/index.js\n\nvar t=\"http://www.w3.org/1999/xhtml\";var e={svg:\"http://www.w3.org/2000/svg\",xhtml:t,xlink:\"http://www.w3.org/1999/xlink\",xml:\"http://www.w3.org/XML/1998/namespace\",xmlns:\"http://www.w3.org/2000/xmlns/\"};function namespace(t){var n=t+=\"\",r=n.indexOf(\":\");r>=0&&\"xmlns\"!==(n=t.slice(0,r))&&(t=t.slice(r+1));return e.hasOwnProperty(n)?{space:e[n],local:t}:t}function creatorInherit(e){return function(){var n=this.ownerDocument,r=this.namespaceURI;return r===t&&n.documentElement.namespaceURI===t?n.createElement(e):n.createElementNS(r,e)}}function creatorFixed(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function creator(t){var e=namespace(t);return(e.local?creatorFixed:creatorInherit)(e)}function none(){}function selector(t){return null==t?none:function(){return this.querySelector(t)}}function selection_select(t){\"function\"!==typeof t&&(t=selector(t));for(var e=this._groups,n=e.length,r=new Array(n),i=0;i<n;++i)for(var o,s,c=e[i],l=c.length,a=r[i]=new Array(l),u=0;u<l;++u)if((o=c[u])&&(s=t.call(o,o.__data__,u,c))){\"__data__\"in o&&(s.__data__=o.__data__);a[u]=s}return new Selection(r,this._parents)}function array(t){return null==t?[]:Array.isArray(t)?t:Array.from(t)}function empty(){return[]}function selectorAll(t){return null==t?empty:function(){return this.querySelectorAll(t)}}function arrayAll(t){return function(){return array(t.apply(this,arguments))}}function selection_selectAll(t){t=\"function\"===typeof t?arrayAll(t):selectorAll(t);for(var e=this._groups,n=e.length,r=[],i=[],o=0;o<n;++o)for(var s,c=e[o],l=c.length,a=0;a<l;++a)if(s=c[a]){r.push(t.call(s,s.__data__,a,c));i.push(s)}return new Selection(r,i)}function matcher(t){return function(){return this.matches(t)}}function childMatcher(t){return function(e){return e.matches(t)}}var n=Array.prototype.find;function childFind(t){return function(){return n.call(this.children,t)}}function childFirst(){return this.firstElementChild}function selection_selectChild(t){return this.select(null==t?childFirst:childFind(\"function\"===typeof t?t:childMatcher(t)))}var r=Array.prototype.filter;function children(){return Array.from(this.children)}function childrenFilter(t){return function(){return r.call(this.children,t)}}function selection_selectChildren(t){return this.selectAll(null==t?children:childrenFilter(\"function\"===typeof t?t:childMatcher(t)))}function selection_filter(t){\"function\"!==typeof t&&(t=matcher(t));for(var e=this._groups,n=e.length,r=new Array(n),i=0;i<n;++i)for(var o,s=e[i],c=s.length,l=r[i]=[],a=0;a<c;++a)(o=s[a])&&t.call(o,o.__data__,a,s)&&l.push(o);return new Selection(r,this._parents)}function sparse(t){return new Array(t.length)}function selection_enter(){return new Selection(this._enter||this._groups.map(sparse),this._parents)}function EnterNode(t,e){this.ownerDocument=t.ownerDocument;this.namespaceURI=t.namespaceURI;this._next=null;this._parent=t;this.__data__=e}EnterNode.prototype={constructor:EnterNode,appendChild:function(t){return this._parent.insertBefore(t,this._next)},insertBefore:function(t,e){return this._parent.insertBefore(t,e)},querySelector:function(t){return this._parent.querySelector(t)},querySelectorAll:function(t){return this._parent.querySelectorAll(t)}};function constant(t){return function(){return t}}function bindIndex(t,e,n,r,i,o){var s,c=0,l=e.length,a=o.length;for(;c<a;++c)if(s=e[c]){s.__data__=o[c];r[c]=s}else n[c]=new EnterNode(t,o[c]);for(;c<l;++c)(s=e[c])&&(i[c]=s)}function bindKey(t,e,n,r,i,o,s){var c,l,a,u=new Map,h=e.length,f=o.length,p=new Array(h);for(c=0;c<h;++c)if(l=e[c]){p[c]=a=s.call(l,l.__data__,c,e)+\"\";u.has(a)?i[c]=l:u.set(a,l)}for(c=0;c<f;++c){a=s.call(t,o[c],c,o)+\"\";if(l=u.get(a)){r[c]=l;l.__data__=o[c];u.delete(a)}else n[c]=new EnterNode(t,o[c])}for(c=0;c<h;++c)(l=e[c])&&u.get(p[c])===l&&(i[c]=l)}function datum(t){return t.__data__}function selection_data(t,e){if(!arguments.length)return Array.from(this,datum);var n=e?bindKey:bindIndex,r=this._parents,i=this._groups;\"function\"!==typeof t&&(t=constant(t));for(var o=i.length,s=new Array(o),c=new Array(o),l=new Array(o),a=0;a<o;++a){var u=r[a],h=i[a],f=h.length,p=arraylike(t.call(u,u&&u.__data__,a,r)),_=p.length,d=c[a]=new Array(_),y=s[a]=new Array(_),m=l[a]=new Array(f);n(u,h,d,y,m,p,e);for(var v,g,w=0,A=0;w<_;++w)if(v=d[w]){w>=A&&(A=w+1);while(!(g=y[A])&&++A<_);v._next=g||null}}s=new Selection(s,r);s._enter=c;s._exit=l;return s}function arraylike(t){return\"object\"===typeof t&&\"length\"in t?t:Array.from(t)}function selection_exit(){return new Selection(this._exit||this._groups.map(sparse),this._parents)}function selection_join(t,e,n){var r=this.enter(),i=this,o=this.exit();if(\"function\"===typeof t){r=t(r);r&&(r=r.selection())}else r=r.append(t+\"\");if(null!=e){i=e(i);i&&(i=i.selection())}null==n?o.remove():n(o);return r&&i?r.merge(i).order():i}function selection_merge(t){var e=t.selection?t.selection():t;for(var n=this._groups,r=e._groups,i=n.length,o=r.length,s=Math.min(i,o),c=new Array(i),l=0;l<s;++l)for(var a,u=n[l],h=r[l],f=u.length,p=c[l]=new Array(f),_=0;_<f;++_)(a=u[_]||h[_])&&(p[_]=a);for(;l<i;++l)c[l]=n[l];return new Selection(c,this._parents)}function selection_order(){for(var t=this._groups,e=-1,n=t.length;++e<n;)for(var r,i=t[e],o=i.length-1,s=i[o];--o>=0;)if(r=i[o]){s&&4^r.compareDocumentPosition(s)&&s.parentNode.insertBefore(r,s);s=r}return this}function selection_sort(t){t||(t=ascending);function compareNode(e,n){return e&&n?t(e.__data__,n.__data__):!e-!n}for(var e=this._groups,n=e.length,r=new Array(n),i=0;i<n;++i){for(var o,s=e[i],c=s.length,l=r[i]=new Array(c),a=0;a<c;++a)(o=s[a])&&(l[a]=o);l.sort(compareNode)}return new Selection(r,this._parents).order()}function ascending(t,e){return t<e?-1:t>e?1:t>=e?0:NaN}function selection_call(){var t=arguments[0];arguments[0]=this;t.apply(null,arguments);return this}function selection_nodes(){return Array.from(this)}function selection_node(){for(var t=this._groups,e=0,n=t.length;e<n;++e)for(var r=t[e],i=0,o=r.length;i<o;++i){var s=r[i];if(s)return s}return null}function selection_size(){let t=0;for(const e of this)++t;return t}function selection_empty(){return!this.node()}function selection_each(t){for(var e=this._groups,n=0,r=e.length;n<r;++n)for(var i,o=e[n],s=0,c=o.length;s<c;++s)(i=o[s])&&t.call(i,i.__data__,s,o);return this}function attrRemove(t){return function(){this.removeAttribute(t)}}function attrRemoveNS(t){return function(){this.removeAttributeNS(t.space,t.local)}}function attrConstant(t,e){return function(){this.setAttribute(t,e)}}function attrConstantNS(t,e){return function(){this.setAttributeNS(t.space,t.local,e)}}function attrFunction(t,e){return function(){var n=e.apply(this,arguments);null==n?this.removeAttribute(t):this.setAttribute(t,n)}}function attrFunctionNS(t,e){return function(){var n=e.apply(this,arguments);null==n?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,n)}}function selection_attr(t,e){var n=namespace(t);if(arguments.length<2){var r=this.node();return n.local?r.getAttributeNS(n.space,n.local):r.getAttribute(n)}return this.each((null==e?n.local?attrRemoveNS:attrRemove:\"function\"===typeof e?n.local?attrFunctionNS:attrFunction:n.local?attrConstantNS:attrConstant)(n,e))}function defaultView(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}function styleRemove(t){return function(){this.style.removeProperty(t)}}function styleConstant(t,e,n){return function(){this.style.setProperty(t,e,n)}}function styleFunction(t,e,n){return function(){var r=e.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,n)}}function selection_style(t,e,n){return arguments.length>1?this.each((null==e?styleRemove:\"function\"===typeof e?styleFunction:styleConstant)(t,e,null==n?\"\":n)):styleValue(this.node(),t)}function styleValue(t,e){return t.style.getPropertyValue(e)||defaultView(t).getComputedStyle(t,null).getPropertyValue(e)}function propertyRemove(t){return function(){delete this[t]}}function propertyConstant(t,e){return function(){this[t]=e}}function propertyFunction(t,e){return function(){var n=e.apply(this,arguments);null==n?delete this[t]:this[t]=n}}function selection_property(t,e){return arguments.length>1?this.each((null==e?propertyRemove:\"function\"===typeof e?propertyFunction:propertyConstant)(t,e)):this.node()[t]}function classArray(t){return t.trim().split(/^|\\s+/)}function classList(t){return t.classList||new ClassList(t)}function ClassList(t){this._node=t;this._names=classArray(t.getAttribute(\"class\")||\"\")}ClassList.prototype={add:function(t){var e=this._names.indexOf(t);if(e<0){this._names.push(t);this._node.setAttribute(\"class\",this._names.join(\" \"))}},remove:function(t){var e=this._names.indexOf(t);if(e>=0){this._names.splice(e,1);this._node.setAttribute(\"class\",this._names.join(\" \"))}},contains:function(t){return this._names.indexOf(t)>=0}};function classedAdd(t,e){var n=classList(t),r=-1,i=e.length;while(++r<i)n.add(e[r])}function classedRemove(t,e){var n=classList(t),r=-1,i=e.length;while(++r<i)n.remove(e[r])}function classedTrue(t){return function(){classedAdd(this,t)}}function classedFalse(t){return function(){classedRemove(this,t)}}function classedFunction(t,e){return function(){(e.apply(this,arguments)?classedAdd:classedRemove)(this,t)}}function selection_classed(t,e){var n=classArray(t+\"\");if(arguments.length<2){var r=classList(this.node()),i=-1,o=n.length;while(++i<o)if(!r.contains(n[i]))return false;return true}return this.each((\"function\"===typeof e?classedFunction:e?classedTrue:classedFalse)(n,e))}function textRemove(){this.textContent=\"\"}function textConstant(t){return function(){this.textContent=t}}function textFunction(t){return function(){var e=t.apply(this,arguments);this.textContent=null==e?\"\":e}}function selection_text(t){return arguments.length?this.each(null==t?textRemove:(\"function\"===typeof t?textFunction:textConstant)(t)):this.node().textContent}function htmlRemove(){this.innerHTML=\"\"}function htmlConstant(t){return function(){this.innerHTML=t}}function htmlFunction(t){return function(){var e=t.apply(this,arguments);this.innerHTML=null==e?\"\":e}}function selection_html(t){return arguments.length?this.each(null==t?htmlRemove:(\"function\"===typeof t?htmlFunction:htmlConstant)(t)):this.node().innerHTML}function raise(){this.nextSibling&&this.parentNode.appendChild(this)}function selection_raise(){return this.each(raise)}function lower(){this.previousSibling&&this.parentNode.insertBefore(this,this.parentNode.firstChild)}function selection_lower(){return this.each(lower)}function selection_append(t){var e=\"function\"===typeof t?t:creator(t);return this.select((function(){return this.appendChild(e.apply(this,arguments))}))}function constantNull(){return null}function selection_insert(t,e){var n=\"function\"===typeof t?t:creator(t),r=null==e?constantNull:\"function\"===typeof e?e:selector(e);return this.select((function(){return this.insertBefore(n.apply(this,arguments),r.apply(this,arguments)||null)}))}function remove(){var t=this.parentNode;t&&t.removeChild(this)}function selection_remove(){return this.each(remove)}function selection_cloneShallow(){var t=this.cloneNode(false),e=this.parentNode;return e?e.insertBefore(t,this.nextSibling):t}function selection_cloneDeep(){var t=this.cloneNode(true),e=this.parentNode;return e?e.insertBefore(t,this.nextSibling):t}function selection_clone(t){return this.select(t?selection_cloneDeep:selection_cloneShallow)}function selection_datum(t){return arguments.length?this.property(\"__data__\",t):this.node().__data__}function contextListener(t){return function(e){t.call(this,e,this.__data__)}}function parseTypenames(t){return t.trim().split(/^|\\s+/).map((function(t){var e=\"\",n=t.indexOf(\".\");n>=0&&(e=t.slice(n+1),t=t.slice(0,n));return{type:t,name:e}}))}function onRemove(t){return function(){var e=this.__on;if(e){for(var n,r=0,i=-1,o=e.length;r<o;++r)n=e[r],t.type&&n.type!==t.type||n.name!==t.name?e[++i]=n:this.removeEventListener(n.type,n.listener,n.options);++i?e.length=i:delete this.__on}}}function onAdd(t,e,n){return function(){var r,i=this.__on,o=contextListener(e);if(i)for(var s=0,c=i.length;s<c;++s)if((r=i[s]).type===t.type&&r.name===t.name){this.removeEventListener(r.type,r.listener,r.options);this.addEventListener(r.type,r.listener=o,r.options=n);r.value=e;return}this.addEventListener(t.type,o,n);r={type:t.type,name:t.name,value:e,listener:o,options:n};i?i.push(r):this.__on=[r]}}function selection_on(t,e,n){var r,i,o=parseTypenames(t+\"\"),s=o.length;if(!(arguments.length<2)){c=e?onAdd:onRemove;for(r=0;r<s;++r)this.each(c(o[r],e,n));return this}var c=this.node().__on;if(c)for(var l,a=0,u=c.length;a<u;++a)for(r=0,l=c[a];r<s;++r)if((i=o[r]).type===l.type&&i.name===l.name)return l.value}function dispatchEvent(t,e,n){var r=defaultView(t),i=r.CustomEvent;if(\"function\"===typeof i)i=new i(e,n);else{i=r.document.createEvent(\"Event\");n?(i.initEvent(e,n.bubbles,n.cancelable),i.detail=n.detail):i.initEvent(e,false,false)}t.dispatchEvent(i)}function dispatchConstant(t,e){return function(){return dispatchEvent(this,t,e)}}function dispatchFunction(t,e){return function(){return dispatchEvent(this,t,e.apply(this,arguments))}}function selection_dispatch(t,e){return this.each((\"function\"===typeof e?dispatchFunction:dispatchConstant)(t,e))}function*selection_iterator(){for(var t=this._groups,e=0,n=t.length;e<n;++e)for(var r,i=t[e],o=0,s=i.length;o<s;++o)(r=i[o])&&(yield r)}var i=[null];function Selection(t,e){this._groups=t;this._parents=e}function selection(){return new Selection([[document.documentElement]],i)}function selection_selection(){return this}Selection.prototype=selection.prototype={constructor:Selection,select:selection_select,selectAll:selection_selectAll,selectChild:selection_selectChild,selectChildren:selection_selectChildren,filter:selection_filter,data:selection_data,enter:selection_enter,exit:selection_exit,join:selection_join,merge:selection_merge,selection:selection_selection,order:selection_order,sort:selection_sort,call:selection_call,nodes:selection_nodes,node:selection_node,size:selection_size,empty:selection_empty,each:selection_each,attr:selection_attr,style:selection_style,property:selection_property,classed:selection_classed,text:selection_text,html:selection_html,raise:selection_raise,lower:selection_lower,append:selection_append,insert:selection_insert,remove:selection_remove,clone:selection_clone,datum:selection_datum,on:selection_on,dispatch:selection_dispatch,[Symbol.iterator]:selection_iterator};function select(t){return\"string\"===typeof t?new Selection([[document.querySelector(t)]],[document.documentElement]):new Selection([[t]],i)}function create(t){return select(creator(t).call(document.documentElement))}var o=0;function local(){return new Local}function Local(){this._=\"@\"+(++o).toString(36)}Local.prototype=local.prototype={constructor:Local,get:function(t){var e=this._;while(!(e in t))if(!(t=t.parentNode))return;return t[e]},set:function(t,e){return t[this._]=e},remove:function(t){return this._ in t&&delete t[this._]},toString:function(){return this._}};function sourceEvent(t){let e;while(e=t.sourceEvent)t=e;return t}function pointer(t,e){t=sourceEvent(t);void 0===e&&(e=t.currentTarget);if(e){var n=e.ownerSVGElement||e;if(n.createSVGPoint){var r=n.createSVGPoint();r.x=t.clientX,r.y=t.clientY;r=r.matrixTransform(e.getScreenCTM().inverse());return[r.x,r.y]}if(e.getBoundingClientRect){var i=e.getBoundingClientRect();return[t.clientX-i.left-e.clientLeft,t.clientY-i.top-e.clientTop]}}return[t.pageX,t.pageY]}function pointers(t,e){if(t.target){t=sourceEvent(t);void 0===e&&(e=t.currentTarget);t=t.touches||[t]}return Array.from(t,(t=>pointer(t,e)))}function selectAll(t){return\"string\"===typeof t?new Selection([document.querySelectorAll(t)],[document.documentElement]):new Selection([array(t)],i)}export{create,creator,local,matcher,namespace,e as namespaces,pointer,pointers,select,selectAll,selection,selector,selectorAll,styleValue as style,defaultView as window};\n\n"
  },
  {
    "path": "vendor/javascript/d3-shape.js",
    "content": "// d3-shape@1.3.7 downloaded from https://ga.jspm.io/npm:d3-shape@1.3.7/dist/d3-shape.js\n\nimport t from\"d3-path\";var n=\"undefined\"!==typeof globalThis?globalThis:\"undefined\"!==typeof self?self:global;var i={};(function(n,e){e(i,t)})(i,(function(t,i){function constant(t){return function constant(){return t}}var e=Math.abs;var a=Math.atan2;var s=Math.cos;var o=Math.max;var r=Math.min;var l=Math.sin;var h=Math.sqrt;var c=1e-12;var _=Math.PI;var u=_/2;var f=2*_;function acos(t){return t>1?0:t<-1?_:Math.acos(t)}function asin(t){return t>=1?u:t<=-1?-u:Math.asin(t)}function arcInnerRadius(t){return t.innerRadius}function arcOuterRadius(t){return t.outerRadius}function arcStartAngle(t){return t.startAngle}function arcEndAngle(t){return t.endAngle}function arcPadAngle(t){return t&&t.padAngle}function intersect(t,n,i,e,a,s,o,r){var l=i-t,h=e-n,_=o-a,u=r-s,f=u*l-_*h;if(!(f*f<c)){f=(_*(n-s)-u*(t-a))/f;return[t+f*l,n+f*h]}}function cornerTangents(t,n,i,e,a,s,r){var l=t-i,c=n-e,_=(r?s:-s)/h(l*l+c*c),u=_*c,f=-_*l,p=t+u,d=n+f,v=i+u,m=e+f,k=(p+v)/2,g=(d+m)/2,b=v-p,T=m-d,R=b*b+T*T,C=a-s,w=p*m-v*d,M=(T<0?-1:1)*h(o(0,C*C*R-w*w)),S=(w*T-b*M)/R,N=(-w*b-T*M)/R,O=(w*T+b*M)/R,A=(-w*b+T*M)/R,E=S-k,P=N-g,$=O-k,B=A-g;E*E+P*P>$*$+B*B&&(S=O,N=A);return{cx:S,cy:N,x01:-u,y01:-f,x11:S*(a/C-1),y11:N*(a/C-1)}}function arc(){var t=arcInnerRadius,o=arcOuterRadius,p=constant(0),d=null,v=arcStartAngle,m=arcEndAngle,k=arcPadAngle,g=null;function arc(){var b,T,R=+t.apply(this||n,arguments),C=+o.apply(this||n,arguments),w=v.apply(this||n,arguments)-u,M=m.apply(this||n,arguments)-u,S=e(M-w),N=M>w;g||(g=b=i.path());C<R&&(T=C,C=R,R=T);if(C>c)if(S>f-c){g.moveTo(C*s(w),C*l(w));g.arc(0,0,C,w,M,!N);if(R>c){g.moveTo(R*s(M),R*l(M));g.arc(0,0,R,M,w,N)}}else{var O=w,A=M,E=w,P=M,$=S,B=S,q=k.apply(this||n,arguments)/2,z=q>c&&(d?+d.apply(this||n,arguments):h(R*R+C*C)),L=r(e(C-R)/2,+p.apply(this||n,arguments)),X=L,Y=L,V,I;if(z>c){var D=asin(z/R*l(q)),H=asin(z/C*l(q));($-=2*D)>c?(D*=N?1:-1,E+=D,P-=D):($=0,E=P=(w+M)/2);(B-=2*H)>c?(H*=N?1:-1,O+=H,A-=H):(B=0,O=A=(w+M)/2)}var W=C*s(O),j=C*l(O),F=R*s(P),G=R*l(P);if(L>c){var J=C*s(A),K=C*l(A),Q=R*s(E),U=R*l(E),Z;if(S<_&&(Z=intersect(W,j,Q,U,J,K,F,G))){var tt=W-Z[0],nt=j-Z[1],it=J-Z[0],et=K-Z[1],at=1/l(acos((tt*it+nt*et)/(h(tt*tt+nt*nt)*h(it*it+et*et)))/2),st=h(Z[0]*Z[0]+Z[1]*Z[1]);X=r(L,(R-st)/(at-1));Y=r(L,(C-st)/(at+1))}}if(B>c)if(Y>c){V=cornerTangents(Q,U,W,j,C,Y,N);I=cornerTangents(J,K,F,G,C,Y,N);g.moveTo(V.cx+V.x01,V.cy+V.y01);if(Y<L)g.arc(V.cx,V.cy,Y,a(V.y01,V.x01),a(I.y01,I.x01),!N);else{g.arc(V.cx,V.cy,Y,a(V.y01,V.x01),a(V.y11,V.x11),!N);g.arc(0,0,C,a(V.cy+V.y11,V.cx+V.x11),a(I.cy+I.y11,I.cx+I.x11),!N);g.arc(I.cx,I.cy,Y,a(I.y11,I.x11),a(I.y01,I.x01),!N)}}else g.moveTo(W,j),g.arc(0,0,C,O,A,!N);else g.moveTo(W,j);if(R>c&&$>c)if(X>c){V=cornerTangents(F,G,J,K,R,-X,N);I=cornerTangents(W,j,Q,U,R,-X,N);g.lineTo(V.cx+V.x01,V.cy+V.y01);if(X<L)g.arc(V.cx,V.cy,X,a(V.y01,V.x01),a(I.y01,I.x01),!N);else{g.arc(V.cx,V.cy,X,a(V.y01,V.x01),a(V.y11,V.x11),!N);g.arc(0,0,R,a(V.cy+V.y11,V.cx+V.x11),a(I.cy+I.y11,I.cx+I.x11),N);g.arc(I.cx,I.cy,X,a(I.y11,I.x11),a(I.y01,I.x01),!N)}}else g.arc(0,0,R,P,E,N);else g.lineTo(F,G)}else g.moveTo(0,0);g.closePath();if(b)return g=null,b+\"\"||null}arc.centroid=function(){var i=(+t.apply(this||n,arguments)+ +o.apply(this||n,arguments))/2,e=(+v.apply(this||n,arguments)+ +m.apply(this||n,arguments))/2-_/2;return[s(e)*i,l(e)*i]};arc.innerRadius=function(n){return arguments.length?(t=\"function\"===typeof n?n:constant(+n),arc):t};arc.outerRadius=function(t){return arguments.length?(o=\"function\"===typeof t?t:constant(+t),arc):o};arc.cornerRadius=function(t){return arguments.length?(p=\"function\"===typeof t?t:constant(+t),arc):p};arc.padRadius=function(t){return arguments.length?(d=null==t?null:\"function\"===typeof t?t:constant(+t),arc):d};arc.startAngle=function(t){return arguments.length?(v=\"function\"===typeof t?t:constant(+t),arc):v};arc.endAngle=function(t){return arguments.length?(m=\"function\"===typeof t?t:constant(+t),arc):m};arc.padAngle=function(t){return arguments.length?(k=\"function\"===typeof t?t:constant(+t),arc):k};arc.context=function(t){return arguments.length?(g=null==t?null:t,arc):g};return arc}function Linear(t){(this||n)._context=t}Linear.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._point=0},lineEnd:function(){((this||n)._line||0!==(this||n)._line&&1===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._line?(this||n)._context.lineTo(t,i):(this||n)._context.moveTo(t,i);break;case 1:(this||n)._point=2;default:(this||n)._context.lineTo(t,i);break}}};function curveLinear(t){return new Linear(t)}function x(t){return t[0]}function y(t){return t[1]}function line(){var t=x,n=y,e=constant(true),a=null,s=curveLinear,o=null;function line(r){var l,h=r.length,c,_=false,u;null==a&&(o=s(u=i.path()));for(l=0;l<=h;++l){!(l<h&&e(c=r[l],l,r))===_&&((_=!_)?o.lineStart():o.lineEnd());_&&o.point(+t(c,l,r),+n(c,l,r))}if(u)return o=null,u+\"\"||null}line.x=function(n){return arguments.length?(t=\"function\"===typeof n?n:constant(+n),line):t};line.y=function(t){return arguments.length?(n=\"function\"===typeof t?t:constant(+t),line):n};line.defined=function(t){return arguments.length?(e=\"function\"===typeof t?t:constant(!!t),line):e};line.curve=function(t){return arguments.length?(s=t,null!=a&&(o=s(a)),line):s};line.context=function(t){return arguments.length?(null==t?a=o=null:o=s(a=t),line):a};return line}function area(){var t=x,n=null,e=constant(0),a=y,s=constant(true),o=null,r=curveLinear,l=null;function area(h){var c,_,u,f=h.length,p,d=false,v,m=new Array(f),k=new Array(f);null==o&&(l=r(v=i.path()));for(c=0;c<=f;++c){if(!(c<f&&s(p=h[c],c,h))===d)if(d=!d){_=c;l.areaStart();l.lineStart()}else{l.lineEnd();l.lineStart();for(u=c-1;u>=_;--u)l.point(m[u],k[u]);l.lineEnd();l.areaEnd()}if(d){m[c]=+t(p,c,h),k[c]=+e(p,c,h);l.point(n?+n(p,c,h):m[c],a?+a(p,c,h):k[c])}}if(v)return l=null,v+\"\"||null}function arealine(){return line().defined(s).curve(r).context(o)}area.x=function(i){return arguments.length?(t=\"function\"===typeof i?i:constant(+i),n=null,area):t};area.x0=function(n){return arguments.length?(t=\"function\"===typeof n?n:constant(+n),area):t};area.x1=function(t){return arguments.length?(n=null==t?null:\"function\"===typeof t?t:constant(+t),area):n};area.y=function(t){return arguments.length?(e=\"function\"===typeof t?t:constant(+t),a=null,area):e};area.y0=function(t){return arguments.length?(e=\"function\"===typeof t?t:constant(+t),area):e};area.y1=function(t){return arguments.length?(a=null==t?null:\"function\"===typeof t?t:constant(+t),area):a};area.lineX0=area.lineY0=function(){return arealine().x(t).y(e)};area.lineY1=function(){return arealine().x(t).y(a)};area.lineX1=function(){return arealine().x(n).y(e)};area.defined=function(t){return arguments.length?(s=\"function\"===typeof t?t:constant(!!t),area):s};area.curve=function(t){return arguments.length?(r=t,null!=o&&(l=r(o)),area):r};area.context=function(t){return arguments.length?(null==t?o=l=null:l=r(o=t),area):o};return area}function descending(t,n){return n<t?-1:n>t?1:n>=t?0:NaN}function identity(t){return t}function pie(){var t=identity,i=descending,e=null,a=constant(0),s=constant(f),o=constant(0);function pie(r){var l,h=r.length,c,_,u=0,p=new Array(h),d=new Array(h),v=+a.apply(this||n,arguments),m=Math.min(f,Math.max(-f,s.apply(this||n,arguments)-v)),k,g=Math.min(Math.abs(m)/h,o.apply(this||n,arguments)),b=g*(m<0?-1:1),T;for(l=0;l<h;++l)(T=d[p[l]=l]=+t(r[l],l,r))>0&&(u+=T);null!=i?p.sort((function(t,n){return i(d[t],d[n])})):null!=e&&p.sort((function(t,n){return e(r[t],r[n])}));for(l=0,_=u?(m-h*b)/u:0;l<h;++l,v=k)c=p[l],T=d[c],k=v+(T>0?T*_:0)+b,d[c]={data:r[c],index:l,value:T,startAngle:v,endAngle:k,padAngle:g};return d}pie.value=function(n){return arguments.length?(t=\"function\"===typeof n?n:constant(+n),pie):t};pie.sortValues=function(t){return arguments.length?(i=t,e=null,pie):i};pie.sort=function(t){return arguments.length?(e=t,i=null,pie):e};pie.startAngle=function(t){return arguments.length?(a=\"function\"===typeof t?t:constant(+t),pie):a};pie.endAngle=function(t){return arguments.length?(s=\"function\"===typeof t?t:constant(+t),pie):s};pie.padAngle=function(t){return arguments.length?(o=\"function\"===typeof t?t:constant(+t),pie):o};return pie}var p=curveRadial(curveLinear);function Radial(t){(this||n)._curve=t}Radial.prototype={areaStart:function(){(this||n)._curve.areaStart()},areaEnd:function(){(this||n)._curve.areaEnd()},lineStart:function(){(this||n)._curve.lineStart()},lineEnd:function(){(this||n)._curve.lineEnd()},point:function(t,i){(this||n)._curve.point(i*Math.sin(t),i*-Math.cos(t))}};function curveRadial(t){function radial(n){return new Radial(t(n))}radial._curve=t;return radial}function lineRadial(t){var n=t.curve;t.angle=t.x,delete t.x;t.radius=t.y,delete t.y;t.curve=function(t){return arguments.length?n(curveRadial(t)):n()._curve};return t}function lineRadial$1(){return lineRadial(line().curve(p))}function areaRadial(){var t=area().curve(p),n=t.curve,i=t.lineX0,e=t.lineX1,a=t.lineY0,s=t.lineY1;t.angle=t.x,delete t.x;t.startAngle=t.x0,delete t.x0;t.endAngle=t.x1,delete t.x1;t.radius=t.y,delete t.y;t.innerRadius=t.y0,delete t.y0;t.outerRadius=t.y1,delete t.y1;t.lineStartAngle=function(){return lineRadial(i())},delete t.lineX0;t.lineEndAngle=function(){return lineRadial(e())},delete t.lineX1;t.lineInnerRadius=function(){return lineRadial(a())},delete t.lineY0;t.lineOuterRadius=function(){return lineRadial(s())},delete t.lineY1;t.curve=function(t){return arguments.length?n(curveRadial(t)):n()._curve};return t}function pointRadial(t,n){return[(n=+n)*Math.cos(t-=Math.PI/2),n*Math.sin(t)]}var d=Array.prototype.slice;function linkSource(t){return t.source}function linkTarget(t){return t.target}function link(t){var e=linkSource,a=linkTarget,s=x,o=y,r=null;function link(){var l,h=d.call(arguments),c=e.apply(this||n,h),_=a.apply(this||n,h);r||(r=l=i.path());t(r,+s.apply(this||n,(h[0]=c,h)),+o.apply(this||n,h),+s.apply(this||n,(h[0]=_,h)),+o.apply(this||n,h));if(l)return r=null,l+\"\"||null}link.source=function(t){return arguments.length?(e=t,link):e};link.target=function(t){return arguments.length?(a=t,link):a};link.x=function(t){return arguments.length?(s=\"function\"===typeof t?t:constant(+t),link):s};link.y=function(t){return arguments.length?(o=\"function\"===typeof t?t:constant(+t),link):o};link.context=function(t){return arguments.length?(r=null==t?null:t,link):r};return link}function curveHorizontal(t,n,i,e,a){t.moveTo(n,i);t.bezierCurveTo(n=(n+e)/2,i,n,a,e,a)}function curveVertical(t,n,i,e,a){t.moveTo(n,i);t.bezierCurveTo(n,i=(i+a)/2,e,i,e,a)}function curveRadial$1(t,n,i,e,a){var s=pointRadial(n,i),o=pointRadial(n,i=(i+a)/2),r=pointRadial(e,i),l=pointRadial(e,a);t.moveTo(s[0],s[1]);t.bezierCurveTo(o[0],o[1],r[0],r[1],l[0],l[1])}function linkHorizontal(){return link(curveHorizontal)}function linkVertical(){return link(curveVertical)}function linkRadial(){var t=link(curveRadial$1);t.angle=t.x,delete t.x;t.radius=t.y,delete t.y;return t}var v={draw:function(t,n){var i=Math.sqrt(n/_);t.moveTo(i,0);t.arc(0,0,i,0,f)}};var m={draw:function(t,n){var i=Math.sqrt(n/5)/2;t.moveTo(-3*i,-i);t.lineTo(-i,-i);t.lineTo(-i,-3*i);t.lineTo(i,-3*i);t.lineTo(i,-i);t.lineTo(3*i,-i);t.lineTo(3*i,i);t.lineTo(i,i);t.lineTo(i,3*i);t.lineTo(-i,3*i);t.lineTo(-i,i);t.lineTo(-3*i,i);t.closePath()}};var k=Math.sqrt(1/3),g=2*k;var b={draw:function(t,n){var i=Math.sqrt(n/g),e=i*k;t.moveTo(0,-i);t.lineTo(e,0);t.lineTo(0,i);t.lineTo(-e,0);t.closePath()}};var T=.8908130915292852,R=Math.sin(_/10)/Math.sin(7*_/10),C=Math.sin(f/10)*R,w=-Math.cos(f/10)*R;var M={draw:function(t,n){var i=Math.sqrt(n*T),e=C*i,a=w*i;t.moveTo(0,-i);t.lineTo(e,a);for(var s=1;s<5;++s){var o=f*s/5,r=Math.cos(o),l=Math.sin(o);t.lineTo(l*i,-r*i);t.lineTo(r*e-l*a,l*e+r*a)}t.closePath()}};var S={draw:function(t,n){var i=Math.sqrt(n),e=-i/2;t.rect(e,e,i,i)}};var N=Math.sqrt(3);var O={draw:function(t,n){var i=-Math.sqrt(n/(3*N));t.moveTo(0,2*i);t.lineTo(-N*i,-i);t.lineTo(N*i,-i);t.closePath()}};var A=-.5,E=Math.sqrt(3)/2,P=1/Math.sqrt(12),$=3*(P/2+1);var B={draw:function(t,n){var i=Math.sqrt(n/$),e=i/2,a=i*P,s=e,o=i*P+i,r=-s,l=o;t.moveTo(e,a);t.lineTo(s,o);t.lineTo(r,l);t.lineTo(A*e-E*a,E*e+A*a);t.lineTo(A*s-E*o,E*s+A*o);t.lineTo(A*r-E*l,E*r+A*l);t.lineTo(A*e+E*a,A*a-E*e);t.lineTo(A*s+E*o,A*o-E*s);t.lineTo(A*r+E*l,A*l-E*r);t.closePath()}};var q=[v,m,b,S,M,O,B];function symbol(){var t=constant(v),e=constant(64),a=null;function symbol(){var s;a||(a=s=i.path());t.apply(this||n,arguments).draw(a,+e.apply(this||n,arguments));if(s)return a=null,s+\"\"||null}symbol.type=function(n){return arguments.length?(t=\"function\"===typeof n?n:constant(n),symbol):t};symbol.size=function(t){return arguments.length?(e=\"function\"===typeof t?t:constant(+t),symbol):e};symbol.context=function(t){return arguments.length?(a=null==t?null:t,symbol):a};return symbol}function noop(){}function point(t,n,i){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+n)/6,(t._y0+4*t._y1+i)/6)}function Basis(t){(this||n)._context=t}Basis.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._y0=(this||n)._y1=NaN;(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 3:point(this||n,(this||n)._x1,(this||n)._y1);case 2:(this||n)._context.lineTo((this||n)._x1,(this||n)._y1);break}((this||n)._line||0!==(this||n)._line&&1===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._line?(this||n)._context.lineTo(t,i):(this||n)._context.moveTo(t,i);break;case 1:(this||n)._point=2;break;case 2:(this||n)._point=3;(this||n)._context.lineTo((5*(this||n)._x0+(this||n)._x1)/6,(5*(this||n)._y0+(this||n)._y1)/6);default:point(this||n,t,i);break}(this||n)._x0=(this||n)._x1,(this||n)._x1=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=i}};function basis(t){return new Basis(t)}function BasisClosed(t){(this||n)._context=t}BasisClosed.prototype={areaStart:noop,areaEnd:noop,lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._x3=(this||n)._x4=(this||n)._y0=(this||n)._y1=(this||n)._y2=(this||n)._y3=(this||n)._y4=NaN;(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 1:(this||n)._context.moveTo((this||n)._x2,(this||n)._y2);(this||n)._context.closePath();break;case 2:(this||n)._context.moveTo(((this||n)._x2+2*(this||n)._x3)/3,((this||n)._y2+2*(this||n)._y3)/3);(this||n)._context.lineTo(((this||n)._x3+2*(this||n)._x2)/3,((this||n)._y3+2*(this||n)._y2)/3);(this||n)._context.closePath();break;case 3:this.point((this||n)._x2,(this||n)._y2);this.point((this||n)._x3,(this||n)._y3);this.point((this||n)._x4,(this||n)._y4);break}},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._x2=t,(this||n)._y2=i;break;case 1:(this||n)._point=2;(this||n)._x3=t,(this||n)._y3=i;break;case 2:(this||n)._point=3;(this||n)._x4=t,(this||n)._y4=i;(this||n)._context.moveTo(((this||n)._x0+4*(this||n)._x1+t)/6,((this||n)._y0+4*(this||n)._y1+i)/6);break;default:point(this||n,t,i);break}(this||n)._x0=(this||n)._x1,(this||n)._x1=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=i}};function basisClosed(t){return new BasisClosed(t)}function BasisOpen(t){(this||n)._context=t}BasisOpen.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._y0=(this||n)._y1=NaN;(this||n)._point=0},lineEnd:function(){((this||n)._line||0!==(this||n)._line&&3===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;break;case 1:(this||n)._point=2;break;case 2:(this||n)._point=3;var e=((this||n)._x0+4*(this||n)._x1+t)/6,a=((this||n)._y0+4*(this||n)._y1+i)/6;(this||n)._line?(this||n)._context.lineTo(e,a):(this||n)._context.moveTo(e,a);break;case 3:(this||n)._point=4;default:point(this||n,t,i);break}(this||n)._x0=(this||n)._x1,(this||n)._x1=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=i}};function basisOpen(t){return new BasisOpen(t)}function Bundle(t,i){(this||n)._basis=new Basis(t);(this||n)._beta=i}Bundle.prototype={lineStart:function(){(this||n)._x=[];(this||n)._y=[];(this||n)._basis.lineStart()},lineEnd:function(){var t=(this||n)._x,i=(this||n)._y,e=t.length-1;if(e>0){var a=t[0],s=i[0],o=t[e]-a,r=i[e]-s,l=-1,h;while(++l<=e){h=l/e;(this||n)._basis.point((this||n)._beta*t[l]+(1-(this||n)._beta)*(a+h*o),(this||n)._beta*i[l]+(1-(this||n)._beta)*(s+h*r))}}(this||n)._x=(this||n)._y=null;(this||n)._basis.lineEnd()},point:function(t,i){(this||n)._x.push(+t);(this||n)._y.push(+i)}};var z=function custom(t){function bundle(n){return 1===t?new Basis(n):new Bundle(n,t)}bundle.beta=function(t){return custom(+t)};return bundle}(.85);function point$1(t,n,i){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-n),t._y2+t._k*(t._y1-i),t._x2,t._y2)}function Cardinal(t,i){(this||n)._context=t;(this||n)._k=(1-i)/6}Cardinal.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._y0=(this||n)._y1=(this||n)._y2=NaN;(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 2:(this||n)._context.lineTo((this||n)._x2,(this||n)._y2);break;case 3:point$1(this||n,(this||n)._x1,(this||n)._y1);break}((this||n)._line||0!==(this||n)._line&&1===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._line?(this||n)._context.lineTo(t,i):(this||n)._context.moveTo(t,i);break;case 1:(this||n)._point=2;(this||n)._x1=t,(this||n)._y1=i;break;case 2:(this||n)._point=3;default:point$1(this||n,t,i);break}(this||n)._x0=(this||n)._x1,(this||n)._x1=(this||n)._x2,(this||n)._x2=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=(this||n)._y2,(this||n)._y2=i}};var L=function custom(t){function cardinal(n){return new Cardinal(n,t)}cardinal.tension=function(t){return custom(+t)};return cardinal}(0);function CardinalClosed(t,i){(this||n)._context=t;(this||n)._k=(1-i)/6}CardinalClosed.prototype={areaStart:noop,areaEnd:noop,lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._x3=(this||n)._x4=(this||n)._x5=(this||n)._y0=(this||n)._y1=(this||n)._y2=(this||n)._y3=(this||n)._y4=(this||n)._y5=NaN;(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 1:(this||n)._context.moveTo((this||n)._x3,(this||n)._y3);(this||n)._context.closePath();break;case 2:(this||n)._context.lineTo((this||n)._x3,(this||n)._y3);(this||n)._context.closePath();break;case 3:this.point((this||n)._x3,(this||n)._y3);this.point((this||n)._x4,(this||n)._y4);this.point((this||n)._x5,(this||n)._y5);break}},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._x3=t,(this||n)._y3=i;break;case 1:(this||n)._point=2;(this||n)._context.moveTo((this||n)._x4=t,(this||n)._y4=i);break;case 2:(this||n)._point=3;(this||n)._x5=t,(this||n)._y5=i;break;default:point$1(this||n,t,i);break}(this||n)._x0=(this||n)._x1,(this||n)._x1=(this||n)._x2,(this||n)._x2=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=(this||n)._y2,(this||n)._y2=i}};var X=function custom(t){function cardinal(n){return new CardinalClosed(n,t)}cardinal.tension=function(t){return custom(+t)};return cardinal}(0);function CardinalOpen(t,i){(this||n)._context=t;(this||n)._k=(1-i)/6}CardinalOpen.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._y0=(this||n)._y1=(this||n)._y2=NaN;(this||n)._point=0},lineEnd:function(){((this||n)._line||0!==(this||n)._line&&3===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;break;case 1:(this||n)._point=2;break;case 2:(this||n)._point=3;(this||n)._line?(this||n)._context.lineTo((this||n)._x2,(this||n)._y2):(this||n)._context.moveTo((this||n)._x2,(this||n)._y2);break;case 3:(this||n)._point=4;default:point$1(this||n,t,i);break}(this||n)._x0=(this||n)._x1,(this||n)._x1=(this||n)._x2,(this||n)._x2=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=(this||n)._y2,(this||n)._y2=i}};var Y=function custom(t){function cardinal(n){return new CardinalOpen(n,t)}cardinal.tension=function(t){return custom(+t)};return cardinal}(0);function point$2(t,n,i){var e=t._x1,a=t._y1,s=t._x2,o=t._y2;if(t._l01_a>c){var r=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,l=3*t._l01_a*(t._l01_a+t._l12_a);e=(e*r-t._x0*t._l12_2a+t._x2*t._l01_2a)/l;a=(a*r-t._y0*t._l12_2a+t._y2*t._l01_2a)/l}if(t._l23_a>c){var h=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,_=3*t._l23_a*(t._l23_a+t._l12_a);s=(s*h+t._x1*t._l23_2a-n*t._l12_2a)/_;o=(o*h+t._y1*t._l23_2a-i*t._l12_2a)/_}t._context.bezierCurveTo(e,a,s,o,t._x2,t._y2)}function CatmullRom(t,i){(this||n)._context=t;(this||n)._alpha=i}CatmullRom.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._y0=(this||n)._y1=(this||n)._y2=NaN;(this||n)._l01_a=(this||n)._l12_a=(this||n)._l23_a=(this||n)._l01_2a=(this||n)._l12_2a=(this||n)._l23_2a=(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 2:(this||n)._context.lineTo((this||n)._x2,(this||n)._y2);break;case 3:this.point((this||n)._x2,(this||n)._y2);break}((this||n)._line||0!==(this||n)._line&&1===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;if((this||n)._point){var e=(this||n)._x2-t,a=(this||n)._y2-i;(this||n)._l23_a=Math.sqrt((this||n)._l23_2a=Math.pow(e*e+a*a,(this||n)._alpha))}switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._line?(this||n)._context.lineTo(t,i):(this||n)._context.moveTo(t,i);break;case 1:(this||n)._point=2;break;case 2:(this||n)._point=3;default:point$2(this||n,t,i);break}(this||n)._l01_a=(this||n)._l12_a,(this||n)._l12_a=(this||n)._l23_a;(this||n)._l01_2a=(this||n)._l12_2a,(this||n)._l12_2a=(this||n)._l23_2a;(this||n)._x0=(this||n)._x1,(this||n)._x1=(this||n)._x2,(this||n)._x2=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=(this||n)._y2,(this||n)._y2=i}};var V=function custom(t){function catmullRom(n){return t?new CatmullRom(n,t):new Cardinal(n,0)}catmullRom.alpha=function(t){return custom(+t)};return catmullRom}(.5);function CatmullRomClosed(t,i){(this||n)._context=t;(this||n)._alpha=i}CatmullRomClosed.prototype={areaStart:noop,areaEnd:noop,lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._x3=(this||n)._x4=(this||n)._x5=(this||n)._y0=(this||n)._y1=(this||n)._y2=(this||n)._y3=(this||n)._y4=(this||n)._y5=NaN;(this||n)._l01_a=(this||n)._l12_a=(this||n)._l23_a=(this||n)._l01_2a=(this||n)._l12_2a=(this||n)._l23_2a=(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 1:(this||n)._context.moveTo((this||n)._x3,(this||n)._y3);(this||n)._context.closePath();break;case 2:(this||n)._context.lineTo((this||n)._x3,(this||n)._y3);(this||n)._context.closePath();break;case 3:this.point((this||n)._x3,(this||n)._y3);this.point((this||n)._x4,(this||n)._y4);this.point((this||n)._x5,(this||n)._y5);break}},point:function(t,i){t=+t,i=+i;if((this||n)._point){var e=(this||n)._x2-t,a=(this||n)._y2-i;(this||n)._l23_a=Math.sqrt((this||n)._l23_2a=Math.pow(e*e+a*a,(this||n)._alpha))}switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._x3=t,(this||n)._y3=i;break;case 1:(this||n)._point=2;(this||n)._context.moveTo((this||n)._x4=t,(this||n)._y4=i);break;case 2:(this||n)._point=3;(this||n)._x5=t,(this||n)._y5=i;break;default:point$2(this||n,t,i);break}(this||n)._l01_a=(this||n)._l12_a,(this||n)._l12_a=(this||n)._l23_a;(this||n)._l01_2a=(this||n)._l12_2a,(this||n)._l12_2a=(this||n)._l23_2a;(this||n)._x0=(this||n)._x1,(this||n)._x1=(this||n)._x2,(this||n)._x2=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=(this||n)._y2,(this||n)._y2=i}};var I=function custom(t){function catmullRom(n){return t?new CatmullRomClosed(n,t):new CardinalClosed(n,0)}catmullRom.alpha=function(t){return custom(+t)};return catmullRom}(.5);function CatmullRomOpen(t,i){(this||n)._context=t;(this||n)._alpha=i}CatmullRomOpen.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._x2=(this||n)._y0=(this||n)._y1=(this||n)._y2=NaN;(this||n)._l01_a=(this||n)._l12_a=(this||n)._l23_a=(this||n)._l01_2a=(this||n)._l12_2a=(this||n)._l23_2a=(this||n)._point=0},lineEnd:function(){((this||n)._line||0!==(this||n)._line&&3===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){t=+t,i=+i;if((this||n)._point){var e=(this||n)._x2-t,a=(this||n)._y2-i;(this||n)._l23_a=Math.sqrt((this||n)._l23_2a=Math.pow(e*e+a*a,(this||n)._alpha))}switch((this||n)._point){case 0:(this||n)._point=1;break;case 1:(this||n)._point=2;break;case 2:(this||n)._point=3;(this||n)._line?(this||n)._context.lineTo((this||n)._x2,(this||n)._y2):(this||n)._context.moveTo((this||n)._x2,(this||n)._y2);break;case 3:(this||n)._point=4;default:point$2(this||n,t,i);break}(this||n)._l01_a=(this||n)._l12_a,(this||n)._l12_a=(this||n)._l23_a;(this||n)._l01_2a=(this||n)._l12_2a,(this||n)._l12_2a=(this||n)._l23_2a;(this||n)._x0=(this||n)._x1,(this||n)._x1=(this||n)._x2,(this||n)._x2=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=(this||n)._y2,(this||n)._y2=i}};var D=function custom(t){function catmullRom(n){return t?new CatmullRomOpen(n,t):new CardinalOpen(n,0)}catmullRom.alpha=function(t){return custom(+t)};return catmullRom}(.5);function LinearClosed(t){(this||n)._context=t}LinearClosed.prototype={areaStart:noop,areaEnd:noop,lineStart:function(){(this||n)._point=0},lineEnd:function(){(this||n)._point&&(this||n)._context.closePath()},point:function(t,i){t=+t,i=+i;(this||n)._point?(this||n)._context.lineTo(t,i):((this||n)._point=1,(this||n)._context.moveTo(t,i))}};function linearClosed(t){return new LinearClosed(t)}function sign(t){return t<0?-1:1}function slope3(t,n,i){var e=t._x1-t._x0,a=n-t._x1,s=(t._y1-t._y0)/(e||a<0&&-0),o=(i-t._y1)/(a||e<0&&-0),r=(s*a+o*e)/(e+a);return(sign(s)+sign(o))*Math.min(Math.abs(s),Math.abs(o),.5*Math.abs(r))||0}function slope2(t,n){var i=t._x1-t._x0;return i?(3*(t._y1-t._y0)/i-n)/2:n}function point$3(t,n,i){var e=t._x0,a=t._y0,s=t._x1,o=t._y1,r=(s-e)/3;t._context.bezierCurveTo(e+r,a+r*n,s-r,o-r*i,s,o)}function MonotoneX(t){(this||n)._context=t}MonotoneX.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x0=(this||n)._x1=(this||n)._y0=(this||n)._y1=(this||n)._t0=NaN;(this||n)._point=0},lineEnd:function(){switch((this||n)._point){case 2:(this||n)._context.lineTo((this||n)._x1,(this||n)._y1);break;case 3:point$3(this||n,(this||n)._t0,slope2(this||n,(this||n)._t0));break}((this||n)._line||0!==(this||n)._line&&1===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line},point:function(t,i){var e=NaN;t=+t,i=+i;if(t!==(this||n)._x1||i!==(this||n)._y1){switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._line?(this||n)._context.lineTo(t,i):(this||n)._context.moveTo(t,i);break;case 1:(this||n)._point=2;break;case 2:(this||n)._point=3;point$3(this||n,slope2(this||n,e=slope3(this||n,t,i)),e);break;default:point$3(this||n,(this||n)._t0,e=slope3(this||n,t,i));break}(this||n)._x0=(this||n)._x1,(this||n)._x1=t;(this||n)._y0=(this||n)._y1,(this||n)._y1=i;(this||n)._t0=e}}};function MonotoneY(t){(this||n)._context=new ReflectContext(t)}(MonotoneY.prototype=Object.create(MonotoneX.prototype)).point=function(t,i){MonotoneX.prototype.point.call(this||n,i,t)};function ReflectContext(t){(this||n)._context=t}ReflectContext.prototype={moveTo:function(t,i){(this||n)._context.moveTo(i,t)},closePath:function(){(this||n)._context.closePath()},lineTo:function(t,i){(this||n)._context.lineTo(i,t)},bezierCurveTo:function(t,i,e,a,s,o){(this||n)._context.bezierCurveTo(i,t,a,e,o,s)}};function monotoneX(t){return new MonotoneX(t)}function monotoneY(t){return new MonotoneY(t)}function Natural(t){(this||n)._context=t}Natural.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x=[];(this||n)._y=[]},lineEnd:function(){var t=(this||n)._x,i=(this||n)._y,e=t.length;if(e){(this||n)._line?(this||n)._context.lineTo(t[0],i[0]):(this||n)._context.moveTo(t[0],i[0]);if(2===e)(this||n)._context.lineTo(t[1],i[1]);else{var a=controlPoints(t),s=controlPoints(i);for(var o=0,r=1;r<e;++o,++r)(this||n)._context.bezierCurveTo(a[0][o],s[0][o],a[1][o],s[1][o],t[r],i[r])}}((this||n)._line||0!==(this||n)._line&&1===e)&&(this||n)._context.closePath();(this||n)._line=1-(this||n)._line;(this||n)._x=(this||n)._y=null},point:function(t,i){(this||n)._x.push(+t);(this||n)._y.push(+i)}};function controlPoints(t){var n,i=t.length-1,e,a=new Array(i),s=new Array(i),o=new Array(i);a[0]=0,s[0]=2,o[0]=t[0]+2*t[1];for(n=1;n<i-1;++n)a[n]=1,s[n]=4,o[n]=4*t[n]+2*t[n+1];a[i-1]=2,s[i-1]=7,o[i-1]=8*t[i-1]+t[i];for(n=1;n<i;++n)e=a[n]/s[n-1],s[n]-=e,o[n]-=e*o[n-1];a[i-1]=o[i-1]/s[i-1];for(n=i-2;n>=0;--n)a[n]=(o[n]-a[n+1])/s[n];s[i-1]=(t[i]+a[i-1])/2;for(n=0;n<i-1;++n)s[n]=2*t[n+1]-a[n+1];return[a,s]}function natural(t){return new Natural(t)}function Step(t,i){(this||n)._context=t;(this||n)._t=i}Step.prototype={areaStart:function(){(this||n)._line=0},areaEnd:function(){(this||n)._line=NaN},lineStart:function(){(this||n)._x=(this||n)._y=NaN;(this||n)._point=0},lineEnd:function(){0<(this||n)._t&&(this||n)._t<1&&2===(this||n)._point&&(this||n)._context.lineTo((this||n)._x,(this||n)._y);((this||n)._line||0!==(this||n)._line&&1===(this||n)._point)&&(this||n)._context.closePath();(this||n)._line>=0&&((this||n)._t=1-(this||n)._t,(this||n)._line=1-(this||n)._line)},point:function(t,i){t=+t,i=+i;switch((this||n)._point){case 0:(this||n)._point=1;(this||n)._line?(this||n)._context.lineTo(t,i):(this||n)._context.moveTo(t,i);break;case 1:(this||n)._point=2;default:if((this||n)._t<=0){(this||n)._context.lineTo((this||n)._x,i);(this||n)._context.lineTo(t,i)}else{var e=(this||n)._x*(1-(this||n)._t)+t*(this||n)._t;(this||n)._context.lineTo(e,(this||n)._y);(this||n)._context.lineTo(e,i)}break}(this||n)._x=t,(this||n)._y=i}};function step(t){return new Step(t,.5)}function stepBefore(t){return new Step(t,0)}function stepAfter(t){return new Step(t,1)}function none(t,n){if((o=t.length)>1)for(var i=1,e,a,s=t[n[0]],o,r=s.length;i<o;++i){a=s,s=t[n[i]];for(e=0;e<r;++e)s[e][1]+=s[e][0]=isNaN(a[e][1])?a[e][0]:a[e][1]}}function none$1(t){var n=t.length,i=new Array(n);while(--n>=0)i[n]=n;return i}function stackValue(t,n){return t[n]}function stack(){var t=constant([]),i=none$1,e=none,a=stackValue;function stack(s){var o=t.apply(this||n,arguments),r,l=s.length,h=o.length,c=new Array(h),_;for(r=0;r<h;++r){for(var u=o[r],f=c[r]=new Array(l),p=0,d;p<l;++p){f[p]=d=[0,+a(s[p],u,p,s)];d.data=s[p]}f.key=u}for(r=0,_=i(c);r<h;++r)c[_[r]].index=r;e(c,_);return c}stack.keys=function(n){return arguments.length?(t=\"function\"===typeof n?n:constant(d.call(n)),stack):t};stack.value=function(t){return arguments.length?(a=\"function\"===typeof t?t:constant(+t),stack):a};stack.order=function(t){return arguments.length?(i=null==t?none$1:\"function\"===typeof t?t:constant(d.call(t)),stack):i};stack.offset=function(t){return arguments.length?(e=null==t?none:t,stack):e};return stack}function expand(t,n){if((e=t.length)>0){for(var i,e,a=0,s=t[0].length,o;a<s;++a){for(o=i=0;i<e;++i)o+=t[i][a][1]||0;if(o)for(i=0;i<e;++i)t[i][a][1]/=o}none(t,n)}}function diverging(t,n){if((l=t.length)>0)for(var i,e=0,a,s,o,r,l,h=t[n[0]].length;e<h;++e)for(o=r=0,i=0;i<l;++i)(s=(a=t[n[i]][e])[1]-a[0])>0?(a[0]=o,a[1]=o+=s):s<0?(a[1]=r,a[0]=r+=s):(a[0]=0,a[1]=s)}function silhouette(t,n){if((a=t.length)>0){for(var i=0,e=t[n[0]],a,s=e.length;i<s;++i){for(var o=0,r=0;o<a;++o)r+=t[o][i][1]||0;e[i][1]+=e[i][0]=-r/2}none(t,n)}}function wiggle(t,n){if((o=t.length)>0&&(s=(a=t[n[0]]).length)>0){for(var i=0,e=1,a,s,o;e<s;++e){for(var r=0,l=0,h=0;r<o;++r){var c=t[n[r]],_=c[e][1]||0,u=c[e-1][1]||0,f=(_-u)/2;for(var p=0;p<r;++p){var d=t[n[p]],v=d[e][1]||0,m=d[e-1][1]||0;f+=v-m}l+=_,h+=f*_}a[e-1][1]+=a[e-1][0]=i;l&&(i-=h/l)}a[e-1][1]+=a[e-1][0]=i;none(t,n)}}function appearance(t){var n=t.map(peak);return none$1(t).sort((function(t,i){return n[t]-n[i]}))}function peak(t){var n=-1,i=0,e=t.length,a,s=-Infinity;while(++n<e)(a=+t[n][1])>s&&(s=a,i=n);return i}function ascending(t){var n=t.map(sum);return none$1(t).sort((function(t,i){return n[t]-n[i]}))}function sum(t){var n=0,i=-1,e=t.length,a;while(++i<e)(a=+t[i][1])&&(n+=a);return n}function descending$1(t){return ascending(t).reverse()}function insideOut(t){var n=t.length,i,e,a=t.map(sum),s=appearance(t),o=0,r=0,l=[],h=[];for(i=0;i<n;++i){e=s[i];if(o<r){o+=a[e];l.push(e)}else{r+=a[e];h.push(e)}}return h.reverse().concat(l)}function reverse(t){return none$1(t).reverse()}t.arc=arc;t.area=area;t.areaRadial=areaRadial;t.curveBasis=basis;t.curveBasisClosed=basisClosed;t.curveBasisOpen=basisOpen;t.curveBundle=z;t.curveCardinal=L;t.curveCardinalClosed=X;t.curveCardinalOpen=Y;t.curveCatmullRom=V;t.curveCatmullRomClosed=I;t.curveCatmullRomOpen=D;t.curveLinear=curveLinear;t.curveLinearClosed=linearClosed;t.curveMonotoneX=monotoneX;t.curveMonotoneY=monotoneY;t.curveNatural=natural;t.curveStep=step;t.curveStepAfter=stepAfter;t.curveStepBefore=stepBefore;t.line=line;t.lineRadial=lineRadial$1;t.linkHorizontal=linkHorizontal;t.linkRadial=linkRadial;t.linkVertical=linkVertical;t.pie=pie;t.pointRadial=pointRadial;t.radialArea=areaRadial;t.radialLine=lineRadial$1;t.stack=stack;t.stackOffsetDiverging=diverging;t.stackOffsetExpand=expand;t.stackOffsetNone=none;t.stackOffsetSilhouette=silhouette;t.stackOffsetWiggle=wiggle;t.stackOrderAppearance=appearance;t.stackOrderAscending=ascending;t.stackOrderDescending=descending$1;t.stackOrderInsideOut=insideOut;t.stackOrderNone=none$1;t.stackOrderReverse=reverse;t.symbol=symbol;t.symbolCircle=v;t.symbolCross=m;t.symbolDiamond=b;t.symbolSquare=S;t.symbolStar=M;t.symbolTriangle=O;t.symbolWye=B;t.symbols=q;Object.defineProperty(t,\"__esModule\",{value:true})}));const e=i.arc,a=i.area,s=i.areaRadial,o=i.curveBasis,r=i.curveBasisClosed,l=i.curveBasisOpen,h=i.curveBundle,c=i.curveCardinal,_=i.curveCardinalClosed,u=i.curveCardinalOpen,f=i.curveCatmullRom,p=i.curveCatmullRomClosed,d=i.curveCatmullRomOpen,v=i.curveLinear,m=i.curveLinearClosed,k=i.curveMonotoneX,g=i.curveMonotoneY,b=i.curveNatural,T=i.curveStep,R=i.curveStepAfter,C=i.curveStepBefore,w=i.line,M=i.lineRadial,S=i.linkHorizontal,N=i.linkRadial,O=i.linkVertical,A=i.pie,E=i.pointRadial,P=i.radialArea,$=i.radialLine,B=i.stack,q=i.stackOffsetDiverging,z=i.stackOffsetExpand,L=i.stackOffsetNone,X=i.stackOffsetSilhouette,Y=i.stackOffsetWiggle,V=i.stackOrderAppearance,I=i.stackOrderAscending,D=i.stackOrderDescending,H=i.stackOrderInsideOut,W=i.stackOrderNone,j=i.stackOrderReverse,F=i.symbol,G=i.symbolCircle,J=i.symbolCross,K=i.symbolDiamond,Q=i.symbolSquare,U=i.symbolStar,Z=i.symbolTriangle,tt=i.symbolWye,nt=i.symbols,it=i.__esModule;export default i;export{it as __esModule,e as arc,a as area,s as areaRadial,o as curveBasis,r as curveBasisClosed,l as curveBasisOpen,h as curveBundle,c as curveCardinal,_ as curveCardinalClosed,u as curveCardinalOpen,f as curveCatmullRom,p as curveCatmullRomClosed,d as curveCatmullRomOpen,v as curveLinear,m as curveLinearClosed,k as curveMonotoneX,g as curveMonotoneY,b as curveNatural,T as curveStep,R as curveStepAfter,C as curveStepBefore,w as line,M as lineRadial,S as linkHorizontal,N as linkRadial,O as linkVertical,A as pie,E as pointRadial,P as radialArea,$ as radialLine,B as stack,q as stackOffsetDiverging,z as stackOffsetExpand,L as stackOffsetNone,X as stackOffsetSilhouette,Y as stackOffsetWiggle,V as stackOrderAppearance,I as stackOrderAscending,D as stackOrderDescending,H as stackOrderInsideOut,W as stackOrderNone,j as stackOrderReverse,F as symbol,G as symbolCircle,J as symbolCross,K as symbolDiamond,Q as symbolSquare,U as symbolStar,Z as symbolTriangle,tt as symbolWye,nt as symbols};\n\n"
  },
  {
    "path": "vendor/javascript/d3-time-format.js",
    "content": "// d3-time-format@4.1.0 downloaded from https://ga.jspm.io/npm:d3-time-format@4.1.0/src/index.js\n\nimport{utcMonday as e,utcDay as r,timeMonday as t,timeDay as n,timeYear as a,timeSunday as o,timeThursday as u,utcYear as f,utcSunday as i,utcThursday as c}from\"d3-time\";function localDate(e){if(0<=e.y&&e.y<100){var r=new Date(-1,e.m,e.d,e.H,e.M,e.S,e.L);r.setFullYear(e.y);return r}return new Date(e.y,e.m,e.d,e.H,e.M,e.S,e.L)}function utcDate(e){if(0<=e.y&&e.y<100){var r=new Date(Date.UTC(-1,e.m,e.d,e.H,e.M,e.S,e.L));r.setUTCFullYear(e.y);return r}return new Date(Date.UTC(e.y,e.m,e.d,e.H,e.M,e.S,e.L))}function newDate(e,r,t){return{y:e,m:r,d:t,H:0,M:0,S:0,L:0}}function formatLocale(a){var o=a.dateTime,u=a.date,f=a.time,i=a.periods,c=a.days,s=a.shortDays,l=a.months,d=a.shortMonths;var p=formatRe(i),y=formatLookup(i),T=formatRe(c),h=formatLookup(c),g=formatRe(s),U=formatLookup(s),M=formatRe(l),C=formatLookup(l),S=formatRe(d),D=formatLookup(d);var v={a:formatShortWeekday,A:formatWeekday,b:formatShortMonth,B:formatMonth,c:null,d:formatDayOfMonth,e:formatDayOfMonth,f:formatMicroseconds,g:formatYearISO,G:formatFullYearISO,H:formatHour24,I:formatHour12,j:formatDayOfYear,L:formatMilliseconds,m:formatMonthNumber,M:formatMinutes,p:formatPeriod,q:formatQuarter,Q:formatUnixTimestamp,s:formatUnixTimestampSeconds,S:formatSeconds,u:formatWeekdayNumberMonday,U:formatWeekNumberSunday,V:formatWeekNumberISO,w:formatWeekdayNumberSunday,W:formatWeekNumberMonday,x:null,X:null,y:formatYear,Y:formatFullYear,Z:formatZone,\"%\":formatLiteralPercent};var w={a:formatUTCShortWeekday,A:formatUTCWeekday,b:formatUTCShortMonth,B:formatUTCMonth,c:null,d:formatUTCDayOfMonth,e:formatUTCDayOfMonth,f:formatUTCMicroseconds,g:formatUTCYearISO,G:formatUTCFullYearISO,H:formatUTCHour24,I:formatUTCHour12,j:formatUTCDayOfYear,L:formatUTCMilliseconds,m:formatUTCMonthNumber,M:formatUTCMinutes,p:formatUTCPeriod,q:formatUTCQuarter,Q:formatUnixTimestamp,s:formatUnixTimestampSeconds,S:formatUTCSeconds,u:formatUTCWeekdayNumberMonday,U:formatUTCWeekNumberSunday,V:formatUTCWeekNumberISO,w:formatUTCWeekdayNumberSunday,W:formatUTCWeekNumberMonday,x:null,X:null,y:formatUTCYear,Y:formatUTCFullYear,Z:formatUTCZone,\"%\":formatLiteralPercent};var W={a:parseShortWeekday,A:parseWeekday,b:parseShortMonth,B:parseMonth,c:parseLocaleDateTime,d:parseDayOfMonth,e:parseDayOfMonth,f:parseMicroseconds,g:parseYear,G:parseFullYear,H:parseHour24,I:parseHour24,j:parseDayOfYear,L:parseMilliseconds,m:parseMonthNumber,M:parseMinutes,p:parsePeriod,q:parseQuarter,Q:parseUnixTimestamp,s:parseUnixTimestampSeconds,S:parseSeconds,u:parseWeekdayNumberMonday,U:parseWeekNumberSunday,V:parseWeekNumberISO,w:parseWeekdayNumberSunday,W:parseWeekNumberMonday,x:parseLocaleDate,X:parseLocaleTime,y:parseYear,Y:parseFullYear,Z:parseZone,\"%\":parseLiteralPercent};v.x=newFormat(u,v);v.X=newFormat(f,v);v.c=newFormat(o,v);w.x=newFormat(u,w);w.X=newFormat(f,w);w.c=newFormat(o,w);function newFormat(e,r){return function(t){var n,a,o,u=[],f=-1,i=0,c=e.length;t instanceof Date||(t=new Date(+t));while(++f<c)if(37===e.charCodeAt(f)){u.push(e.slice(i,f));null!=(a=m[n=e.charAt(++f)])?n=e.charAt(++f):a=\"e\"===n?\" \":\"0\";(o=r[n])&&(n=o(t,a));u.push(n);i=f+1}u.push(e.slice(i,f));return u.join(\"\")}}function newParse(a,o){return function(u){var f,i,c=newDate(1900,void 0,1),m=parseSpecifier(c,a,u+=\"\",0);if(m!=u.length)return null;if(\"Q\"in c)return new Date(c.Q);if(\"s\"in c)return new Date(1e3*c.s+(\"L\"in c?c.L:0));o&&!(\"Z\"in c)&&(c.Z=0);\"p\"in c&&(c.H=c.H%12+12*c.p);void 0===c.m&&(c.m=\"q\"in c?c.q:0);if(\"V\"in c){if(c.V<1||c.V>53)return null;\"w\"in c||(c.w=1);if(\"Z\"in c){f=utcDate(newDate(c.y,0,1)),i=f.getUTCDay();f=i>4||0===i?e.ceil(f):e(f);f=r.offset(f,7*(c.V-1));c.y=f.getUTCFullYear();c.m=f.getUTCMonth();c.d=f.getUTCDate()+(c.w+6)%7}else{f=localDate(newDate(c.y,0,1)),i=f.getDay();f=i>4||0===i?t.ceil(f):t(f);f=n.offset(f,7*(c.V-1));c.y=f.getFullYear();c.m=f.getMonth();c.d=f.getDate()+(c.w+6)%7}}else if(\"W\"in c||\"U\"in c){\"w\"in c||(c.w=\"u\"in c?c.u%7:\"W\"in c?1:0);i=\"Z\"in c?utcDate(newDate(c.y,0,1)).getUTCDay():localDate(newDate(c.y,0,1)).getDay();c.m=0;c.d=\"W\"in c?(c.w+6)%7+7*c.W-(i+5)%7:c.w+7*c.U-(i+6)%7}if(\"Z\"in c){c.H+=c.Z/100|0;c.M+=c.Z%100;return utcDate(c)}return localDate(c)}}function parseSpecifier(e,r,t,n){var a,o,u=0,f=r.length,i=t.length;while(u<f){if(n>=i)return-1;a=r.charCodeAt(u++);if(37===a){a=r.charAt(u++);o=W[a in m?r.charAt(u++):a];if(!o||(n=o(e,t,n))<0)return-1}else if(a!=t.charCodeAt(n++))return-1}return n}function parsePeriod(e,r,t){var n=p.exec(r.slice(t));return n?(e.p=y.get(n[0].toLowerCase()),t+n[0].length):-1}function parseShortWeekday(e,r,t){var n=g.exec(r.slice(t));return n?(e.w=U.get(n[0].toLowerCase()),t+n[0].length):-1}function parseWeekday(e,r,t){var n=T.exec(r.slice(t));return n?(e.w=h.get(n[0].toLowerCase()),t+n[0].length):-1}function parseShortMonth(e,r,t){var n=S.exec(r.slice(t));return n?(e.m=D.get(n[0].toLowerCase()),t+n[0].length):-1}function parseMonth(e,r,t){var n=M.exec(r.slice(t));return n?(e.m=C.get(n[0].toLowerCase()),t+n[0].length):-1}function parseLocaleDateTime(e,r,t){return parseSpecifier(e,o,r,t)}function parseLocaleDate(e,r,t){return parseSpecifier(e,u,r,t)}function parseLocaleTime(e,r,t){return parseSpecifier(e,f,r,t)}function formatShortWeekday(e){return s[e.getDay()]}function formatWeekday(e){return c[e.getDay()]}function formatShortMonth(e){return d[e.getMonth()]}function formatMonth(e){return l[e.getMonth()]}function formatPeriod(e){return i[+(e.getHours()>=12)]}function formatQuarter(e){return 1+~~(e.getMonth()/3)}function formatUTCShortWeekday(e){return s[e.getUTCDay()]}function formatUTCWeekday(e){return c[e.getUTCDay()]}function formatUTCShortMonth(e){return d[e.getUTCMonth()]}function formatUTCMonth(e){return l[e.getUTCMonth()]}function formatUTCPeriod(e){return i[+(e.getUTCHours()>=12)]}function formatUTCQuarter(e){return 1+~~(e.getUTCMonth()/3)}return{format:function(e){var r=newFormat(e+=\"\",v);r.toString=function(){return e};return r},parse:function(e){var r=newParse(e+=\"\",false);r.toString=function(){return e};return r},utcFormat:function(e){var r=newFormat(e+=\"\",w);r.toString=function(){return e};return r},utcParse:function(e){var r=newParse(e+=\"\",true);r.toString=function(){return e};return r}}}var m={\"-\":\"\",_:\" \",0:\"0\"},s=/^\\s*\\d+/,l=/^%/,d=/[\\\\^$*+?|[\\]().{}]/g;function pad(e,r,t){var n=e<0?\"-\":\"\",a=(n?-e:e)+\"\",o=a.length;return n+(o<t?new Array(t-o+1).join(r)+a:a)}function requote(e){return e.replace(d,\"\\\\$&\")}function formatRe(e){return new RegExp(\"^(?:\"+e.map(requote).join(\"|\")+\")\",\"i\")}function formatLookup(e){return new Map(e.map(((e,r)=>[e.toLowerCase(),r])))}function parseWeekdayNumberSunday(e,r,t){var n=s.exec(r.slice(t,t+1));return n?(e.w=+n[0],t+n[0].length):-1}function parseWeekdayNumberMonday(e,r,t){var n=s.exec(r.slice(t,t+1));return n?(e.u=+n[0],t+n[0].length):-1}function parseWeekNumberSunday(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.U=+n[0],t+n[0].length):-1}function parseWeekNumberISO(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.V=+n[0],t+n[0].length):-1}function parseWeekNumberMonday(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.W=+n[0],t+n[0].length):-1}function parseFullYear(e,r,t){var n=s.exec(r.slice(t,t+4));return n?(e.y=+n[0],t+n[0].length):-1}function parseYear(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.y=+n[0]+(+n[0]>68?1900:2e3),t+n[0].length):-1}function parseZone(e,r,t){var n=/^(Z)|([+-]\\d\\d)(?::?(\\d\\d))?/.exec(r.slice(t,t+6));return n?(e.Z=n[1]?0:-(n[2]+(n[3]||\"00\")),t+n[0].length):-1}function parseQuarter(e,r,t){var n=s.exec(r.slice(t,t+1));return n?(e.q=3*n[0]-3,t+n[0].length):-1}function parseMonthNumber(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.m=n[0]-1,t+n[0].length):-1}function parseDayOfMonth(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.d=+n[0],t+n[0].length):-1}function parseDayOfYear(e,r,t){var n=s.exec(r.slice(t,t+3));return n?(e.m=0,e.d=+n[0],t+n[0].length):-1}function parseHour24(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.H=+n[0],t+n[0].length):-1}function parseMinutes(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.M=+n[0],t+n[0].length):-1}function parseSeconds(e,r,t){var n=s.exec(r.slice(t,t+2));return n?(e.S=+n[0],t+n[0].length):-1}function parseMilliseconds(e,r,t){var n=s.exec(r.slice(t,t+3));return n?(e.L=+n[0],t+n[0].length):-1}function parseMicroseconds(e,r,t){var n=s.exec(r.slice(t,t+6));return n?(e.L=Math.floor(n[0]/1e3),t+n[0].length):-1}function parseLiteralPercent(e,r,t){var n=l.exec(r.slice(t,t+1));return n?t+n[0].length:-1}function parseUnixTimestamp(e,r,t){var n=s.exec(r.slice(t));return n?(e.Q=+n[0],t+n[0].length):-1}function parseUnixTimestampSeconds(e,r,t){var n=s.exec(r.slice(t));return n?(e.s=+n[0],t+n[0].length):-1}function formatDayOfMonth(e,r){return pad(e.getDate(),r,2)}function formatHour24(e,r){return pad(e.getHours(),r,2)}function formatHour12(e,r){return pad(e.getHours()%12||12,r,2)}function formatDayOfYear(e,r){return pad(1+n.count(a(e),e),r,3)}function formatMilliseconds(e,r){return pad(e.getMilliseconds(),r,3)}function formatMicroseconds(e,r){return formatMilliseconds(e,r)+\"000\"}function formatMonthNumber(e,r){return pad(e.getMonth()+1,r,2)}function formatMinutes(e,r){return pad(e.getMinutes(),r,2)}function formatSeconds(e,r){return pad(e.getSeconds(),r,2)}function formatWeekdayNumberMonday(e){var r=e.getDay();return 0===r?7:r}function formatWeekNumberSunday(e,r){return pad(o.count(a(e)-1,e),r,2)}function dISO(e){var r=e.getDay();return r>=4||0===r?u(e):u.ceil(e)}function formatWeekNumberISO(e,r){e=dISO(e);return pad(u.count(a(e),e)+(4===a(e).getDay()),r,2)}function formatWeekdayNumberSunday(e){return e.getDay()}function formatWeekNumberMonday(e,r){return pad(t.count(a(e)-1,e),r,2)}function formatYear(e,r){return pad(e.getFullYear()%100,r,2)}function formatYearISO(e,r){e=dISO(e);return pad(e.getFullYear()%100,r,2)}function formatFullYear(e,r){return pad(e.getFullYear()%1e4,r,4)}function formatFullYearISO(e,r){var t=e.getDay();e=t>=4||0===t?u(e):u.ceil(e);return pad(e.getFullYear()%1e4,r,4)}function formatZone(e){var r=e.getTimezoneOffset();return(r>0?\"-\":(r*=-1,\"+\"))+pad(r/60|0,\"0\",2)+pad(r%60,\"0\",2)}function formatUTCDayOfMonth(e,r){return pad(e.getUTCDate(),r,2)}function formatUTCHour24(e,r){return pad(e.getUTCHours(),r,2)}function formatUTCHour12(e,r){return pad(e.getUTCHours()%12||12,r,2)}function formatUTCDayOfYear(e,t){return pad(1+r.count(f(e),e),t,3)}function formatUTCMilliseconds(e,r){return pad(e.getUTCMilliseconds(),r,3)}function formatUTCMicroseconds(e,r){return formatUTCMilliseconds(e,r)+\"000\"}function formatUTCMonthNumber(e,r){return pad(e.getUTCMonth()+1,r,2)}function formatUTCMinutes(e,r){return pad(e.getUTCMinutes(),r,2)}function formatUTCSeconds(e,r){return pad(e.getUTCSeconds(),r,2)}function formatUTCWeekdayNumberMonday(e){var r=e.getUTCDay();return 0===r?7:r}function formatUTCWeekNumberSunday(e,r){return pad(i.count(f(e)-1,e),r,2)}function UTCdISO(e){var r=e.getUTCDay();return r>=4||0===r?c(e):c.ceil(e)}function formatUTCWeekNumberISO(e,r){e=UTCdISO(e);return pad(c.count(f(e),e)+(4===f(e).getUTCDay()),r,2)}function formatUTCWeekdayNumberSunday(e){return e.getUTCDay()}function formatUTCWeekNumberMonday(r,t){return pad(e.count(f(r)-1,r),t,2)}function formatUTCYear(e,r){return pad(e.getUTCFullYear()%100,r,2)}function formatUTCYearISO(e,r){e=UTCdISO(e);return pad(e.getUTCFullYear()%100,r,2)}function formatUTCFullYear(e,r){return pad(e.getUTCFullYear()%1e4,r,4)}function formatUTCFullYearISO(e,r){var t=e.getUTCDay();e=t>=4||0===t?c(e):c.ceil(e);return pad(e.getUTCFullYear()%1e4,r,4)}function formatUTCZone(){return\"+0000\"}function formatLiteralPercent(){return\"%\"}function formatUnixTimestamp(e){return+e}function formatUnixTimestampSeconds(e){return Math.floor(+e/1e3)}var p;var y;var T;var h;var g;defaultLocale({dateTime:\"%x, %X\",date:\"%-m/%-d/%Y\",time:\"%-I:%M:%S %p\",periods:[\"AM\",\"PM\"],days:[\"Sunday\",\"Monday\",\"Tuesday\",\"Wednesday\",\"Thursday\",\"Friday\",\"Saturday\"],shortDays:[\"Sun\",\"Mon\",\"Tue\",\"Wed\",\"Thu\",\"Fri\",\"Sat\"],months:[\"January\",\"February\",\"March\",\"April\",\"May\",\"June\",\"July\",\"August\",\"September\",\"October\",\"November\",\"December\"],shortMonths:[\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"May\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Oct\",\"Nov\",\"Dec\"]});function defaultLocale(e){p=formatLocale(e);y=p.format;T=p.parse;h=p.utcFormat;g=p.utcParse;return p}var U=\"%Y-%m-%dT%H:%M:%S.%LZ\";function formatIsoNative(e){return e.toISOString()}var M=Date.prototype.toISOString?formatIsoNative:h(U);function parseIsoNative(e){var r=new Date(e);return isNaN(r)?null:r}var C=+new Date(\"2000-01-01T00:00:00.000Z\")?parseIsoNative:g(U);export{M as isoFormat,C as isoParse,y as timeFormat,defaultLocale as timeFormatDefaultLocale,formatLocale as timeFormatLocale,T as timeParse,h as utcFormat,g as utcParse};\n\n"
  },
  {
    "path": "vendor/javascript/d3-time.js",
    "content": "// d3-time@3.1.0 downloaded from https://ga.jspm.io/npm:d3-time@3.1.0/src/index.js\n\nimport{bisector as e,tickStep as t}from\"d3-array\";const n=new Date,s=new Date;function timeInterval(e,t,r,a){function interval(t){return e(t=0===arguments.length?new Date:new Date(+t)),t}interval.floor=t=>(e(t=new Date(+t)),t);interval.ceil=n=>(e(n=new Date(n-1)),t(n,1),e(n),n);interval.round=e=>{const t=interval(e),n=interval.ceil(e);return e-t<n-e?t:n};interval.offset=(e,n)=>(t(e=new Date(+e),null==n?1:Math.floor(n)),e);interval.range=(n,s,r)=>{const a=[];n=interval.ceil(n);r=null==r?1:Math.floor(r);if(!(n<s)||!(r>0))return a;let o;do{a.push(o=new Date(+n)),t(n,r),e(n)}while(o<n&&n<s);return a};interval.filter=n=>timeInterval((t=>{if(t>=t)while(e(t),!n(t))t.setTime(t-1)}),((e,s)=>{if(e>=e)if(s<0)while(++s<=0)while(t(e,-1),!n(e));else while(--s>=0)while(t(e,1),!n(e));}));if(r){interval.count=(t,a)=>{n.setTime(+t),s.setTime(+a);e(n),e(s);return Math.floor(r(n,s))};interval.every=e=>{e=Math.floor(e);return isFinite(e)&&e>0?e>1?interval.filter(a?t=>a(t)%e===0:t=>interval.count(0,t)%e===0):interval:null}}return interval}const r=timeInterval((()=>{}),((e,t)=>{e.setTime(+e+t)}),((e,t)=>t-e));r.every=e=>{e=Math.floor(e);return isFinite(e)&&e>0?e>1?timeInterval((t=>{t.setTime(Math.floor(t/e)*e)}),((t,n)=>{t.setTime(+t+n*e)}),((t,n)=>(n-t)/e)):r:null};const a=r.range;const o=1e3;const l=60*o;const i=60*l;const c=24*i;const u=7*c;const g=30*c;const T=365*c;const m=timeInterval((e=>{e.setTime(e-e.getMilliseconds())}),((e,t)=>{e.setTime(+e+t*o)}),((e,t)=>(t-e)/o),(e=>e.getUTCSeconds()));const v=m.range;const f=timeInterval((e=>{e.setTime(e-e.getMilliseconds()-e.getSeconds()*o)}),((e,t)=>{e.setTime(+e+t*l)}),((e,t)=>(t-e)/l),(e=>e.getMinutes()));const C=f.range;const U=timeInterval((e=>{e.setUTCSeconds(0,0)}),((e,t)=>{e.setTime(+e+t*l)}),((e,t)=>(t-e)/l),(e=>e.getUTCMinutes()));const M=U.range;const h=timeInterval((e=>{e.setTime(e-e.getMilliseconds()-e.getSeconds()*o-e.getMinutes()*l)}),((e,t)=>{e.setTime(+e+t*i)}),((e,t)=>(t-e)/i),(e=>e.getHours()));const d=h.range;const k=timeInterval((e=>{e.setUTCMinutes(0,0,0)}),((e,t)=>{e.setTime(+e+t*i)}),((e,t)=>(t-e)/i),(e=>e.getUTCHours()));const D=k.range;const y=timeInterval((e=>e.setHours(0,0,0,0)),((e,t)=>e.setDate(e.getDate()+t)),((e,t)=>(t-e-(t.getTimezoneOffset()-e.getTimezoneOffset())*l)/c),(e=>e.getDate()-1));const F=y.range;const I=timeInterval((e=>{e.setUTCHours(0,0,0,0)}),((e,t)=>{e.setUTCDate(e.getUTCDate()+t)}),((e,t)=>(t-e)/c),(e=>e.getUTCDate()-1));const Y=I.range;const W=timeInterval((e=>{e.setUTCHours(0,0,0,0)}),((e,t)=>{e.setUTCDate(e.getUTCDate()+t)}),((e,t)=>(t-e)/c),(e=>Math.floor(e/c)));const w=W.range;function timeWeekday(e){return timeInterval((t=>{t.setDate(t.getDate()-(t.getDay()+7-e)%7);t.setHours(0,0,0,0)}),((e,t)=>{e.setDate(e.getDate()+7*t)}),((e,t)=>(t-e-(t.getTimezoneOffset()-e.getTimezoneOffset())*l)/u))}const H=timeWeekday(0);const S=timeWeekday(1);const p=timeWeekday(2);const z=timeWeekday(3);const O=timeWeekday(4);const x=timeWeekday(5);const b=timeWeekday(6);const j=H.range;const q=S.range;const A=p.range;const B=z.range;const E=O.range;const G=x.range;const J=b.range;function utcWeekday(e){return timeInterval((t=>{t.setUTCDate(t.getUTCDate()-(t.getUTCDay()+7-e)%7);t.setUTCHours(0,0,0,0)}),((e,t)=>{e.setUTCDate(e.getUTCDate()+7*t)}),((e,t)=>(t-e)/u))}const K=utcWeekday(0);const L=utcWeekday(1);const N=utcWeekday(2);const P=utcWeekday(3);const Q=utcWeekday(4);const R=utcWeekday(5);const V=utcWeekday(6);const X=K.range;const Z=L.range;const $=N.range;const _=P.range;const ee=Q.range;const te=R.range;const ne=V.range;const se=timeInterval((e=>{e.setDate(1);e.setHours(0,0,0,0)}),((e,t)=>{e.setMonth(e.getMonth()+t)}),((e,t)=>t.getMonth()-e.getMonth()+12*(t.getFullYear()-e.getFullYear())),(e=>e.getMonth()));const re=se.range;const ae=timeInterval((e=>{e.setUTCDate(1);e.setUTCHours(0,0,0,0)}),((e,t)=>{e.setUTCMonth(e.getUTCMonth()+t)}),((e,t)=>t.getUTCMonth()-e.getUTCMonth()+12*(t.getUTCFullYear()-e.getUTCFullYear())),(e=>e.getUTCMonth()));const oe=ae.range;const le=timeInterval((e=>{e.setMonth(0,1);e.setHours(0,0,0,0)}),((e,t)=>{e.setFullYear(e.getFullYear()+t)}),((e,t)=>t.getFullYear()-e.getFullYear()),(e=>e.getFullYear()));le.every=e=>isFinite(e=Math.floor(e))&&e>0?timeInterval((t=>{t.setFullYear(Math.floor(t.getFullYear()/e)*e);t.setMonth(0,1);t.setHours(0,0,0,0)}),((t,n)=>{t.setFullYear(t.getFullYear()+n*e)})):null;const ie=le.range;const ce=timeInterval((e=>{e.setUTCMonth(0,1);e.setUTCHours(0,0,0,0)}),((e,t)=>{e.setUTCFullYear(e.getUTCFullYear()+t)}),((e,t)=>t.getUTCFullYear()-e.getUTCFullYear()),(e=>e.getUTCFullYear()));ce.every=e=>isFinite(e=Math.floor(e))&&e>0?timeInterval((t=>{t.setUTCFullYear(Math.floor(t.getUTCFullYear()/e)*e);t.setUTCMonth(0,1);t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCFullYear(t.getUTCFullYear()+n*e)})):null;const ue=ce.range;function ticker(n,s,a,v,f,C){const U=[[m,1,o],[m,5,5*o],[m,15,15*o],[m,30,30*o],[C,1,l],[C,5,5*l],[C,15,15*l],[C,30,30*l],[f,1,i],[f,3,3*i],[f,6,6*i],[f,12,12*i],[v,1,c],[v,2,2*c],[a,1,u],[s,1,g],[s,3,3*g],[n,1,T]];function ticks(e,t,n){const s=t<e;s&&([e,t]=[t,e]);const r=n&&\"function\"===typeof n.range?n:tickInterval(e,t,n);const a=r?r.range(e,+t+1):[];return s?a.reverse():a}function tickInterval(s,a,o){const l=Math.abs(a-s)/o;const i=e((([,,e])=>e)).right(U,l);if(i===U.length)return n.every(t(s/T,a/T,o));if(0===i)return r.every(Math.max(t(s,a,o),1));const[c,u]=U[l/U[i-1][2]<U[i][2]/l?i-1:i];return c.every(u)}return[ticks,tickInterval]}const[ge,Te]=ticker(ce,ae,K,W,k,U);const[me,ve]=ticker(le,se,H,y,h,f);export{y as timeDay,F as timeDays,x as timeFriday,G as timeFridays,h as timeHour,d as timeHours,timeInterval,r as timeMillisecond,a as timeMilliseconds,f as timeMinute,C as timeMinutes,S as timeMonday,q as timeMondays,se as timeMonth,re as timeMonths,b as timeSaturday,J as timeSaturdays,m as timeSecond,v as timeSeconds,H as timeSunday,j as timeSundays,O as timeThursday,E as timeThursdays,ve as timeTickInterval,me as timeTicks,p as timeTuesday,A as timeTuesdays,z as timeWednesday,B as timeWednesdays,H as timeWeek,j as timeWeeks,le as timeYear,ie as timeYears,W as unixDay,w as unixDays,I as utcDay,Y as utcDays,R as utcFriday,te as utcFridays,k as utcHour,D as utcHours,r as utcMillisecond,a as utcMilliseconds,U as utcMinute,M as utcMinutes,L as utcMonday,Z as utcMondays,ae as utcMonth,oe as utcMonths,V as utcSaturday,ne as utcSaturdays,m as utcSecond,v as utcSeconds,K as utcSunday,X as utcSundays,Q as utcThursday,ee as utcThursdays,Te as utcTickInterval,ge as utcTicks,N as utcTuesday,$ as utcTuesdays,P as utcWednesday,_ as utcWednesdays,K as utcWeek,X as utcWeeks,ce as utcYear,ue as utcYears};\n\n"
  },
  {
    "path": "vendor/javascript/d3-timer.js",
    "content": "// d3-timer@3.0.1 downloaded from https://ga.jspm.io/npm:d3-timer@3.0.1/src/index.js\n\nvar t,e,n=0,i=0,r=0,o=1e3,l=0,a=0,u=0,s=\"object\"===typeof performance&&performance.now?performance:Date,c=\"object\"===typeof window&&window.requestAnimationFrame?window.requestAnimationFrame.bind(window):function(t){setTimeout(t,17)};function now(){return a||(c(clearNow),a=s.now()+u)}function clearNow(){a=0}function Timer(){this._call=this._time=this._next=null}Timer.prototype=timer.prototype={constructor:Timer,restart:function(n,i,r){if(\"function\"!==typeof n)throw new TypeError(\"callback is not a function\");r=(null==r?now():+r)+(null==i?0:+i);if(!this._next&&e!==this){e?e._next=this:t=this;e=this}this._call=n;this._time=r;sleep()},stop:function(){if(this._call){this._call=null;this._time=Infinity;sleep()}}};function timer(t,e,n){var i=new Timer;i.restart(t,e,n);return i}function timerFlush(){now();++n;var e,i=t;while(i){(e=a-i._time)>=0&&i._call.call(void 0,e);i=i._next}--n}function wake(){a=(l=s.now())+u;n=i=0;try{timerFlush()}finally{n=0;nap();a=0}}function poke(){var t=s.now(),e=t-l;e>o&&(u-=e,l=t)}function nap(){var n,i,r=t,o=Infinity;while(r)if(r._call){o>r._time&&(o=r._time);n=r,r=r._next}else{i=r._next,r._next=null;r=n?n._next=i:t=i}e=n;sleep(o)}function sleep(t){if(!n){i&&(i=clearTimeout(i));var e=t-a;if(e>24){t<Infinity&&(i=setTimeout(wake,t-s.now()-u));r&&(r=clearInterval(r))}else{r||(l=s.now(),r=setInterval(poke,o));n=1,c(wake)}}}function timeout(t,e,n){var i=new Timer;e=null==e?0:+e;i.restart((n=>{i.stop();t(n+e)}),e,n);return i}function interval(t,e,n){var i=new Timer,r=e;if(null==e)return i.restart(t,e,n),i;i._restart=i.restart;i.restart=function(t,e,n){e=+e,n=null==n?now():+n;i._restart((function tick(o){o+=r;i._restart(tick,r+=e,n);t(o)}),e,n)};i.restart(t,e,n);return i}export{interval,now,timeout,timer,timerFlush};\n\n"
  },
  {
    "path": "vendor/javascript/d3-transition.js",
    "content": "// d3-transition@3.0.1 downloaded from https://ga.jspm.io/npm:d3-transition@3.0.1/src/index.js\n\nimport{namespace as t,matcher as n,selector as e,selectorAll as r,selection as i,style as o}from\"d3-selection\";import{dispatch as a}from\"d3-dispatch\";import{timer as s,timeout as u,now as l}from\"d3-timer\";import{interpolateNumber as c,interpolateRgb as f,interpolateString as h,interpolateTransformSvg as _,interpolateTransformCss as v}from\"d3-interpolate\";import{color as d}from\"d3-color\";import{easeCubicInOut as p}from\"d3-ease\";var y=a(\"start\",\"end\",\"cancel\",\"interrupt\");var w=[];var m=0;var g=1;var T=2;var x=3;var C=4;var A=5;var N=6;function schedule(t,n,e,r,i,o){var a=t.__transition;if(a){if(e in a)return}else t.__transition={};create(t,e,{name:n,index:r,group:i,on:y,tween:w,time:o.time,delay:o.delay,duration:o.duration,ease:o.ease,timer:null,state:m})}function init(t,n){var e=get(t,n);if(e.state>m)throw new Error(\"too late; already scheduled\");return e}function set(t,n){var e=get(t,n);if(e.state>x)throw new Error(\"too late; already running\");return e}function get(t,n){var e=t.__transition;if(!e||!(e=e[n]))throw new Error(\"transition not found\");return e}function create(t,n,e){var r,i=t.__transition;i[n]=e;e.timer=s(schedule,0,e.time);function schedule(t){e.state=g;e.timer.restart(start,e.delay,e.time);e.delay<=t&&start(t-e.delay)}function start(o){var a,s,l,c;if(e.state!==g)return stop();for(a in i){c=i[a];if(c.name===e.name){if(c.state===x)return u(start);if(c.state===C){c.state=N;c.timer.stop();c.on.call(\"interrupt\",t,t.__data__,c.index,c.group);delete i[a]}else if(+a<n){c.state=N;c.timer.stop();c.on.call(\"cancel\",t,t.__data__,c.index,c.group);delete i[a]}}}u((function(){if(e.state===x){e.state=C;e.timer.restart(tick,e.delay,e.time);tick(o)}}));e.state=T;e.on.call(\"start\",t,t.__data__,e.index,e.group);if(e.state===T){e.state=x;r=new Array(l=e.tween.length);for(a=0,s=-1;a<l;++a)(c=e.tween[a].value.call(t,t.__data__,e.index,e.group))&&(r[++s]=c);r.length=s+1}}function tick(n){var i=n<e.duration?e.ease.call(null,n/e.duration):(e.timer.restart(stop),e.state=A,1),o=-1,a=r.length;while(++o<a)r[o].call(t,i);if(e.state===A){e.on.call(\"end\",t,t.__data__,e.index,e.group);stop()}}function stop(){e.state=N;e.timer.stop();delete i[n];for(var r in i)return;delete t.__transition}}function interrupt(t,n){var e,r,i,o=t.__transition,a=true;if(o){n=null==n?null:n+\"\";for(i in o)if((e=o[i]).name===n){r=e.state>T&&e.state<A;e.state=N;e.timer.stop();e.on.call(r?\"interrupt\":\"cancel\",t,t.__data__,e.index,e.group);delete o[i]}else a=false;a&&delete t.__transition}}function selection_interrupt(t){return this.each((function(){interrupt(this,t)}))}function tweenRemove(t,n){var e,r;return function(){var i=set(this,t),o=i.tween;if(o!==e){r=e=o;for(var a=0,s=r.length;a<s;++a)if(r[a].name===n){r=r.slice();r.splice(a,1);break}}i.tween=r}}function tweenFunction(t,n,e){var r,i;if(\"function\"!==typeof e)throw new Error;return function(){var o=set(this,t),a=o.tween;if(a!==r){i=(r=a).slice();for(var s={name:n,value:e},u=0,l=i.length;u<l;++u)if(i[u].name===n){i[u]=s;break}u===l&&i.push(s)}o.tween=i}}function transition_tween(t,n){var e=this._id;t+=\"\";if(arguments.length<2){var r=get(this.node(),e).tween;for(var i,o=0,a=r.length;o<a;++o)if((i=r[o]).name===t)return i.value;return null}return this.each((null==n?tweenRemove:tweenFunction)(e,t,n))}function tweenValue(t,n,e){var r=t._id;t.each((function(){var t=set(this,r);(t.value||(t.value={}))[n]=e.apply(this,arguments)}));return function(t){return get(t,r).value[n]}}function interpolate(t,n){var e;return(\"number\"===typeof n?c:n instanceof d?f:(e=d(n))?(n=e,f):h)(t,n)}function attrRemove(t){return function(){this.removeAttribute(t)}}function attrRemoveNS(t){return function(){this.removeAttributeNS(t.space,t.local)}}function attrConstant(t,n,e){var r,i,o=e+\"\";return function(){var a=this.getAttribute(t);return a===o?null:a===r?i:i=n(r=a,e)}}function attrConstantNS(t,n,e){var r,i,o=e+\"\";return function(){var a=this.getAttributeNS(t.space,t.local);return a===o?null:a===r?i:i=n(r=a,e)}}function attrFunction(t,n,e){var r,i,o;return function(){var a,s,u=e(this);if(null!=u){a=this.getAttribute(t);s=u+\"\";return a===s?null:a===r&&s===i?o:(i=s,o=n(r=a,u))}this.removeAttribute(t)}}function attrFunctionNS(t,n,e){var r,i,o;return function(){var a,s,u=e(this);if(null!=u){a=this.getAttributeNS(t.space,t.local);s=u+\"\";return a===s?null:a===r&&s===i?o:(i=s,o=n(r=a,u))}this.removeAttributeNS(t.space,t.local)}}function transition_attr(n,e){var r=t(n),i=\"transform\"===r?_:interpolate;return this.attrTween(n,\"function\"===typeof e?(r.local?attrFunctionNS:attrFunction)(r,i,tweenValue(this,\"attr.\"+n,e)):null==e?(r.local?attrRemoveNS:attrRemove)(r):(r.local?attrConstantNS:attrConstant)(r,i,e))}function attrInterpolate(t,n){return function(e){this.setAttribute(t,n.call(this,e))}}function attrInterpolateNS(t,n){return function(e){this.setAttributeNS(t.space,t.local,n.call(this,e))}}function attrTweenNS(t,n){var e,r;function tween(){var i=n.apply(this,arguments);i!==r&&(e=(r=i)&&attrInterpolateNS(t,i));return e}tween._value=n;return tween}function attrTween(t,n){var e,r;function tween(){var i=n.apply(this,arguments);i!==r&&(e=(r=i)&&attrInterpolate(t,i));return e}tween._value=n;return tween}function transition_attrTween(n,e){var r=\"attr.\"+n;if(arguments.length<2)return(r=this.tween(r))&&r._value;if(null==e)return this.tween(r,null);if(\"function\"!==typeof e)throw new Error;var i=t(n);return this.tween(r,(i.local?attrTweenNS:attrTween)(i,e))}function delayFunction(t,n){return function(){init(this,t).delay=+n.apply(this,arguments)}}function delayConstant(t,n){return n=+n,function(){init(this,t).delay=n}}function transition_delay(t){var n=this._id;return arguments.length?this.each((\"function\"===typeof t?delayFunction:delayConstant)(n,t)):get(this.node(),n).delay}function durationFunction(t,n){return function(){set(this,t).duration=+n.apply(this,arguments)}}function durationConstant(t,n){return n=+n,function(){set(this,t).duration=n}}function transition_duration(t){var n=this._id;return arguments.length?this.each((\"function\"===typeof t?durationFunction:durationConstant)(n,t)):get(this.node(),n).duration}function easeConstant(t,n){if(\"function\"!==typeof n)throw new Error;return function(){set(this,t).ease=n}}function transition_ease(t){var n=this._id;return arguments.length?this.each(easeConstant(n,t)):get(this.node(),n).ease}function easeVarying(t,n){return function(){var e=n.apply(this,arguments);if(\"function\"!==typeof e)throw new Error;set(this,t).ease=e}}function transition_easeVarying(t){if(\"function\"!==typeof t)throw new Error;return this.each(easeVarying(this._id,t))}function transition_filter(t){\"function\"!==typeof t&&(t=n(t));for(var e=this._groups,r=e.length,i=new Array(r),o=0;o<r;++o)for(var a,s=e[o],u=s.length,l=i[o]=[],c=0;c<u;++c)(a=s[c])&&t.call(a,a.__data__,c,s)&&l.push(a);return new Transition(i,this._parents,this._name,this._id)}function transition_merge(t){if(t._id!==this._id)throw new Error;for(var n=this._groups,e=t._groups,r=n.length,i=e.length,o=Math.min(r,i),a=new Array(r),s=0;s<o;++s)for(var u,l=n[s],c=e[s],f=l.length,h=a[s]=new Array(f),_=0;_<f;++_)(u=l[_]||c[_])&&(h[_]=u);for(;s<r;++s)a[s]=n[s];return new Transition(a,this._parents,this._name,this._id)}function start(t){return(t+\"\").trim().split(/^|\\s+/).every((function(t){var n=t.indexOf(\".\");n>=0&&(t=t.slice(0,n));return!t||\"start\"===t}))}function onFunction(t,n,e){var r,i,o=start(n)?init:set;return function(){var a=o(this,t),s=a.on;s!==r&&(i=(r=s).copy()).on(n,e);a.on=i}}function transition_on(t,n){var e=this._id;return arguments.length<2?get(this.node(),e).on.on(t):this.each(onFunction(e,t,n))}function removeFunction(t){return function(){var n=this.parentNode;for(var e in this.__transition)if(+e!==t)return;n&&n.removeChild(this)}}function transition_remove(){return this.on(\"end.remove\",removeFunction(this._id))}function transition_select(t){var n=this._name,r=this._id;\"function\"!==typeof t&&(t=e(t));for(var i=this._groups,o=i.length,a=new Array(o),s=0;s<o;++s)for(var u,l,c=i[s],f=c.length,h=a[s]=new Array(f),_=0;_<f;++_)if((u=c[_])&&(l=t.call(u,u.__data__,_,c))){\"__data__\"in u&&(l.__data__=u.__data__);h[_]=l;schedule(h[_],n,r,_,h,get(u,r))}return new Transition(a,this._parents,n,r)}function transition_selectAll(t){var n=this._name,e=this._id;\"function\"!==typeof t&&(t=r(t));for(var i=this._groups,o=i.length,a=[],s=[],u=0;u<o;++u)for(var l,c=i[u],f=c.length,h=0;h<f;++h)if(l=c[h]){for(var _,v=t.call(l,l.__data__,h,c),d=get(l,e),p=0,y=v.length;p<y;++p)(_=v[p])&&schedule(_,n,e,p,v,d);a.push(v);s.push(l)}return new Transition(a,s,n,e)}var F=i.prototype.constructor;function transition_selection(){return new F(this._groups,this._parents)}function styleNull(t,n){var e,r,i;return function(){var a=o(this,t),s=(this.style.removeProperty(t),o(this,t));return a===s?null:a===e&&s===r?i:i=n(e=a,r=s)}}function styleRemove(t){return function(){this.style.removeProperty(t)}}function styleConstant(t,n,e){var r,i,a=e+\"\";return function(){var s=o(this,t);return s===a?null:s===r?i:i=n(r=s,e)}}function styleFunction(t,n,e){var r,i,a;return function(){var s=o(this,t),u=e(this),l=u+\"\";null==u&&(l=u=(this.style.removeProperty(t),o(this,t)));return s===l?null:s===r&&l===i?a:(i=l,a=n(r=s,u))}}function styleMaybeRemove(t,n){var e,r,i,o,a=\"style.\"+n,s=\"end.\"+a;return function(){var u=set(this,t),l=u.on,c=null==u.value[a]?o||(o=styleRemove(n)):void 0;l===e&&i===c||(r=(e=l).copy()).on(s,i=c);u.on=r}}function transition_style(t,n,e){var r=\"transform\"===(t+=\"\")?v:interpolate;return null==n?this.styleTween(t,styleNull(t,r)).on(\"end.style.\"+t,styleRemove(t)):\"function\"===typeof n?this.styleTween(t,styleFunction(t,r,tweenValue(this,\"style.\"+t,n))).each(styleMaybeRemove(this._id,t)):this.styleTween(t,styleConstant(t,r,n),e).on(\"end.style.\"+t,null)}function styleInterpolate(t,n,e){return function(r){this.style.setProperty(t,n.call(this,r),e)}}function styleTween(t,n,e){var r,i;function tween(){var o=n.apply(this,arguments);o!==i&&(r=(i=o)&&styleInterpolate(t,o,e));return r}tween._value=n;return tween}function transition_styleTween(t,n,e){var r=\"style.\"+(t+=\"\");if(arguments.length<2)return(r=this.tween(r))&&r._value;if(null==n)return this.tween(r,null);if(\"function\"!==typeof n)throw new Error;return this.tween(r,styleTween(t,n,null==e?\"\":e))}function textConstant(t){return function(){this.textContent=t}}function textFunction(t){return function(){var n=t(this);this.textContent=null==n?\"\":n}}function transition_text(t){return this.tween(\"text\",\"function\"===typeof t?textFunction(tweenValue(this,\"text\",t)):textConstant(null==t?\"\":t+\"\"))}function textInterpolate(t){return function(n){this.textContent=t.call(this,n)}}function textTween(t){var n,e;function tween(){var r=t.apply(this,arguments);r!==e&&(n=(e=r)&&textInterpolate(r));return n}tween._value=t;return tween}function transition_textTween(t){var n=\"text\";if(arguments.length<1)return(n=this.tween(n))&&n._value;if(null==t)return this.tween(n,null);if(\"function\"!==typeof t)throw new Error;return this.tween(n,textTween(t))}function transition_transition(){var t=this._name,n=this._id,e=newId();for(var r=this._groups,i=r.length,o=0;o<i;++o)for(var a,s=r[o],u=s.length,l=0;l<u;++l)if(a=s[l]){var c=get(a,n);schedule(a,t,e,l,s,{time:c.time+c.delay+c.duration,delay:0,duration:c.duration,ease:c.ease})}return new Transition(r,this._parents,t,e)}function transition_end(){var t,n,e=this,r=e._id,i=e.size();return new Promise((function(o,a){var s={value:a},u={value:function(){0===--i&&o()}};e.each((function(){var e=set(this,r),i=e.on;if(i!==t){n=(t=i).copy();n._.cancel.push(s);n._.interrupt.push(s);n._.end.push(u)}e.on=n}));0===i&&o()}))}var b=0;function Transition(t,n,e,r){this._groups=t;this._parents=n;this._name=e;this._id=r}function transition(t){return i().transition(t)}function newId(){return++b}var S=i.prototype;Transition.prototype=transition.prototype={constructor:Transition,select:transition_select,selectAll:transition_selectAll,selectChild:S.selectChild,selectChildren:S.selectChildren,filter:transition_filter,merge:transition_merge,selection:transition_selection,transition:transition_transition,call:S.call,nodes:S.nodes,node:S.node,size:S.size,empty:S.empty,each:S.each,on:transition_on,attr:transition_attr,attrTween:transition_attrTween,style:transition_style,styleTween:transition_styleTween,text:transition_text,textTween:transition_textTween,remove:transition_remove,tween:transition_tween,delay:transition_delay,duration:transition_duration,ease:transition_ease,easeVarying:transition_easeVarying,end:transition_end,[Symbol.iterator]:S[Symbol.iterator]};var E={time:null,delay:0,duration:250,ease:p};function inherit(t,n){var e;while(!(e=t.__transition)||!(e=e[n]))if(!(t=t.parentNode))throw new Error(`transition ${n} not found`);return e}function selection_transition(t){var n,e;t instanceof Transition?(n=t._id,t=t._name):(n=newId(),(e=E).time=l(),t=null==t?null:t+\"\");for(var r=this._groups,i=r.length,o=0;o<i;++o)for(var a,s=r[o],u=s.length,c=0;c<u;++c)(a=s[c])&&schedule(a,t,n,c,s,e||inherit(a,n));return new Transition(r,this._parents,t,n)}i.prototype.interrupt=selection_interrupt;i.prototype.transition=selection_transition;var I=[null];function active(t,n){var e,r,i=t.__transition;if(i){n=null==n?null:n+\"\";for(r in i)if((e=i[r]).state>g&&e.name===n)return new Transition([[t]],I,n,+r)}return null}export{active,interrupt,transition};\n\n"
  },
  {
    "path": "vendor/javascript/d3-zoom.js",
    "content": "// d3-zoom@3.0.0 downloaded from https://ga.jspm.io/npm:d3-zoom@3.0.0/src/index.js\n\nimport{dispatch as t}from\"d3-dispatch\";import{dragDisable as o,dragEnable as e}from\"d3-drag\";import{interpolateZoom as n}from\"d3-interpolate\";import{select as i,pointer as r}from\"d3-selection\";import{interrupt as u}from\"d3-transition\";var constant=t=>()=>t;function ZoomEvent(t,{sourceEvent:o,target:e,transform:n,dispatch:i}){Object.defineProperties(this,{type:{value:t,enumerable:true,configurable:true},sourceEvent:{value:o,enumerable:true,configurable:true},target:{value:e,enumerable:true,configurable:true},transform:{value:n,enumerable:true,configurable:true},_:{value:i}})}function Transform(t,o,e){this.k=t;this.x=o;this.y=e}Transform.prototype={constructor:Transform,scale:function(t){return 1===t?this:new Transform(this.k*t,this.x,this.y)},translate:function(t,o){return 0===t&0===o?this:new Transform(this.k,this.x+this.k*t,this.y+this.k*o)},apply:function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},applyX:function(t){return t*this.k+this.x},applyY:function(t){return t*this.k+this.y},invert:function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},invertX:function(t){return(t-this.x)/this.k},invertY:function(t){return(t-this.y)/this.k},rescaleX:function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},rescaleY:function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},toString:function(){return\"translate(\"+this.x+\",\"+this.y+\") scale(\"+this.k+\")\"}};var s=new Transform(1,0,0);transform.prototype=Transform.prototype;function transform(t){while(!t.__zoom)if(!(t=t.parentNode))return s;return t.__zoom}function nopropagation(t){t.stopImmediatePropagation()}function noevent(t){t.preventDefault();t.stopImmediatePropagation()}function defaultFilter(t){return(!t.ctrlKey||\"wheel\"===t.type)&&!t.button}function defaultExtent(){var t=this;if(t instanceof SVGElement){t=t.ownerSVGElement||t;if(t.hasAttribute(\"viewBox\")){t=t.viewBox.baseVal;return[[t.x,t.y],[t.x+t.width,t.y+t.height]]}return[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]}return[[0,0],[t.clientWidth,t.clientHeight]]}function defaultTransform(){return this.__zoom||s}function defaultWheelDelta(t){return-t.deltaY*(1===t.deltaMode?.05:t.deltaMode?1:.002)*(t.ctrlKey?10:1)}function defaultTouchable(){return navigator.maxTouchPoints||\"ontouchstart\"in this}function defaultConstrain(t,o,e){var n=t.invertX(o[0][0])-e[0][0],i=t.invertX(o[1][0])-e[1][0],r=t.invertY(o[0][1])-e[0][1],u=t.invertY(o[1][1])-e[1][1];return t.translate(i>n?(n+i)/2:Math.min(0,n)||Math.max(0,i),u>r?(r+u)/2:Math.min(0,r)||Math.max(0,u))}function zoom(){var a,h,c,l=defaultFilter,m=defaultExtent,f=defaultConstrain,p=defaultWheelDelta,d=defaultTouchable,v=[0,Infinity],z=[[-Infinity,-Infinity],[Infinity,Infinity]],y=250,g=n,_=t(\"start\",\"zoom\",\"end\"),w=500,T=150,k=0,x=10;function zoom(t){t.property(\"__zoom\",defaultTransform).on(\"wheel.zoom\",wheeled,{passive:false}).on(\"mousedown.zoom\",mousedowned).on(\"dblclick.zoom\",dblclicked).filter(d).on(\"touchstart.zoom\",touchstarted).on(\"touchmove.zoom\",touchmoved).on(\"touchend.zoom touchcancel.zoom\",touchended).style(\"-webkit-tap-highlight-color\",\"rgba(0,0,0,0)\")}zoom.transform=function(t,o,e,n){var i=t.selection?t.selection():t;i.property(\"__zoom\",defaultTransform);t!==i?schedule(t,o,e,n):i.interrupt().each((function(){gesture(this,arguments).event(n).start().zoom(null,\"function\"===typeof o?o.apply(this,arguments):o).end()}))};zoom.scaleBy=function(t,o,e,n){zoom.scaleTo(t,(function(){var t=this.__zoom.k,e=\"function\"===typeof o?o.apply(this,arguments):o;return t*e}),e,n)};zoom.scaleTo=function(t,o,e,n){zoom.transform(t,(function(){var t=m.apply(this,arguments),n=this.__zoom,i=null==e?centroid(t):\"function\"===typeof e?e.apply(this,arguments):e,r=n.invert(i),u=\"function\"===typeof o?o.apply(this,arguments):o;return f(translate(scale(n,u),i,r),t,z)}),e,n)};zoom.translateBy=function(t,o,e,n){zoom.transform(t,(function(){return f(this.__zoom.translate(\"function\"===typeof o?o.apply(this,arguments):o,\"function\"===typeof e?e.apply(this,arguments):e),m.apply(this,arguments),z)}),null,n)};zoom.translateTo=function(t,o,e,n,i){zoom.transform(t,(function(){var t=m.apply(this,arguments),i=this.__zoom,r=null==n?centroid(t):\"function\"===typeof n?n.apply(this,arguments):n;return f(s.translate(r[0],r[1]).scale(i.k).translate(\"function\"===typeof o?-o.apply(this,arguments):-o,\"function\"===typeof e?-e.apply(this,arguments):-e),t,z)}),n,i)};function scale(t,o){o=Math.max(v[0],Math.min(v[1],o));return o===t.k?t:new Transform(o,t.x,t.y)}function translate(t,o,e){var n=o[0]-e[0]*t.k,i=o[1]-e[1]*t.k;return n===t.x&&i===t.y?t:new Transform(t.k,n,i)}function centroid(t){return[(+t[0][0]+ +t[1][0])/2,(+t[0][1]+ +t[1][1])/2]}function schedule(t,o,e,n){t.on(\"start.zoom\",(function(){gesture(this,arguments).event(n).start()})).on(\"interrupt.zoom end.zoom\",(function(){gesture(this,arguments).event(n).end()})).tween(\"zoom\",(function(){var t=this,i=arguments,r=gesture(t,i).event(n),u=m.apply(t,i),s=null==e?centroid(u):\"function\"===typeof e?e.apply(t,i):e,a=Math.max(u[1][0]-u[0][0],u[1][1]-u[0][1]),h=t.__zoom,c=\"function\"===typeof o?o.apply(t,i):o,l=g(h.invert(s).concat(a/h.k),c.invert(s).concat(a/c.k));return function(t){if(1===t)t=c;else{var o=l(t),e=a/o[2];t=new Transform(e,s[0]-o[0]*e,s[1]-o[1]*e)}r.zoom(null,t)}}))}function gesture(t,o,e){return!e&&t.__zooming||new Gesture(t,o)}function Gesture(t,o){this.that=t;this.args=o;this.active=0;this.sourceEvent=null;this.extent=m.apply(t,o);this.taps=0}Gesture.prototype={event:function(t){t&&(this.sourceEvent=t);return this},start:function(){if(1===++this.active){this.that.__zooming=this;this.emit(\"start\")}return this},zoom:function(t,o){this.mouse&&\"mouse\"!==t&&(this.mouse[1]=o.invert(this.mouse[0]));this.touch0&&\"touch\"!==t&&(this.touch0[1]=o.invert(this.touch0[0]));this.touch1&&\"touch\"!==t&&(this.touch1[1]=o.invert(this.touch1[0]));this.that.__zoom=o;this.emit(\"zoom\");return this},end:function(){if(0===--this.active){delete this.that.__zooming;this.emit(\"end\")}return this},emit:function(t){var o=i(this.that).datum();_.call(t,this.that,new ZoomEvent(t,{sourceEvent:this.sourceEvent,target:zoom,type:t,transform:this.that.__zoom,dispatch:_}),o)}};function wheeled(t,...o){if(l.apply(this,arguments)){var e=gesture(this,o).event(t),n=this.__zoom,i=Math.max(v[0],Math.min(v[1],n.k*Math.pow(2,p.apply(this,arguments)))),s=r(t);if(e.wheel){e.mouse[0][0]===s[0]&&e.mouse[0][1]===s[1]||(e.mouse[1]=n.invert(e.mouse[0]=s));clearTimeout(e.wheel)}else{if(n.k===i)return;e.mouse=[s,n.invert(s)];u(this);e.start()}noevent(t);e.wheel=setTimeout(wheelidled,T);e.zoom(\"mouse\",f(translate(scale(n,i),e.mouse[0],e.mouse[1]),e.extent,z))}function wheelidled(){e.wheel=null;e.end()}}function mousedowned(t,...n){if(!c&&l.apply(this,arguments)){var s=t.currentTarget,a=gesture(this,n,true).event(t),h=i(t.view).on(\"mousemove.zoom\",mousemoved,true).on(\"mouseup.zoom\",mouseupped,true),m=r(t,s),p=t.clientX,d=t.clientY;o(t.view);nopropagation(t);a.mouse=[m,this.__zoom.invert(m)];u(this);a.start()}function mousemoved(t){noevent(t);if(!a.moved){var o=t.clientX-p,e=t.clientY-d;a.moved=o*o+e*e>k}a.event(t).zoom(\"mouse\",f(translate(a.that.__zoom,a.mouse[0]=r(t,s),a.mouse[1]),a.extent,z))}function mouseupped(t){h.on(\"mousemove.zoom mouseup.zoom\",null);e(t.view,a.moved);noevent(t);a.event(t).end()}}function dblclicked(t,...o){if(l.apply(this,arguments)){var e=this.__zoom,n=r(t.changedTouches?t.changedTouches[0]:t,this),u=e.invert(n),s=e.k*(t.shiftKey?.5:2),a=f(translate(scale(e,s),n,u),m.apply(this,o),z);noevent(t);y>0?i(this).transition().duration(y).call(schedule,a,n,t):i(this).call(zoom.transform,a,n,t)}}function touchstarted(t,...o){if(l.apply(this,arguments)){var e,n,i,s,c=t.touches,m=c.length,f=gesture(this,o,t.changedTouches.length===m).event(t);nopropagation(t);for(n=0;n<m;++n){i=c[n],s=r(i,this);s=[s,this.__zoom.invert(s),i.identifier];f.touch0?f.touch1||f.touch0[2]===s[2]||(f.touch1=s,f.taps=0):(f.touch0=s,e=true,f.taps=1+!!a)}a&&(a=clearTimeout(a));if(e){f.taps<2&&(h=s[0],a=setTimeout((function(){a=null}),w));u(this);f.start()}}}function touchmoved(t,...o){if(this.__zooming){var e,n,i,u,s=gesture(this,o).event(t),a=t.changedTouches,h=a.length;noevent(t);for(e=0;e<h;++e){n=a[e],i=r(n,this);s.touch0&&s.touch0[2]===n.identifier?s.touch0[0]=i:s.touch1&&s.touch1[2]===n.identifier&&(s.touch1[0]=i)}n=s.that.__zoom;if(s.touch1){var c=s.touch0[0],l=s.touch0[1],m=s.touch1[0],p=s.touch1[1],d=(d=m[0]-c[0])*d+(d=m[1]-c[1])*d,v=(v=p[0]-l[0])*v+(v=p[1]-l[1])*v;n=scale(n,Math.sqrt(d/v));i=[(c[0]+m[0])/2,(c[1]+m[1])/2];u=[(l[0]+p[0])/2,(l[1]+p[1])/2]}else{if(!s.touch0)return;i=s.touch0[0],u=s.touch0[1]}s.zoom(\"touch\",f(translate(n,i,u),s.extent,z))}}function touchended(t,...o){if(this.__zooming){var e,n,u=gesture(this,o).event(t),s=t.changedTouches,a=s.length;nopropagation(t);c&&clearTimeout(c);c=setTimeout((function(){c=null}),w);for(e=0;e<a;++e){n=s[e];u.touch0&&u.touch0[2]===n.identifier?delete u.touch0:u.touch1&&u.touch1[2]===n.identifier&&delete u.touch1}u.touch1&&!u.touch0&&(u.touch0=u.touch1,delete u.touch1);if(u.touch0)u.touch0[1]=this.__zoom.invert(u.touch0[0]);else{u.end();if(2===u.taps){n=r(n,this);if(Math.hypot(h[0]-n[0],h[1]-n[1])<x){var l=i(this).on(\"dblclick.zoom\");l&&l.apply(this,arguments)}}}}}zoom.wheelDelta=function(t){return arguments.length?(p=\"function\"===typeof t?t:constant(+t),zoom):p};zoom.filter=function(t){return arguments.length?(l=\"function\"===typeof t?t:constant(!!t),zoom):l};zoom.touchable=function(t){return arguments.length?(d=\"function\"===typeof t?t:constant(!!t),zoom):d};zoom.extent=function(t){return arguments.length?(m=\"function\"===typeof t?t:constant([[+t[0][0],+t[0][1]],[+t[1][0],+t[1][1]]]),zoom):m};zoom.scaleExtent=function(t){return arguments.length?(v[0]=+t[0],v[1]=+t[1],zoom):[v[0],v[1]]};zoom.translateExtent=function(t){return arguments.length?(z[0][0]=+t[0][0],z[1][0]=+t[1][0],z[0][1]=+t[0][1],z[1][1]=+t[1][1],zoom):[[z[0][0],z[0][1]],[z[1][0],z[1][1]]]};zoom.constrain=function(t){return arguments.length?(f=t,zoom):f};zoom.duration=function(t){return arguments.length?(y=+t,zoom):y};zoom.interpolate=function(t){return arguments.length?(g=t,zoom):g};zoom.on=function(){var t=_.on.apply(_,arguments);return t===_?zoom:t};zoom.clickDistance=function(t){return arguments.length?(k=(t=+t)*t,zoom):Math.sqrt(k)};zoom.tapDistance=function(t){return arguments.length?(x=+t,zoom):x};return zoom}export{Transform as ZoomTransform,zoom,s as zoomIdentity,transform as zoomTransform};\n\n"
  },
  {
    "path": "vendor/javascript/d3.js",
    "content": "// d3@7.9.0 downloaded from https://ga.jspm.io/npm:d3@7.9.0/src/index.js\n\nexport*from\"d3-array\";export*from\"d3-axis\";export*from\"d3-brush\";export*from\"d3-chord\";export*from\"d3-color\";export*from\"d3-contour\";export*from\"d3-delaunay\";export*from\"d3-dispatch\";export*from\"d3-drag\";export*from\"d3-dsv\";export*from\"d3-ease\";export*from\"d3-fetch\";export*from\"d3-force\";export*from\"d3-format\";export*from\"d3-geo\";export*from\"d3-hierarchy\";export*from\"d3-interpolate\";export*from\"d3-path\";export*from\"d3-polygon\";export*from\"d3-quadtree\";export*from\"d3-random\";export*from\"d3-scale\";export*from\"d3-scale-chromatic\";export*from\"d3-selection\";export*from\"d3-shape\";export*from\"d3-time\";export*from\"d3-time-format\";export*from\"d3-timer\";export*from\"d3-transition\";export*from\"d3-zoom\";\n\n"
  },
  {
    "path": "vendor/javascript/delaunator.js",
    "content": "// delaunator@5.0.1 downloaded from https://ga.jspm.io/npm:delaunator@5.0.1/index.js\n\nimport{orient2d as t}from\"robust-predicates\";const s=Math.pow(2,-52);const i=new Uint32Array(512);class Delaunator{static from(t,s=defaultGetX,i=defaultGetY){const n=t.length;const e=new Float64Array(n*2);for(let h=0;h<n;h++){const n=t[h];e[2*h]=s(n);e[2*h+1]=i(n)}return new Delaunator(e)}constructor(t){const s=t.length>>1;if(s>0&&typeof t[0]!==\"number\")throw new Error(\"Expected coords to contain numbers.\");this.coords=t;const i=Math.max(2*s-5,0);this._triangles=new Uint32Array(i*3);this._halfedges=new Int32Array(i*3);this._hashSize=Math.ceil(Math.sqrt(s));this._hullPrev=new Uint32Array(s);this._hullNext=new Uint32Array(s);this._hullTri=new Uint32Array(s);this._hullHash=new Int32Array(this._hashSize);this._ids=new Uint32Array(s);this._dists=new Float64Array(s);this.update()}update(){const{coords:i,_hullPrev:n,_hullNext:e,_hullTri:h,_hullHash:l}=this;const r=i.length>>1;let o=Infinity;let c=Infinity;let a=-Infinity;let u=-Infinity;for(let t=0;t<r;t++){const s=i[2*t];const n=i[2*t+1];s<o&&(o=s);n<c&&(c=n);s>a&&(a=s);n>u&&(u=n);this._ids[t]=t}const _=(o+a)/2;const f=(c+u)/2;let d,y,g;for(let t=0,s=Infinity;t<r;t++){const n=dist(_,f,i[2*t],i[2*t+1]);if(n<s){d=t;s=n}}const w=i[2*d];const k=i[2*d+1];for(let t=0,s=Infinity;t<r;t++){if(t===d)continue;const n=dist(w,k,i[2*t],i[2*t+1]);if(n<s&&n>0){y=t;s=n}}let b=i[2*y];let p=i[2*y+1];let A=Infinity;for(let t=0;t<r;t++){if(t===d||t===y)continue;const s=circumradius(w,k,b,p,i[2*t],i[2*t+1]);if(s<A){g=t;A=s}}let I=i[2*g];let S=i[2*g+1];if(A===Infinity){for(let t=0;t<r;t++)this._dists[t]=i[2*t]-i[0]||i[2*t+1]-i[1];quicksort(this._ids,this._dists,0,r-1);const t=new Uint32Array(r);let s=0;for(let i=0,n=-Infinity;i<r;i++){const e=this._ids[i];const h=this._dists[e];if(h>n){t[s++]=e;n=h}}this.hull=t.subarray(0,s);this.triangles=new Uint32Array(0);this.halfedges=new Uint32Array(0);return}if(t(w,k,b,p,I,S)<0){const t=y;const s=b;const i=p;y=g;b=I;p=S;g=t;I=s;S=i}const m=circumcenter(w,k,b,p,I,S);this._cx=m.x;this._cy=m.y;for(let t=0;t<r;t++)this._dists[t]=dist(i[2*t],i[2*t+1],m.x,m.y);quicksort(this._ids,this._dists,0,r-1);this._hullStart=d;let x=3;e[d]=n[g]=y;e[y]=n[d]=g;e[g]=n[y]=d;h[d]=0;h[y]=1;h[g]=2;l.fill(-1);l[this._hashKey(w,k)]=d;l[this._hashKey(b,p)]=y;l[this._hashKey(I,S)]=g;this.trianglesLen=0;this._addTriangle(d,y,g,-1,-1,-1);for(let r,o,c=0;c<this._ids.length;c++){const a=this._ids[c];const u=i[2*a];const _=i[2*a+1];if(c>0&&Math.abs(u-r)<=s&&Math.abs(_-o)<=s)continue;r=u;o=_;if(a===d||a===y||a===g)continue;let f=0;for(let t=0,s=this._hashKey(u,_);t<this._hashSize;t++){f=l[(s+t)%this._hashSize];if(f!==-1&&f!==e[f])break}f=n[f];let w,k=f;while(w=e[k],t(u,_,i[2*k],i[2*k+1],i[2*w],i[2*w+1])>=0){k=w;if(k===f){k=-1;break}}if(k===-1)continue;let b=this._addTriangle(k,a,e[k],-1,-1,h[k]);h[a]=this._legalize(b+2);h[k]=b;x++;let p=e[k];while(w=e[p],t(u,_,i[2*p],i[2*p+1],i[2*w],i[2*w+1])<0){b=this._addTriangle(p,a,w,h[a],-1,h[p]);h[a]=this._legalize(b+2);e[p]=p;x--;p=w}if(k===f)while(w=n[k],t(u,_,i[2*w],i[2*w+1],i[2*k],i[2*k+1])<0){b=this._addTriangle(w,a,k,-1,h[k],h[w]);this._legalize(b+2);h[w]=b;e[k]=k;x--;k=w}this._hullStart=n[a]=k;e[k]=n[p]=a;e[a]=p;l[this._hashKey(u,_)]=a;l[this._hashKey(i[2*k],i[2*k+1])]=k}this.hull=new Uint32Array(x);for(let t=0,s=this._hullStart;t<x;t++){this.hull[t]=s;s=e[s]}this.triangles=this._triangles.subarray(0,this.trianglesLen);this.halfedges=this._halfedges.subarray(0,this.trianglesLen)}_hashKey(t,s){return Math.floor(pseudoAngle(t-this._cx,s-this._cy)*this._hashSize)%this._hashSize}_legalize(t){const{_triangles:s,_halfedges:n,coords:e}=this;let h=0;let l=0;while(true){const r=n[t];const o=t-t%3;l=o+(t+2)%3;if(r===-1){if(h===0)break;t=i[--h];continue}const c=r-r%3;const a=o+(t+1)%3;const u=c+(r+2)%3;const _=s[l];const f=s[t];const d=s[a];const y=s[u];const g=inCircle(e[2*_],e[2*_+1],e[2*f],e[2*f+1],e[2*d],e[2*d+1],e[2*y],e[2*y+1]);if(g){s[t]=y;s[r]=_;const e=n[u];if(e===-1){let s=this._hullStart;do{if(this._hullTri[s]===u){this._hullTri[s]=t;break}s=this._hullPrev[s]}while(s!==this._hullStart)}this._link(t,e);this._link(r,n[l]);this._link(l,u);const o=c+(r+1)%3;h<i.length&&(i[h++]=o)}else{if(h===0)break;t=i[--h]}}return l}_link(t,s){this._halfedges[t]=s;s!==-1&&(this._halfedges[s]=t)}_addTriangle(t,s,i,n,e,h){const l=this.trianglesLen;this._triangles[l]=t;this._triangles[l+1]=s;this._triangles[l+2]=i;this._link(l,n);this._link(l+1,e);this._link(l+2,h);this.trianglesLen+=3;return l}}function pseudoAngle(t,s){const i=t/(Math.abs(t)+Math.abs(s));return(s>0?3-i:1+i)/4}function dist(t,s,i,n){const e=t-i;const h=s-n;return e*e+h*h}function inCircle(t,s,i,n,e,h,l,r){const o=t-l;const c=s-r;const a=i-l;const u=n-r;const _=e-l;const f=h-r;const d=o*o+c*c;const y=a*a+u*u;const g=_*_+f*f;return o*(u*g-y*f)-c*(a*g-y*_)+d*(a*f-u*_)<0}function circumradius(t,s,i,n,e,h){const l=i-t;const r=n-s;const o=e-t;const c=h-s;const a=l*l+r*r;const u=o*o+c*c;const _=.5/(l*c-r*o);const f=(c*a-r*u)*_;const d=(l*u-o*a)*_;return f*f+d*d}function circumcenter(t,s,i,n,e,h){const l=i-t;const r=n-s;const o=e-t;const c=h-s;const a=l*l+r*r;const u=o*o+c*c;const _=.5/(l*c-r*o);const f=t+(c*a-r*u)*_;const d=s+(l*u-o*a)*_;return{x:f,y:d}}function quicksort(t,s,i,n){if(n-i<=20)for(let e=i+1;e<=n;e++){const n=t[e];const h=s[n];let l=e-1;while(l>=i&&s[t[l]]>h)t[l+1]=t[l--];t[l+1]=n}else{const e=i+n>>1;let h=i+1;let l=n;swap(t,e,h);s[t[i]]>s[t[n]]&&swap(t,i,n);s[t[h]]>s[t[n]]&&swap(t,h,n);s[t[i]]>s[t[h]]&&swap(t,i,h);const r=t[h];const o=s[r];while(true){do{h++}while(s[t[h]]<o);do{l--}while(s[t[l]]>o);if(l<h)break;swap(t,h,l)}t[i+1]=t[l];t[l]=r;if(n-h+1>=l-i){quicksort(t,s,h,n);quicksort(t,s,i,l-1)}else{quicksort(t,s,i,l-1);quicksort(t,s,h,n)}}}function swap(t,s,i){const n=t[s];t[s]=t[i];t[i]=n}function defaultGetX(t){return t[0]}function defaultGetY(t){return t[1]}export{Delaunator as default};\n\n"
  },
  {
    "path": "vendor/javascript/internmap.js",
    "content": "// internmap@2.0.3 downloaded from https://ga.jspm.io/npm:internmap@2.0.3/src/index.js\n\nclass InternMap extends Map{constructor(e,t=keyof){super();Object.defineProperties(this,{_intern:{value:new Map},_key:{value:t}});if(null!=e)for(const[t,n]of e)this.set(t,n)}get(e){return super.get(intern_get(this,e))}has(e){return super.has(intern_get(this,e))}set(e,t){return super.set(intern_set(this,e),t)}delete(e){return super.delete(intern_delete(this,e))}}class InternSet extends Set{constructor(e,t=keyof){super();Object.defineProperties(this,{_intern:{value:new Map},_key:{value:t}});if(null!=e)for(const t of e)this.add(t)}has(e){return super.has(intern_get(this,e))}add(e){return super.add(intern_set(this,e))}delete(e){return super.delete(intern_delete(this,e))}}function intern_get({_intern:e,_key:t},n){const r=t(n);return e.has(r)?e.get(r):n}function intern_set({_intern:e,_key:t},n){const r=t(n);if(e.has(r))return e.get(r);e.set(r,n);return n}function intern_delete({_intern:e,_key:t},n){const r=t(n);if(e.has(r)){n=e.get(r);e.delete(r)}return n}function keyof(e){return null!==e&&\"object\"===typeof e?e.valueOf():e}export{InternMap,InternSet};\n\n"
  },
  {
    "path": "vendor/javascript/robust-predicates.js",
    "content": "// robust-predicates@3.0.2 downloaded from https://ga.jspm.io/npm:robust-predicates@3.0.2/index.js\n\nconst c=11102230246251565e-32;const s=134217729;const t=(3+8*c)*c;function sum(c,s,t,n,e){let o,a,l,i;let r=s[0];let f=n[0];let u=0;let d=0;if(f>r===f>-r){o=r;r=s[++u]}else{o=f;f=n[++d]}let v=0;if(u<c&&d<t){if(f>r===f>-r){a=r+o;l=o-(a-r);r=s[++u]}else{a=f+o;l=o-(a-f);f=n[++d]}o=a;0!==l&&(e[v++]=l);while(u<c&&d<t){if(f>r===f>-r){a=o+r;i=a-o;l=o-(a-i)+(r-i);r=s[++u]}else{a=o+f;i=a-o;l=o-(a-i)+(f-i);f=n[++d]}o=a;0!==l&&(e[v++]=l)}}while(u<c){a=o+r;i=a-o;l=o-(a-i)+(r-i);r=s[++u];o=a;0!==l&&(e[v++]=l)}while(d<t){a=o+f;i=a-o;l=o-(a-i)+(f-i);f=n[++d];o=a;0!==l&&(e[v++]=l)}0===o&&0!==v||(e[v++]=o);return v}function sum_three(c,s,t,n,e,o,a,l){return sum(sum(c,s,t,n,a),a,e,o,l)}function scale(c,t,n,e){let o,a,l,i,r;let f,u,d,v,h,m;u=s*n;h=u-(u-n);m=n-h;let _=t[0];o=_*n;u=s*_;d=u-(u-_);v=_-d;l=v*m-(o-d*h-v*h-d*m);let b=0;0!==l&&(e[b++]=l);for(let M=1;M<c;M++){_=t[M];i=_*n;u=s*_;d=u-(u-_);v=_-d;r=v*m-(i-d*h-v*h-d*m);a=o+r;f=a-o;l=o-(a-f)+(r-f);0!==l&&(e[b++]=l);o=i+a;l=a-(o-i);0!==l&&(e[b++]=l)}0===o&&0!==b||(e[b++]=o);return b}function negate(c,s){for(let t=0;t<c;t++)s[t]=-s[t];return c}function estimate(c,s){let t=s[0];for(let n=1;n<c;n++)t+=s[n];return t}function vec(c){return new Float64Array(c)}const n=(3+16*c)*c;const e=(2+12*c)*c;const o=(9+64*c)*c*c;const a=vec(4);const l=vec(8);const i=vec(12);const r=vec(16);const f=vec(4);function orient2dadapt(c,n,u,d,v,h,m){let _,b,M,p;let $,x,g,w,y,A,F,j,k,q,z,B,C,D;const E=c-v;const G=u-v;const H=n-h;const I=d-h;q=E*I;x=s*E;g=x-(x-E);w=E-g;x=s*I;y=x-(x-I);A=I-y;z=w*A-(q-g*y-w*y-g*A);B=H*G;x=s*H;g=x-(x-H);w=H-g;x=s*G;y=x-(x-G);A=G-y;C=w*A-(B-g*y-w*y-g*A);F=z-C;$=z-F;a[0]=z-(F+$)+($-C);j=q+F;$=j-q;k=q-(j-$)+(F-$);F=k-B;$=k-F;a[1]=k-(F+$)+($-B);D=j+F;$=D-j;a[2]=j-(D-$)+(F-$);a[3]=D;let J=estimate(4,a);let K=e*m;if(J>=K||-J>=K)return J;$=c-E;_=c-(E+$)+($-v);$=u-G;M=u-(G+$)+($-v);$=n-H;b=n-(H+$)+($-h);$=d-I;p=d-(I+$)+($-h);if(0===_&&0===b&&0===M&&0===p)return J;K=o*m+t*Math.abs(J);J+=E*p+I*_-(H*M+G*b);if(J>=K||-J>=K)return J;q=_*I;x=s*_;g=x-(x-_);w=_-g;x=s*I;y=x-(x-I);A=I-y;z=w*A-(q-g*y-w*y-g*A);B=b*G;x=s*b;g=x-(x-b);w=b-g;x=s*G;y=x-(x-G);A=G-y;C=w*A-(B-g*y-w*y-g*A);F=z-C;$=z-F;f[0]=z-(F+$)+($-C);j=q+F;$=j-q;k=q-(j-$)+(F-$);F=k-B;$=k-F;f[1]=k-(F+$)+($-B);D=j+F;$=D-j;f[2]=j-(D-$)+(F-$);f[3]=D;const L=sum(4,a,4,f,l);q=E*p;x=s*E;g=x-(x-E);w=E-g;x=s*p;y=x-(x-p);A=p-y;z=w*A-(q-g*y-w*y-g*A);B=H*M;x=s*H;g=x-(x-H);w=H-g;x=s*M;y=x-(x-M);A=M-y;C=w*A-(B-g*y-w*y-g*A);F=z-C;$=z-F;f[0]=z-(F+$)+($-C);j=q+F;$=j-q;k=q-(j-$)+(F-$);F=k-B;$=k-F;f[1]=k-(F+$)+($-B);D=j+F;$=D-j;f[2]=j-(D-$)+(F-$);f[3]=D;const N=sum(L,l,4,f,i);q=_*p;x=s*_;g=x-(x-_);w=_-g;x=s*p;y=x-(x-p);A=p-y;z=w*A-(q-g*y-w*y-g*A);B=b*M;x=s*b;g=x-(x-b);w=b-g;x=s*M;y=x-(x-M);A=M-y;C=w*A-(B-g*y-w*y-g*A);F=z-C;$=z-F;f[0]=z-(F+$)+($-C);j=q+F;$=j-q;k=q-(j-$)+(F-$);F=k-B;$=k-F;f[1]=k-(F+$)+($-B);D=j+F;$=D-j;f[2]=j-(D-$)+(F-$);f[3]=D;const O=sum(N,i,4,f,r);return r[O-1]}function orient2d(c,s,t,e,o,a){const l=(s-a)*(t-o);const i=(c-o)*(e-a);const r=l-i;const f=Math.abs(l+i);return Math.abs(r)>=n*f?r:-orient2dadapt(c,s,t,e,o,a,f)}function orient2dfast(c,s,t,n,e,o){return(s-o)*(t-e)-(c-e)*(n-o)}const u=(7+56*c)*c;const d=(3+28*c)*c;const v=(26+288*c)*c*c;const h=vec(4);const m=vec(4);const _=vec(4);const b=vec(4);const M=vec(4);const p=vec(4);const $=vec(4);const x=vec(4);const g=vec(4);const w=vec(8);const y=vec(8);const A=vec(8);const F=vec(4);const j=vec(8);const k=vec(8);const q=vec(8);const z=vec(12);let B=vec(192);let C=vec(192);function finadd$1(c,s,t){c=sum(c,B,s,t,C);const n=B;B=C;C=n;return c}function tailinit(c,t,n,e,o,a,l,i){let r,f,u,d,v,h,m,_,b,M,p,$,x,g,w;if(0===c){if(0===t){l[0]=0;i[0]=0;return 1}w=-t;M=w*n;f=s*w;u=f-(f-w);d=w-u;f=s*n;v=f-(f-n);h=n-v;l[0]=d*h-(M-u*v-d*v-u*h);l[1]=M;M=t*o;f=s*t;u=f-(f-t);d=t-u;f=s*o;v=f-(f-o);h=o-v;i[0]=d*h-(M-u*v-d*v-u*h);i[1]=M;return 2}if(0===t){M=c*e;f=s*c;u=f-(f-c);d=c-u;f=s*e;v=f-(f-e);h=e-v;l[0]=d*h-(M-u*v-d*v-u*h);l[1]=M;w=-c;M=w*a;f=s*w;u=f-(f-w);d=w-u;f=s*a;v=f-(f-a);h=a-v;i[0]=d*h-(M-u*v-d*v-u*h);i[1]=M;return 2}M=c*e;f=s*c;u=f-(f-c);d=c-u;f=s*e;v=f-(f-e);h=e-v;p=d*h-(M-u*v-d*v-u*h);$=t*n;f=s*t;u=f-(f-t);d=t-u;f=s*n;v=f-(f-n);h=n-v;x=d*h-($-u*v-d*v-u*h);m=p-x;r=p-m;l[0]=p-(m+r)+(r-x);_=M+m;r=_-M;b=M-(_-r)+(m-r);m=b-$;r=b-m;l[1]=b-(m+r)+(r-$);g=_+m;r=g-_;l[2]=_-(g-r)+(m-r);l[3]=g;M=t*o;f=s*t;u=f-(f-t);d=t-u;f=s*o;v=f-(f-o);h=o-v;p=d*h-(M-u*v-d*v-u*h);$=c*a;f=s*c;u=f-(f-c);d=c-u;f=s*a;v=f-(f-a);h=a-v;x=d*h-($-u*v-d*v-u*h);m=p-x;r=p-m;i[0]=p-(m+r)+(r-x);_=M+m;r=_-M;b=M-(_-r)+(m-r);m=b-$;r=b-m;i[1]=b-(m+r)+(r-$);g=_+m;r=g-_;i[2]=_-(g-r)+(m-r);i[3]=g;return 4}function tailadd(c,t,n,e,o){let a,l,i,r,f,u,d,v,h,m,_,b,M;_=t*n;l=s*t;i=l-(l-t);r=t-i;l=s*n;f=l-(l-n);u=n-f;b=r*u-(_-i*f-r*f-i*u);l=s*e;f=l-(l-e);u=e-f;d=b*e;l=s*b;i=l-(l-b);r=b-i;F[0]=r*u-(d-i*f-r*f-i*u);v=_*e;l=s*_;i=l-(l-_);r=_-i;m=r*u-(v-i*f-r*f-i*u);h=d+m;a=h-d;F[1]=d-(h-a)+(m-a);M=v+h;F[2]=h-(M-v);F[3]=M;c=finadd$1(c,4,F);if(0!==o){l=s*o;f=l-(l-o);u=o-f;d=b*o;l=s*b;i=l-(l-b);r=b-i;F[0]=r*u-(d-i*f-r*f-i*u);v=_*o;l=s*_;i=l-(l-_);r=_-i;m=r*u-(v-i*f-r*f-i*u);h=d+m;a=h-d;F[1]=d-(h-a)+(m-a);M=v+h;F[2]=h-(M-v);F[3]=M;c=finadd$1(c,4,F)}return c}function orient3dadapt(c,n,e,o,a,l,i,r,f,u,F,C,D){let E;let G,H,I;let J,K,L;let N,O,P;let Q,R,S,T,U,V,W,X,Y,Z,cc,sc,tc,nc;const ec=c-u;const oc=o-u;const ac=i-u;const lc=n-F;const ic=a-F;const rc=r-F;const fc=e-C;const uc=l-C;const dc=f-C;Z=oc*rc;R=s*oc;S=R-(R-oc);T=oc-S;R=s*rc;U=R-(R-rc);V=rc-U;cc=T*V-(Z-S*U-T*U-S*V);sc=ac*ic;R=s*ac;S=R-(R-ac);T=ac-S;R=s*ic;U=R-(R-ic);V=ic-U;tc=T*V-(sc-S*U-T*U-S*V);W=cc-tc;Q=cc-W;h[0]=cc-(W+Q)+(Q-tc);X=Z+W;Q=X-Z;Y=Z-(X-Q)+(W-Q);W=Y-sc;Q=Y-W;h[1]=Y-(W+Q)+(Q-sc);nc=X+W;Q=nc-X;h[2]=X-(nc-Q)+(W-Q);h[3]=nc;Z=ac*lc;R=s*ac;S=R-(R-ac);T=ac-S;R=s*lc;U=R-(R-lc);V=lc-U;cc=T*V-(Z-S*U-T*U-S*V);sc=ec*rc;R=s*ec;S=R-(R-ec);T=ec-S;R=s*rc;U=R-(R-rc);V=rc-U;tc=T*V-(sc-S*U-T*U-S*V);W=cc-tc;Q=cc-W;m[0]=cc-(W+Q)+(Q-tc);X=Z+W;Q=X-Z;Y=Z-(X-Q)+(W-Q);W=Y-sc;Q=Y-W;m[1]=Y-(W+Q)+(Q-sc);nc=X+W;Q=nc-X;m[2]=X-(nc-Q)+(W-Q);m[3]=nc;Z=ec*ic;R=s*ec;S=R-(R-ec);T=ec-S;R=s*ic;U=R-(R-ic);V=ic-U;cc=T*V-(Z-S*U-T*U-S*V);sc=oc*lc;R=s*oc;S=R-(R-oc);T=oc-S;R=s*lc;U=R-(R-lc);V=lc-U;tc=T*V-(sc-S*U-T*U-S*V);W=cc-tc;Q=cc-W;_[0]=cc-(W+Q)+(Q-tc);X=Z+W;Q=X-Z;Y=Z-(X-Q)+(W-Q);W=Y-sc;Q=Y-W;_[1]=Y-(W+Q)+(Q-sc);nc=X+W;Q=nc-X;_[2]=X-(nc-Q)+(W-Q);_[3]=nc;E=sum(sum(scale(4,h,fc,j),j,scale(4,m,uc,k),k,q),q,scale(4,_,dc,j),j,B);let vc=estimate(E,B);let hc=d*D;if(vc>=hc||-vc>=hc)return vc;Q=c-ec;G=c-(ec+Q)+(Q-u);Q=o-oc;H=o-(oc+Q)+(Q-u);Q=i-ac;I=i-(ac+Q)+(Q-u);Q=n-lc;J=n-(lc+Q)+(Q-F);Q=a-ic;K=a-(ic+Q)+(Q-F);Q=r-rc;L=r-(rc+Q)+(Q-F);Q=e-fc;N=e-(fc+Q)+(Q-C);Q=l-uc;O=l-(uc+Q)+(Q-C);Q=f-dc;P=f-(dc+Q)+(Q-C);if(0===G&&0===H&&0===I&&0===J&&0===K&&0===L&&0===N&&0===O&&0===P)return vc;hc=v*D+t*Math.abs(vc);vc+=fc*(oc*L+rc*H-(ic*I+ac*K))+N*(oc*rc-ic*ac)+uc*(ac*J+lc*I-(rc*G+ec*L))+O*(ac*lc-rc*ec)+dc*(ec*K+ic*G-(lc*H+oc*J))+P*(ec*ic-lc*oc);if(vc>=hc||-vc>=hc)return vc;const mc=tailinit(G,J,oc,ic,ac,rc,b,M);const _c=tailinit(H,K,ac,rc,ec,lc,p,$);const bc=tailinit(I,L,ec,lc,oc,ic,x,g);const Mc=sum(_c,p,bc,g,w);E=finadd$1(E,scale(Mc,w,fc,q),q);const pc=sum(bc,x,mc,M,y);E=finadd$1(E,scale(pc,y,uc,q),q);const $c=sum(mc,b,_c,$,A);E=finadd$1(E,scale($c,A,dc,q),q);if(0!==N){E=finadd$1(E,scale(4,h,N,z),z);E=finadd$1(E,scale(Mc,w,N,q),q)}if(0!==O){E=finadd$1(E,scale(4,m,O,z),z);E=finadd$1(E,scale(pc,y,O,q),q)}if(0!==P){E=finadd$1(E,scale(4,_,P,z),z);E=finadd$1(E,scale($c,A,P,q),q)}if(0!==G){0!==K&&(E=tailadd(E,G,K,dc,P));0!==L&&(E=tailadd(E,-G,L,uc,O))}if(0!==H){0!==L&&(E=tailadd(E,H,L,fc,N));0!==J&&(E=tailadd(E,-H,J,dc,P))}if(0!==I){0!==J&&(E=tailadd(E,I,J,uc,O));0!==K&&(E=tailadd(E,-I,K,fc,N))}return B[E-1]}function orient3d(c,s,t,n,e,o,a,l,i,r,f,d){const v=c-r;const h=n-r;const m=a-r;const _=s-f;const b=e-f;const M=l-f;const p=t-d;const $=o-d;const x=i-d;const g=h*M;const w=m*b;const y=m*_;const A=v*M;const F=v*b;const j=h*_;const k=p*(g-w)+$*(y-A)+x*(F-j);const q=(Math.abs(g)+Math.abs(w))*Math.abs(p)+(Math.abs(y)+Math.abs(A))*Math.abs($)+(Math.abs(F)+Math.abs(j))*Math.abs(x);const z=u*q;return k>z||-k>z?k:orient3dadapt(c,s,t,n,e,o,a,l,i,r,f,d,q)}function orient3dfast(c,s,t,n,e,o,a,l,i,r,f,u){const d=c-r;const v=n-r;const h=a-r;const m=s-f;const _=e-f;const b=l-f;const M=t-u;const p=o-u;const $=i-u;return d*(_*$-p*b)+v*(b*M-$*m)+h*(m*p-M*_)}const D=(10+96*c)*c;const E=(4+48*c)*c;const G=(44+576*c)*c*c;const H=vec(4);const I=vec(4);const J=vec(4);const K=vec(4);const L=vec(4);const N=vec(4);const O=vec(4);const P=vec(4);const Q=vec(8);const R=vec(8);const S=vec(8);const T=vec(8);const U=vec(8);const V=vec(8);const W=vec(8);const X=vec(8);const Y=vec(8);const Z=vec(4);const cc=vec(4);const sc=vec(4);const tc=vec(8);const nc=vec(16);const ec=vec(16);const oc=vec(16);const ac=vec(32);const lc=vec(32);const ic=vec(48);const rc=vec(64);let fc=vec(1152);let uc=vec(1152);function finadd(c,s,t){c=sum(c,fc,s,t,uc);const n=fc;fc=uc;uc=n;return c}function incircleadapt(c,n,e,o,a,l,i,r,f){let u;let d,v,h,m,_,b;let M,p,$,x,g,w;let y,A,F;let j,k,q;let z,B;let C,D,uc,dc,vc,hc,mc,_c,bc,Mc,pc,$c,xc,gc;const wc=c-i;const yc=e-i;const Ac=a-i;const Fc=n-r;const jc=o-r;const kc=l-r;Mc=yc*kc;D=s*yc;uc=D-(D-yc);dc=yc-uc;D=s*kc;vc=D-(D-kc);hc=kc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=Ac*jc;D=s*Ac;uc=D-(D-Ac);dc=Ac-uc;D=s*jc;vc=D-(D-jc);hc=jc-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc-xc;C=pc-mc;H[0]=pc-(mc+C)+(C-xc);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc-$c;C=bc-mc;H[1]=bc-(mc+C)+(C-$c);gc=_c+mc;C=gc-_c;H[2]=_c-(gc-C)+(mc-C);H[3]=gc;Mc=Ac*Fc;D=s*Ac;uc=D-(D-Ac);dc=Ac-uc;D=s*Fc;vc=D-(D-Fc);hc=Fc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=wc*kc;D=s*wc;uc=D-(D-wc);dc=wc-uc;D=s*kc;vc=D-(D-kc);hc=kc-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc-xc;C=pc-mc;I[0]=pc-(mc+C)+(C-xc);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc-$c;C=bc-mc;I[1]=bc-(mc+C)+(C-$c);gc=_c+mc;C=gc-_c;I[2]=_c-(gc-C)+(mc-C);I[3]=gc;Mc=wc*jc;D=s*wc;uc=D-(D-wc);dc=wc-uc;D=s*jc;vc=D-(D-jc);hc=jc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=yc*Fc;D=s*yc;uc=D-(D-yc);dc=yc-uc;D=s*Fc;vc=D-(D-Fc);hc=Fc-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc-xc;C=pc-mc;J[0]=pc-(mc+C)+(C-xc);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc-$c;C=bc-mc;J[1]=bc-(mc+C)+(C-$c);gc=_c+mc;C=gc-_c;J[2]=_c-(gc-C)+(mc-C);J[3]=gc;u=sum(sum(sum(scale(scale(4,H,wc,tc),tc,wc,nc),nc,scale(scale(4,H,Fc,tc),tc,Fc,ec),ec,ac),ac,sum(scale(scale(4,I,yc,tc),tc,yc,nc),nc,scale(scale(4,I,jc,tc),tc,jc,ec),ec,lc),lc,rc),rc,sum(scale(scale(4,J,Ac,tc),tc,Ac,nc),nc,scale(scale(4,J,kc,tc),tc,kc,ec),ec,ac),ac,fc);let qc=estimate(u,fc);let zc=E*f;if(qc>=zc||-qc>=zc)return qc;C=c-wc;d=c-(wc+C)+(C-i);C=n-Fc;m=n-(Fc+C)+(C-r);C=e-yc;v=e-(yc+C)+(C-i);C=o-jc;_=o-(jc+C)+(C-r);C=a-Ac;h=a-(Ac+C)+(C-i);C=l-kc;b=l-(kc+C)+(C-r);if(0===d&&0===v&&0===h&&0===m&&0===_&&0===b)return qc;zc=G*f+t*Math.abs(qc);qc+=(wc*wc+Fc*Fc)*(yc*b+kc*v-(jc*h+Ac*_))+2*(wc*d+Fc*m)*(yc*kc-jc*Ac)+((yc*yc+jc*jc)*(Ac*m+Fc*h-(kc*d+wc*b))+2*(yc*v+jc*_)*(Ac*Fc-kc*wc))+((Ac*Ac+kc*kc)*(wc*_+jc*d-(Fc*v+yc*m))+2*(Ac*h+kc*b)*(wc*jc-Fc*yc));if(qc>=zc||-qc>=zc)return qc;if(0!==v||0!==_||0!==h||0!==b){Mc=wc*wc;D=s*wc;uc=D-(D-wc);dc=wc-uc;pc=dc*dc-(Mc-uc*uc-(uc+uc)*dc);$c=Fc*Fc;D=s*Fc;uc=D-(D-Fc);dc=Fc-uc;xc=dc*dc-($c-uc*uc-(uc+uc)*dc);mc=pc+xc;C=mc-pc;K[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;K[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;K[2]=_c-(gc-C)+(mc-C);K[3]=gc}if(0!==h||0!==b||0!==d||0!==m){Mc=yc*yc;D=s*yc;uc=D-(D-yc);dc=yc-uc;pc=dc*dc-(Mc-uc*uc-(uc+uc)*dc);$c=jc*jc;D=s*jc;uc=D-(D-jc);dc=jc-uc;xc=dc*dc-($c-uc*uc-(uc+uc)*dc);mc=pc+xc;C=mc-pc;L[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;L[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;L[2]=_c-(gc-C)+(mc-C);L[3]=gc}if(0!==d||0!==m||0!==v||0!==_){Mc=Ac*Ac;D=s*Ac;uc=D-(D-Ac);dc=Ac-uc;pc=dc*dc-(Mc-uc*uc-(uc+uc)*dc);$c=kc*kc;D=s*kc;uc=D-(D-kc);dc=kc-uc;xc=dc*dc-($c-uc*uc-(uc+uc)*dc);mc=pc+xc;C=mc-pc;N[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;N[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;N[2]=_c-(gc-C)+(mc-C);N[3]=gc}if(0!==d){M=scale(4,H,d,Q);u=finadd(u,sum_three(scale(M,Q,2*wc,nc),nc,scale(scale(4,N,d,tc),tc,jc,ec),ec,scale(scale(4,L,d,tc),tc,-kc,oc),oc,ac,ic),ic)}if(0!==m){p=scale(4,H,m,R);u=finadd(u,sum_three(scale(p,R,2*Fc,nc),nc,scale(scale(4,L,m,tc),tc,Ac,ec),ec,scale(scale(4,N,m,tc),tc,-yc,oc),oc,ac,ic),ic)}if(0!==v){$=scale(4,I,v,S);u=finadd(u,sum_three(scale($,S,2*yc,nc),nc,scale(scale(4,K,v,tc),tc,kc,ec),ec,scale(scale(4,N,v,tc),tc,-Fc,oc),oc,ac,ic),ic)}if(0!==_){x=scale(4,I,_,T);u=finadd(u,sum_three(scale(x,T,2*jc,nc),nc,scale(scale(4,N,_,tc),tc,wc,ec),ec,scale(scale(4,K,_,tc),tc,-Ac,oc),oc,ac,ic),ic)}if(0!==h){g=scale(4,J,h,U);u=finadd(u,sum_three(scale(g,U,2*Ac,nc),nc,scale(scale(4,L,h,tc),tc,Fc,ec),ec,scale(scale(4,K,h,tc),tc,-jc,oc),oc,ac,ic),ic)}if(0!==b){w=scale(4,J,b,V);u=finadd(u,sum_three(scale(w,V,2*kc,nc),nc,scale(scale(4,K,b,tc),tc,yc,ec),ec,scale(scale(4,L,b,tc),tc,-wc,oc),oc,ac,ic),ic)}if(0!==d||0!==m){if(0!==v||0!==_||0!==h||0!==b){Mc=v*kc;D=s*v;uc=D-(D-v);dc=v-uc;D=s*kc;vc=D-(D-kc);hc=kc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=yc*b;D=s*yc;uc=D-(D-yc);dc=yc-uc;D=s*b;vc=D-(D-b);hc=b-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc+xc;C=mc-pc;O[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;O[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;O[2]=_c-(gc-C)+(mc-C);O[3]=gc;Mc=h*-jc;D=s*h;uc=D-(D-h);dc=h-uc;D=s*-jc;vc=D-(D- -jc);hc=-jc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=Ac*-_;D=s*Ac;uc=D-(D-Ac);dc=Ac-uc;D=s*-_;vc=D-(D- -_);hc=-_-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc+xc;C=mc-pc;P[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;P[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;P[2]=_c-(gc-C)+(mc-C);P[3]=gc;A=sum(4,O,4,P,X);Mc=v*b;D=s*v;uc=D-(D-v);dc=v-uc;D=s*b;vc=D-(D-b);hc=b-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=h*_;D=s*h;uc=D-(D-h);dc=h-uc;D=s*_;vc=D-(D-_);hc=_-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc-xc;C=pc-mc;cc[0]=pc-(mc+C)+(C-xc);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc-$c;C=bc-mc;cc[1]=bc-(mc+C)+(C-$c);gc=_c+mc;C=gc-_c;cc[2]=_c-(gc-C)+(mc-C);cc[3]=gc;k=4}else{X[0]=0;A=1;cc[0]=0;k=1}if(0!==d){const c=scale(A,X,d,oc);u=finadd(u,sum(scale(M,Q,d,nc),nc,scale(c,oc,2*wc,ac),ac,ic),ic);const s=scale(k,cc,d,tc);u=finadd(u,sum_three(scale(s,tc,2*wc,nc),nc,scale(s,tc,d,ec),ec,scale(c,oc,d,ac),ac,lc,rc),rc);0!==_&&(u=finadd(u,scale(scale(4,N,d,tc),tc,_,nc),nc));0!==b&&(u=finadd(u,scale(scale(4,L,-d,tc),tc,b,nc),nc))}if(0!==m){const c=scale(A,X,m,oc);u=finadd(u,sum(scale(p,R,m,nc),nc,scale(c,oc,2*Fc,ac),ac,ic),ic);const s=scale(k,cc,m,tc);u=finadd(u,sum_three(scale(s,tc,2*Fc,nc),nc,scale(s,tc,m,ec),ec,scale(c,oc,m,ac),ac,lc,rc),rc)}}if(0!==v||0!==_){if(0!==h||0!==b||0!==d||0!==m){Mc=h*Fc;D=s*h;uc=D-(D-h);dc=h-uc;D=s*Fc;vc=D-(D-Fc);hc=Fc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=Ac*m;D=s*Ac;uc=D-(D-Ac);dc=Ac-uc;D=s*m;vc=D-(D-m);hc=m-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc+xc;C=mc-pc;O[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;O[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;O[2]=_c-(gc-C)+(mc-C);O[3]=gc;z=-kc;B=-b;Mc=d*z;D=s*d;uc=D-(D-d);dc=d-uc;D=s*z;vc=D-(D-z);hc=z-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=wc*B;D=s*wc;uc=D-(D-wc);dc=wc-uc;D=s*B;vc=D-(D-B);hc=B-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc+xc;C=mc-pc;P[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;P[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;P[2]=_c-(gc-C)+(mc-C);P[3]=gc;F=sum(4,O,4,P,Y);Mc=h*m;D=s*h;uc=D-(D-h);dc=h-uc;D=s*m;vc=D-(D-m);hc=m-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=d*b;D=s*d;uc=D-(D-d);dc=d-uc;D=s*b;vc=D-(D-b);hc=b-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc-xc;C=pc-mc;sc[0]=pc-(mc+C)+(C-xc);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc-$c;C=bc-mc;sc[1]=bc-(mc+C)+(C-$c);gc=_c+mc;C=gc-_c;sc[2]=_c-(gc-C)+(mc-C);sc[3]=gc;q=4}else{Y[0]=0;F=1;sc[0]=0;q=1}if(0!==v){const c=scale(F,Y,v,oc);u=finadd(u,sum(scale($,S,v,nc),nc,scale(c,oc,2*yc,ac),ac,ic),ic);const s=scale(q,sc,v,tc);u=finadd(u,sum_three(scale(s,tc,2*yc,nc),nc,scale(s,tc,v,ec),ec,scale(c,oc,v,ac),ac,lc,rc),rc);0!==b&&(u=finadd(u,scale(scale(4,K,v,tc),tc,b,nc),nc));0!==m&&(u=finadd(u,scale(scale(4,N,-v,tc),tc,m,nc),nc))}if(0!==_){const c=scale(F,Y,_,oc);u=finadd(u,sum(scale(x,T,_,nc),nc,scale(c,oc,2*jc,ac),ac,ic),ic);const s=scale(q,sc,_,tc);u=finadd(u,sum_three(scale(s,tc,2*jc,nc),nc,scale(s,tc,_,ec),ec,scale(c,oc,_,ac),ac,lc,rc),rc)}}if(0!==h||0!==b){if(0!==d||0!==m||0!==v||0!==_){Mc=d*jc;D=s*d;uc=D-(D-d);dc=d-uc;D=s*jc;vc=D-(D-jc);hc=jc-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=wc*_;D=s*wc;uc=D-(D-wc);dc=wc-uc;D=s*_;vc=D-(D-_);hc=_-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc+xc;C=mc-pc;O[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;O[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;O[2]=_c-(gc-C)+(mc-C);O[3]=gc;z=-Fc;B=-m;Mc=v*z;D=s*v;uc=D-(D-v);dc=v-uc;D=s*z;vc=D-(D-z);hc=z-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=yc*B;D=s*yc;uc=D-(D-yc);dc=yc-uc;D=s*B;vc=D-(D-B);hc=B-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc+xc;C=mc-pc;P[0]=pc-(mc-C)+(xc-C);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc+$c;C=mc-bc;P[1]=bc-(mc-C)+($c-C);gc=_c+mc;C=gc-_c;P[2]=_c-(gc-C)+(mc-C);P[3]=gc;y=sum(4,O,4,P,W);Mc=d*_;D=s*d;uc=D-(D-d);dc=d-uc;D=s*_;vc=D-(D-_);hc=_-vc;pc=dc*hc-(Mc-uc*vc-dc*vc-uc*hc);$c=v*m;D=s*v;uc=D-(D-v);dc=v-uc;D=s*m;vc=D-(D-m);hc=m-vc;xc=dc*hc-($c-uc*vc-dc*vc-uc*hc);mc=pc-xc;C=pc-mc;Z[0]=pc-(mc+C)+(C-xc);_c=Mc+mc;C=_c-Mc;bc=Mc-(_c-C)+(mc-C);mc=bc-$c;C=bc-mc;Z[1]=bc-(mc+C)+(C-$c);gc=_c+mc;C=gc-_c;Z[2]=_c-(gc-C)+(mc-C);Z[3]=gc;j=4}else{W[0]=0;y=1;Z[0]=0;j=1}if(0!==h){const c=scale(y,W,h,oc);u=finadd(u,sum(scale(g,U,h,nc),nc,scale(c,oc,2*Ac,ac),ac,ic),ic);const s=scale(j,Z,h,tc);u=finadd(u,sum_three(scale(s,tc,2*Ac,nc),nc,scale(s,tc,h,ec),ec,scale(c,oc,h,ac),ac,lc,rc),rc);0!==m&&(u=finadd(u,scale(scale(4,L,h,tc),tc,m,nc),nc));0!==_&&(u=finadd(u,scale(scale(4,K,-h,tc),tc,_,nc),nc))}if(0!==b){const c=scale(y,W,b,oc);u=finadd(u,sum(scale(w,V,b,nc),nc,scale(c,oc,2*kc,ac),ac,ic),ic);const s=scale(j,Z,b,tc);u=finadd(u,sum_three(scale(s,tc,2*kc,nc),nc,scale(s,tc,b,ec),ec,scale(c,oc,b,ac),ac,lc,rc),rc)}}return fc[u-1]}function incircle(c,s,t,n,e,o,a,l){const i=c-a;const r=t-a;const f=e-a;const u=s-l;const d=n-l;const v=o-l;const h=r*v;const m=f*d;const _=i*i+u*u;const b=f*u;const M=i*v;const p=r*r+d*d;const $=i*d;const x=r*u;const g=f*f+v*v;const w=_*(h-m)+p*(b-M)+g*($-x);const y=(Math.abs(h)+Math.abs(m))*_+(Math.abs(b)+Math.abs(M))*p+(Math.abs($)+Math.abs(x))*g;const A=D*y;return w>A||-w>A?w:incircleadapt(c,s,t,n,e,o,a,l,y)}function incirclefast(c,s,t,n,e,o,a,l){const i=c-a;const r=s-l;const f=t-a;const u=n-l;const d=e-a;const v=o-l;const h=i*u-f*r;const m=f*v-d*u;const _=d*r-i*v;const b=i*i+r*r;const M=f*f+u*u;const p=d*d+v*v;return b*m+M*_+p*h}const dc=(16+224*c)*c;const vc=(5+72*c)*c;const hc=(71+1408*c)*c*c;const mc=vec(4);const _c=vec(4);const bc=vec(4);const Mc=vec(4);const pc=vec(4);const $c=vec(4);const xc=vec(4);const gc=vec(4);const wc=vec(4);const yc=vec(4);const Ac=vec(24);const Fc=vec(24);const jc=vec(24);const kc=vec(24);const qc=vec(24);const zc=vec(24);const Bc=vec(24);const Cc=vec(24);const Dc=vec(24);const Ec=vec(24);const Gc=vec(1152);const Hc=vec(1152);const Ic=vec(1152);const Jc=vec(1152);const Kc=vec(1152);const Lc=vec(2304);const Nc=vec(2304);const Oc=vec(3456);const Pc=vec(5760);const Qc=vec(8);const Rc=vec(8);const Sc=vec(8);const Tc=vec(16);const Uc=vec(24);const Vc=vec(48);const Wc=vec(48);const Xc=vec(96);const Yc=vec(192);const Zc=vec(384);const cs=vec(384);const ss=vec(384);const ts=vec(768);function sum_three_scale(c,s,t,n,e,o,a){return sum_three(scale(4,c,n,Qc),Qc,scale(4,s,e,Rc),Rc,scale(4,t,o,Sc),Sc,Tc,a)}function liftexact(c,s,t,n,e,o,a,l,i,r,f,u){const d=sum(sum(c,s,t,n,Vc),Vc,negate(sum(e,o,a,l,Wc),Wc),Wc,Xc);return sum_three(scale(scale(d,Xc,i,Yc),Yc,i,Zc),Zc,scale(scale(d,Xc,r,Yc),Yc,r,cs),cs,scale(scale(d,Xc,f,Yc),Yc,f,ss),ss,ts,u)}function insphereexact(c,t,n,e,o,a,l,i,r,f,u,d,v,h,m){let _,b,M,p,$,x,g,w,y,A,F,j,k,q;A=c*o;b=s*c;M=b-(b-c);p=c-M;b=s*o;$=b-(b-o);x=o-$;F=p*x-(A-M*$-p*$-M*x);j=e*t;b=s*e;M=b-(b-e);p=e-M;b=s*t;$=b-(b-t);x=t-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;mc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;mc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;mc[2]=w-(q-_)+(g-_);mc[3]=q;A=e*i;b=s*e;M=b-(b-e);p=e-M;b=s*i;$=b-(b-i);x=i-$;F=p*x-(A-M*$-p*$-M*x);j=l*o;b=s*l;M=b-(b-l);p=l-M;b=s*o;$=b-(b-o);x=o-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;_c[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;_c[1]=y-(g+_)+(_-j);q=w+g;_=q-w;_c[2]=w-(q-_)+(g-_);_c[3]=q;A=l*u;b=s*l;M=b-(b-l);p=l-M;b=s*u;$=b-(b-u);x=u-$;F=p*x-(A-M*$-p*$-M*x);j=f*i;b=s*f;M=b-(b-f);p=f-M;b=s*i;$=b-(b-i);x=i-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;bc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;bc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;bc[2]=w-(q-_)+(g-_);bc[3]=q;A=f*h;b=s*f;M=b-(b-f);p=f-M;b=s*h;$=b-(b-h);x=h-$;F=p*x-(A-M*$-p*$-M*x);j=v*u;b=s*v;M=b-(b-v);p=v-M;b=s*u;$=b-(b-u);x=u-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;Mc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;Mc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;Mc[2]=w-(q-_)+(g-_);Mc[3]=q;A=v*t;b=s*v;M=b-(b-v);p=v-M;b=s*t;$=b-(b-t);x=t-$;F=p*x-(A-M*$-p*$-M*x);j=c*h;b=s*c;M=b-(b-c);p=c-M;b=s*h;$=b-(b-h);x=h-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;pc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;pc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;pc[2]=w-(q-_)+(g-_);pc[3]=q;A=c*i;b=s*c;M=b-(b-c);p=c-M;b=s*i;$=b-(b-i);x=i-$;F=p*x-(A-M*$-p*$-M*x);j=l*t;b=s*l;M=b-(b-l);p=l-M;b=s*t;$=b-(b-t);x=t-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;$c[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;$c[1]=y-(g+_)+(_-j);q=w+g;_=q-w;$c[2]=w-(q-_)+(g-_);$c[3]=q;A=e*u;b=s*e;M=b-(b-e);p=e-M;b=s*u;$=b-(b-u);x=u-$;F=p*x-(A-M*$-p*$-M*x);j=f*o;b=s*f;M=b-(b-f);p=f-M;b=s*o;$=b-(b-o);x=o-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;xc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;xc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;xc[2]=w-(q-_)+(g-_);xc[3]=q;A=l*h;b=s*l;M=b-(b-l);p=l-M;b=s*h;$=b-(b-h);x=h-$;F=p*x-(A-M*$-p*$-M*x);j=v*i;b=s*v;M=b-(b-v);p=v-M;b=s*i;$=b-(b-i);x=i-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;gc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;gc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;gc[2]=w-(q-_)+(g-_);gc[3]=q;A=f*t;b=s*f;M=b-(b-f);p=f-M;b=s*t;$=b-(b-t);x=t-$;F=p*x-(A-M*$-p*$-M*x);j=c*u;b=s*c;M=b-(b-c);p=c-M;b=s*u;$=b-(b-u);x=u-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;wc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;wc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;wc[2]=w-(q-_)+(g-_);wc[3]=q;A=v*o;b=s*v;M=b-(b-v);p=v-M;b=s*o;$=b-(b-o);x=o-$;F=p*x-(A-M*$-p*$-M*x);j=e*h;b=s*e;M=b-(b-e);p=e-M;b=s*h;$=b-(b-h);x=h-$;k=p*x-(j-M*$-p*$-M*x);g=F-k;_=F-g;yc[0]=F-(g+_)+(_-k);w=A+g;_=w-A;y=A-(w-_)+(g-_);g=y-j;_=y-g;yc[1]=y-(g+_)+(_-j);q=w+g;_=q-w;yc[2]=w-(q-_)+(g-_);yc[3]=q;const z=sum_three_scale(mc,_c,$c,r,n,-a,Ac);const B=sum_three_scale(_c,bc,xc,d,a,-r,Fc);const C=sum_three_scale(bc,Mc,gc,m,r,-d,jc);const D=sum_three_scale(Mc,pc,wc,n,d,-m,kc);const E=sum_three_scale(pc,mc,yc,a,m,-n,qc);const G=sum_three_scale(mc,xc,wc,d,n,a,zc);const H=sum_three_scale(_c,gc,yc,m,a,r,Bc);const I=sum_three_scale(bc,wc,$c,n,r,d,Cc);const J=sum_three_scale(Mc,yc,xc,a,d,m,Dc);const K=sum_three_scale(pc,$c,gc,r,m,n,Ec);const L=sum_three(liftexact(C,jc,H,Bc,J,Dc,B,Fc,c,t,n,Gc),Gc,liftexact(D,kc,I,Cc,K,Ec,C,jc,e,o,a,Hc),Hc,sum_three(liftexact(E,qc,J,Dc,G,zc,D,kc,l,i,r,Ic),Ic,liftexact(z,Ac,K,Ec,H,Bc,E,qc,f,u,d,Jc),Jc,liftexact(B,Fc,G,zc,I,Cc,z,Ac,v,h,m,Kc),Kc,Nc,Oc),Oc,Lc,Pc);return Pc[L-1]}const ns=vec(96);const es=vec(96);const os=vec(96);const as=vec(1152);function liftadapt(c,s,t,n,e,o,a,l,i,r){const f=sum_three_scale(c,s,t,n,e,o,Uc);return sum_three(scale(scale(f,Uc,a,Vc),Vc,a,ns),ns,scale(scale(f,Uc,l,Vc),Vc,l,es),es,scale(scale(f,Uc,i,Vc),Vc,i,os),os,Yc,r)}function insphereadapt(c,n,e,o,a,l,i,r,f,u,d,v,h,m,_,b){let M,p,$,x,g,w;let y,A,F,j;let k,q,z,B;let C,D,E,G;let H,I,J,K,L,N,O,P,Q,R,S,T,U;const V=c-h;const W=o-h;const X=i-h;const Y=u-h;const Z=n-m;const cc=a-m;const sc=r-m;const tc=d-m;const nc=e-_;const ec=l-_;const oc=f-_;const ac=v-_;R=V*cc;I=s*V;J=I-(I-V);K=V-J;I=s*cc;L=I-(I-cc);N=cc-L;S=K*N-(R-J*L-K*L-J*N);T=W*Z;I=s*W;J=I-(I-W);K=W-J;I=s*Z;L=I-(I-Z);N=Z-L;U=K*N-(T-J*L-K*L-J*N);O=S-U;H=S-O;mc[0]=S-(O+H)+(H-U);P=R+O;H=P-R;Q=R-(P-H)+(O-H);O=Q-T;H=Q-O;mc[1]=Q-(O+H)+(H-T);M=P+O;H=M-P;mc[2]=P-(M-H)+(O-H);mc[3]=M;R=W*sc;I=s*W;J=I-(I-W);K=W-J;I=s*sc;L=I-(I-sc);N=sc-L;S=K*N-(R-J*L-K*L-J*N);T=X*cc;I=s*X;J=I-(I-X);K=X-J;I=s*cc;L=I-(I-cc);N=cc-L;U=K*N-(T-J*L-K*L-J*N);O=S-U;H=S-O;_c[0]=S-(O+H)+(H-U);P=R+O;H=P-R;Q=R-(P-H)+(O-H);O=Q-T;H=Q-O;_c[1]=Q-(O+H)+(H-T);p=P+O;H=p-P;_c[2]=P-(p-H)+(O-H);_c[3]=p;R=X*tc;I=s*X;J=I-(I-X);K=X-J;I=s*tc;L=I-(I-tc);N=tc-L;S=K*N-(R-J*L-K*L-J*N);T=Y*sc;I=s*Y;J=I-(I-Y);K=Y-J;I=s*sc;L=I-(I-sc);N=sc-L;U=K*N-(T-J*L-K*L-J*N);O=S-U;H=S-O;bc[0]=S-(O+H)+(H-U);P=R+O;H=P-R;Q=R-(P-H)+(O-H);O=Q-T;H=Q-O;bc[1]=Q-(O+H)+(H-T);$=P+O;H=$-P;bc[2]=P-($-H)+(O-H);bc[3]=$;R=Y*Z;I=s*Y;J=I-(I-Y);K=Y-J;I=s*Z;L=I-(I-Z);N=Z-L;S=K*N-(R-J*L-K*L-J*N);T=V*tc;I=s*V;J=I-(I-V);K=V-J;I=s*tc;L=I-(I-tc);N=tc-L;U=K*N-(T-J*L-K*L-J*N);O=S-U;H=S-O;wc[0]=S-(O+H)+(H-U);P=R+O;H=P-R;Q=R-(P-H)+(O-H);O=Q-T;H=Q-O;wc[1]=Q-(O+H)+(H-T);x=P+O;H=x-P;wc[2]=P-(x-H)+(O-H);wc[3]=x;R=V*sc;I=s*V;J=I-(I-V);K=V-J;I=s*sc;L=I-(I-sc);N=sc-L;S=K*N-(R-J*L-K*L-J*N);T=X*Z;I=s*X;J=I-(I-X);K=X-J;I=s*Z;L=I-(I-Z);N=Z-L;U=K*N-(T-J*L-K*L-J*N);O=S-U;H=S-O;$c[0]=S-(O+H)+(H-U);P=R+O;H=P-R;Q=R-(P-H)+(O-H);O=Q-T;H=Q-O;$c[1]=Q-(O+H)+(H-T);g=P+O;H=g-P;$c[2]=P-(g-H)+(O-H);$c[3]=g;R=W*tc;I=s*W;J=I-(I-W);K=W-J;I=s*tc;L=I-(I-tc);N=tc-L;S=K*N-(R-J*L-K*L-J*N);T=Y*cc;I=s*Y;J=I-(I-Y);K=Y-J;I=s*cc;L=I-(I-cc);N=cc-L;U=K*N-(T-J*L-K*L-J*N);O=S-U;H=S-O;xc[0]=S-(O+H)+(H-U);P=R+O;H=P-R;Q=R-(P-H)+(O-H);O=Q-T;H=Q-O;xc[1]=Q-(O+H)+(H-T);w=P+O;H=w-P;xc[2]=P-(w-H)+(O-H);xc[3]=w;const lc=sum(sum(negate(liftadapt(_c,bc,xc,ac,ec,-oc,V,Z,nc,Gc),Gc),Gc,liftadapt(bc,wc,$c,nc,oc,ac,W,cc,ec,Hc),Hc,Lc),Lc,sum(negate(liftadapt(wc,mc,xc,ec,ac,nc,X,sc,oc,Ic),Ic),Ic,liftadapt(mc,_c,$c,oc,nc,-ec,Y,tc,ac,Jc),Jc,Nc),Nc,as);let ic=estimate(lc,as);let rc=vc*b;if(ic>=rc||-ic>=rc)return ic;H=c-V;y=c-(V+H)+(H-h);H=n-Z;k=n-(Z+H)+(H-m);H=e-nc;C=e-(nc+H)+(H-_);H=o-W;A=o-(W+H)+(H-h);H=a-cc;q=a-(cc+H)+(H-m);H=l-ec;D=l-(ec+H)+(H-_);H=i-X;F=i-(X+H)+(H-h);H=r-sc;z=r-(sc+H)+(H-m);H=f-oc;E=f-(oc+H)+(H-_);H=u-Y;j=u-(Y+H)+(H-h);H=d-tc;B=d-(tc+H)+(H-m);H=v-ac;G=v-(ac+H)+(H-_);if(0===y&&0===k&&0===C&&0===A&&0===q&&0===D&&0===F&&0===z&&0===E&&0===j&&0===B&&0===G)return ic;rc=hc*b+t*Math.abs(ic);const fc=V*q+cc*y-(Z*A+W*k);const uc=W*z+sc*A-(cc*F+X*q);const dc=X*B+tc*F-(sc*j+Y*z);const Mc=Y*k+Z*j-(tc*y+V*B);const pc=V*z+sc*y-(Z*F+X*k);const gc=W*B+tc*A-(cc*j+Y*q);ic+=(W*W+cc*cc+ec*ec)*(oc*Mc+ac*pc+nc*dc+(E*x+G*g+C*$))+(Y*Y+tc*tc+ac*ac)*(nc*uc-ec*pc+oc*fc+(C*p-D*g+E*M))-((V*V+Z*Z+nc*nc)*(ec*dc-oc*gc+ac*uc+(D*$-E*w+G*p))+(X*X+sc*sc+oc*oc)*(ac*fc+nc*gc+ec*Mc+(G*M+C*w+D*x)))+2*((W*A+cc*q+ec*D)*(oc*x+ac*g+nc*$)+(Y*j+tc*B+ac*G)*(nc*p-ec*g+oc*M)-((V*y+Z*k+nc*C)*(ec*$-oc*w+ac*p)+(X*F+sc*z+oc*E)*(ac*M+nc*w+ec*x)));return ic>=rc||-ic>=rc?ic:insphereexact(c,n,e,o,a,l,i,r,f,u,d,v,h,m,_)}function insphere(c,s,t,n,e,o,a,l,i,r,f,u,d,v,h){const m=c-d;const _=n-d;const b=a-d;const M=r-d;const p=s-v;const $=e-v;const x=l-v;const g=f-v;const w=t-h;const y=o-h;const A=i-h;const F=u-h;const j=m*$;const k=_*p;const q=j-k;const z=_*x;const B=b*$;const C=z-B;const D=b*g;const E=M*x;const G=D-E;const H=M*p;const I=m*g;const J=H-I;const K=m*x;const L=b*p;const N=K-L;const O=_*g;const P=M*$;const Q=O-P;const R=m*m+p*p+w*w;const S=_*_+$*$+y*y;const T=b*b+x*x+A*A;const U=M*M+g*g+F*F;const V=T*(F*q+w*Q+y*J)-U*(w*C-y*N+A*q)+(R*(y*G-A*Q+F*C)-S*(A*J+F*N+w*G));const W=Math.abs(w);const X=Math.abs(y);const Y=Math.abs(A);const Z=Math.abs(F);const cc=Math.abs(j)+Math.abs(k);const sc=Math.abs(z)+Math.abs(B);const tc=Math.abs(D)+Math.abs(E);const nc=Math.abs(H)+Math.abs(I);const ec=Math.abs(K)+Math.abs(L);const oc=Math.abs(O)+Math.abs(P);const ac=(tc*X+oc*Y+sc*Z)*R+(nc*Y+ec*Z+tc*W)*S+(cc*Z+oc*W+nc*X)*T+(sc*W+ec*X+cc*Y)*U;const lc=dc*ac;return V>lc||-V>lc?V:-insphereadapt(c,s,t,n,e,o,a,l,i,r,f,u,d,v,h,ac)}function inspherefast(c,s,t,n,e,o,a,l,i,r,f,u,d,v,h){const m=c-d;const _=n-d;const b=a-d;const M=r-d;const p=s-v;const $=e-v;const x=l-v;const g=f-v;const w=t-h;const y=o-h;const A=i-h;const F=u-h;const j=m*$-_*p;const k=_*x-b*$;const q=b*g-M*x;const z=M*p-m*g;const B=m*x-b*p;const C=_*g-M*$;const D=w*k-y*B+A*j;const E=y*q-A*C+F*k;const G=A*z+F*B+w*q;const H=F*j+w*C+y*z;const I=m*m+p*p+w*w;const J=_*_+$*$+y*y;const K=b*b+x*x+A*A;const L=M*M+g*g+F*F;return K*H-L*D+(I*E-J*G)}export{incircle,incirclefast,insphere,inspherefast,orient2d,orient2dfast,orient3d,orient3dfast};\n\n"
  }
]